Compare commits

...

12 Commits

Author SHA1 Message Date
Matt Walsh
4903b95fec 6.1.7 2025-09-09 20:26:37 -05:00
Matt Walsh
b43fb32820 Merge branch 'ios-metar-regex' 2025-09-09 20:26:29 -05:00
Matt Walsh
0d0c4ec452 fix Dockerfile.server build close #142 2025-09-09 20:06:54 -05:00
Matt Walsh
49d18c2fbe 6.1.6 2025-09-09 19:36:33 -05:00
Matt Walsh
1732a3381f fix hazards displaying when disabled (sometimes) close #140 2025-09-09 19:36:23 -05:00
Matt Walsh
093b6ac239 unique version numbers for staging uploads 2025-09-04 21:57:12 -05:00
Matt Walsh
12d068d740 playlist info in readme close #138 2025-09-04 21:26:01 -05:00
Matt Walsh
517c560ef6 don't parse metar for old ios versions 2025-09-03 21:46:48 -05:00
Matt Walsh
3eb571bed4 update community notes in readme close #135 2025-08-23 16:28:02 -05:00
Matt Walsh
52ca161bdb 6.1.5 2025-08-15 15:13:39 -05:00
Matt Walsh
ee5690dcad Merge branch 'station-data' 2025-08-15 15:01:00 -05:00
Matt Walsh
c05b827593 move station post processor inline with api gets 2025-08-15 14:59:16 -05:00
12 changed files with 37268 additions and 18639 deletions

12
.vscode/launch.json vendored
View File

@@ -26,6 +26,18 @@
"skipFiles": [ "skipFiles": [
"<node_internals>/**" "<node_internals>/**"
], ],
"args": [
"--use-cache"
],
"type": "node"
},
{
"name": "Data:stations-api",
"program": "${workspaceFolder}/datagenerators/stations.mjs",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node" "type": "node"
}, },
{ {

View File

@@ -2,7 +2,7 @@ FROM node:24-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --legacy-peer-deps RUN npm ci --legacy-peer-deps
COPY . . COPY . .
RUN npm run build RUN npm run build

View File

@@ -309,11 +309,13 @@ If you're unable to pre-set the play state before entering kiosk mode (such as w
## Community Notes ## Community Notes
Thanks to the WeatherStar community for providing these discussions to further extend your retro forecasts! Thanks to the WeatherStar+ community for providing these discussions to further extend your retro forecasts!
* [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948) * [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948)
* [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution. * [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution.
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg. * [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
* [SSL Certificates](https://github.com/netbymatt/ws4kp/issues/135) Discussion about how to host with an SSL certificate (enables geolocation).
* [Changing playlists](https://github.com/netbymatt/ws4kp/issues/138) Possible ways to automatically change the playlist on a schedule.
## Customization ## Customization

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
import { readFileSync, writeFileSync } from 'fs'; import { readFileSync, writeFileSync } from 'fs';
import * as url from 'node:url';
// Load station data // Load station data
const stationInfo = JSON.parse(readFileSync('./datagenerators/output/stations-raw.json', 'utf8')); const stationInfo = JSON.parse(readFileSync('./datagenerators/output/stations-raw.json', 'utf8'));
// const regionalCities = JSON.parse(readFileSync('./datagenerators/output/regionalcities.json', 'utf8')); // const regionalCities = JSON.parse(readFileSync('./datagenerators/output/regionalcities.json', 'utf8'));
@@ -1109,14 +1111,31 @@ or where the fallback to the ICAO airport code occurred:
jq -c '.[] | select(.name | test("^[A-Z]{3}$")) | {state, city, simple, name}' jq -c '.[] | select(.name | test("^[A-Z]{3}$")) | {state, city, simple, name}'
*/ */
const diffMode = process.argv.includes('--diff'); const readArguments = () => ({
const onlyProblems = process.argv.includes('--only-problems'); diffMode: process.argv.includes('--diff'),
const noProblems = process.argv.includes('--no-problems'); onlyProblems: process.argv.includes('--only-problems'),
const onlyDuplicates = process.argv.includes('--only-dupes'); noProblems: process.argv.includes('--no-problems'),
const noPriority = process.argv.includes('--no-priority'); onlyDuplicates: process.argv.includes('--only-dupes'),
const noSimple = process.argv.includes('--no-simple'); noPriority: process.argv.includes('--no-priority'),
const noCoordinates = process.argv.includes('--no-coords'); noSimple: process.argv.includes('--no-simple'),
const writeFile = process.argv.includes('--write'); noCoordinates: process.argv.includes('--no-coords'),
writeFile: process.argv.includes('--write'),
});
const DEFAULT_OPTIONS = {
diffMode: false,
onlyProblems: false,
noProblems: false,
onlyDuplicates: false,
noPriority: false,
noSimple: false,
noCoordinates: false,
writeFile: false,
};
const postProcessor = (_options) => {
// combine default and provided options
const options = { ...DEFAULT_OPTIONS, ..._options };
// Process ALL stations at once to get the display name map // Process ALL stations at once to get the display name map
let displayNameMap = processAllStations(stationInfo); let displayNameMap = processAllStations(stationInfo);
@@ -1196,10 +1215,10 @@ cleanedMapByState.forEach((stateMap, _state) => {
// Filter results if requested // Filter results if requested
let finalResults = results; let finalResults = results;
if (onlyProblems) { if (options.onlyProblems) {
finalResults = results.filter((r) => r.potentialIssues.length > 0); finalResults = results.filter((r) => r.potentialIssues.length > 0);
} }
if (onlyDuplicates) { if (options.onlyDuplicates) {
finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate')); finalResults = finalResults.filter((r) => r.potentialIssues.includes('duplicate'));
} }
@@ -1207,7 +1226,7 @@ const outputResult = finalResults.map((result) => {
let outputItem = result; let outputItem = result;
// Don't include lat or long in diff mode // Don't include lat or long in diff mode
if (noCoordinates || diffMode) { if (options.noCoordinates || options.diffMode) {
const { const {
lat: _lat, lon: _lon, ...resultWithoutLocation lat: _lat, lon: _lon, ...resultWithoutLocation
} = result; } = result;
@@ -1215,19 +1234,19 @@ const outputResult = finalResults.map((result) => {
} }
// Don't include potentialIssues when --no-problems is specified // Don't include potentialIssues when --no-problems is specified
if (noProblems || diffMode) { if (options.noProblems || options.diffMode) {
const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem; const { potentialIssues: _potentialIssues, ...resultWithoutIssues } = outputItem;
outputItem = resultWithoutIssues; outputItem = resultWithoutIssues;
} }
// Remove type and priority if --no-priority is specified // Remove type and priority if --no-priority is specified
if (noPriority || diffMode) { if (options.noPriority || options.diffMode) {
const { type: _type, priority: _priority, ...resultWithoutPriority } = outputItem; const { type: _type, priority: _priority, ...resultWithoutPriority } = outputItem;
outputItem = resultWithoutPriority; outputItem = resultWithoutPriority;
} }
// remove simple field if --no-simple is specified // remove simple field if --no-simple is specified
if (noSimple || diffMode) { if (options.noSimple || options.diffMode) {
const { simple: _simple, ...resultWithoutSimple } = outputItem; const { simple: _simple, ...resultWithoutSimple } = outputItem;
outputItem = resultWithoutSimple; outputItem = resultWithoutSimple;
} }
@@ -1235,13 +1254,41 @@ const outputResult = finalResults.map((result) => {
return outputItem; return outputItem;
}); });
if (writeFile) {
const fileResults = results.map(({ const fileResults = results.map(({
simple: _simple, type: _type, potentialIssues: _potentialIssues, ...rest simple: _simple, type: _type, potentialIssues: _potentialIssues, location: _location, ...rest
}) => rest); }) => rest);
if (options.writeFile) {
writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults)); writeFileSync('./datagenerators/output/stations.json', compactStringifyToObject(fileResults));
console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`); console.log(`Wrote ${fileResults.length} processed stations to datagenerators/output/stations.json`);
} else { } else {
console.log(compactStringifyToArray(outputResult)); console.log(compactStringifyToArray(outputResult));
} }
// array to output object
const returnObject = {};
fileResults.forEach((item) => {
returnObject[item.id] = item;
});
return returnObject;
};
// determine if running from command line or module
const commandLine = (() => {
if (import.meta.url.startsWith('file:')) { // (A)
const modulePath = url.fileURLToPath(import.meta.url);
if (process.argv[1] === modulePath) { // (B)
return true;
}
}
return false;
}
)();
// run post processor if called from command line
if (commandLine) {
postProcessor(readArguments());
}
export default postProcessor;

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-loop-func */
// list all stations in a single file // list all stations in a single file
// only find stations with 4 letter codes // only find stations with 4 letter codes
@@ -6,16 +7,23 @@ import https from './https.mjs';
import states from './stations-states.mjs'; import states from './stations-states.mjs';
import chunk from './chunk.mjs'; import chunk from './chunk.mjs';
import overrides from './stations-overrides.mjs'; import overrides from './stations-overrides.mjs';
import postProcessor from './stations-postprocessor.mjs';
// check for cached flag
const USE_CACHE = process.argv.includes('--use-cache');
// skip stations starting with these letters // skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J']; const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// chunk the list of states // chunk the list of states
const chunkStates = chunk(states, 1); const chunkStates = chunk(states, 3);
// store output // store output
const output = {}; const output = {};
let completed = 0;
// get data from api if desired
if (!USE_CACHE) {
// process all chunks // process all chunks
for (let i = 0; i < chunkStates.length; i += 1) { for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i]; const stateChunk = chunkStates[i];
@@ -44,16 +52,12 @@ for (let i = 0; i < chunkStates.length; i += 1) {
console.log(`Duplicate station: ${state}-${id}`); console.log(`Duplicate station: ${state}-${id}`);
return; return;
} }
// get any overrides if available
const override = overrides[id] ?? {};
output[id] = { output[id] = {
id, id,
city: station.properties.name, city: station.properties.name,
state, state,
lat: station.geometry.coordinates[1], lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0], lon: station.geometry.coordinates[0],
// finally add the overrides
...override,
}; };
}); });
next = stations?.pagination?.next; next = stations?.pagination?.next;
@@ -62,7 +66,8 @@ for (let i = 0; i < chunkStates.length; i += 1) {
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2)); writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
} }
while (next && stations.features.length > 0); while (next && stations.features.length > 0);
console.log(`Complete: ${state}`); completed += 1;
console.log(`Complete: ${state} ${completed}/${states.length}`);
return true; return true;
} catch { } catch {
console.error(`Unable to get state: ${state}`); console.error(`Unable to get state: ${state}`);
@@ -70,3 +75,23 @@ for (let i = 0; i < chunkStates.length; i += 1) {
} }
})); }));
} }
}
// run the post processor
// data is passed through the file stations-raw.json
const postProcessed = postProcessor();
// apply any overrides
Object.entries(overrides).forEach(([id, values]) => {
// check for existing value
if (postProcessed[id]) {
// apply the overrides
postProcessed[id] = {
...postProcessed[id],
...values,
};
}
});
// write final file to disk
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(postProcessed, null, 2));

View File

@@ -97,11 +97,16 @@ const copyCss = () => src(cssSources)
const htmlSources = [ const htmlSources = [
'views/*.ejs', 'views/*.ejs',
]; ];
const compressHtml = async () => {
const packageJson = await readFile('package.json'); const packageJson = await readFile('package.json');
const { version } = JSON.parse(packageJson); let { version } = JSON.parse(packageJson);
const previewVersion = async () => {
// generate a relatively unique timestamp for cache invalidation of the preview site
const now = new Date();
const msNow = now.getTime() % 1_000_000;
version = msNow.toString();
};
return src(htmlSources) const compressHtml = async () => src(htmlSources)
.pipe(ejs({ .pipe(ejs({
production: version, production: version,
serverAvailable: false, serverAvailable: false,
@@ -112,7 +117,6 @@ const compressHtml = async () => {
.pipe(rename({ extname: '.html' })) .pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true })) .pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest('./dist')); .pipe(dest('./dist'));
};
const otherFiles = [ const otherFiles = [
'server/robots.txt', 'server/robots.txt',
@@ -205,7 +209,7 @@ const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyCss, com
// upload_images could be in parallel with upload, but _images logs a lot and has little changes // upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing // by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate); const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
const stageFrontend = series(buildDist, uploadImagesPreview, uploadPreview, invalidatePreview); const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
export default publishFrontend; export default publishFrontend;

2488
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "6.1.4", "version": "6.1.7",
"description": "Welcome to the WeatherStar 4000+ project page!", "description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
@@ -56,6 +56,7 @@
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"ejs": "^3.1.5", "ejs": "^3.1.5",
"express": "^5.1.0", "express": "^5.1.0",
"metar-taf-parser": "^9.0.0" "metar-taf-parser": "^9.0.0",
"npm": "^11.6.0"
} }
} }

View File

@@ -103,7 +103,10 @@ class Hazards extends WeatherDisplay {
// show alert indicator // show alert indicator
if (unViewed > 0) alert.classList.add('show'); if (unViewed > 0) alert.classList.add('show');
// draw the canvas to calculate the new timings and activate hazards in the slide deck again // draw the canvas to calculate the new timings and activate hazards in the slide deck again
// unless this has been disabled
if (this.isEnabled) {
this.drawLongCanvas(); this.drawLongCanvas();
}
} catch (error) { } catch (error) {
console.error(`Unexpected Active Alerts error: ${error.message}`); console.error(`Unexpected Active Alerts error: ${error.message}`);
if (this.isEnabled) this.setStatus(STATUS.failed); if (this.isEnabled) this.setStatus(STATUS.failed);
@@ -115,7 +118,7 @@ class Hazards extends WeatherDisplay {
this.getDataCallback(); this.getDataCallback();
if (!superResult) { if (!superResult) {
this.setStatus(STATUS.loaded); // Don't override status - super.getData() already set it to STATUS.disabled
return; return;
} }
this.drawLongCanvas(); this.drawLongCanvas();

View File

@@ -3,13 +3,32 @@ import { parseMetar } from '../../vendor/auto/metar-taf-parser.mjs';
// eslint-disable-next-line import/extensions // eslint-disable-next-line import/extensions
import en from '../../vendor/auto/locale/en.js'; import en from '../../vendor/auto/locale/en.js';
// metar-taf-parser requires regex lookbehind
// this does not work in iOS < 16.4
// this is a detection algorithm for iOS versions
const isIos = /iP(ad|od|hone)/i.test(window.navigator.userAgent);
let iosVersionOk = false;
if (isIos) {
// regex match the version string
const iosVersionRaw = /OS (\d+)_(\d+)/.exec(window.navigator.userAgent);
// check for match
if (iosVersionRaw) {
// break into parts
const iosVersionMajor = parseInt(iosVersionRaw[1], 10);
const iosVersionMinor = parseInt(iosVersionRaw[2], 10);
if (iosVersionMajor > 16) iosVersionOk = true;
if (iosVersionMajor === 16 && iosVersionMinor >= 4) iosVersionOk = true;
}
}
/** /**
* Augment observation data by parsing METAR when API fields are missing * Augment observation data by parsing METAR when API fields are missing
* @param {Object} observation - The observation object from the API * @param {Object} observation - The observation object from the API
* @returns {Object} - Augmented observation with parsed METAR data filled in * @returns {Object} - Augmented observation with parsed METAR data filled in
*/ */
const augmentObservationWithMetar = (observation) => { const augmentObservationWithMetar = (observation) => {
if (!observation?.rawMessage) { // check for a metar message and for unusable ios versions
if (!observation?.rawMessage || (isIos && !iosVersionOk)) {
return observation; return observation;
} }