Compare commits

..

9 Commits

Author SHA1 Message Date
Matt Walsh
ce99fc16e7 5.16.6 2025-05-12 10:48:12 -05:00
Matt Walsh
97f96d4091 fix missing states in station list 2025-05-12 10:47:43 -05:00
Matt Walsh
13f08b62cc 5.16.5 2025-05-02 23:22:39 -05:00
Matt Walsh
eacd82b4f4 fix hourly graph not updating close #77 2025-05-02 23:22:24 -05:00
Matt Walsh
91f669e828 add streaming to readme 2025-05-02 10:52:32 -05:00
Matt Walsh
cdbe3d968f city spelling corrections 2025-04-29 16:38:54 -05:00
Matt Walsh
0a388cdb83 clean up settings constructor 2025-04-08 10:40:36 -05:00
Matt Walsh
8678d9f053 5.16.4 2025-04-07 22:13:33 -05:00
Matt Walsh
a592668d0d hourly color coded feels like temperatures close #69 2025-04-07 22:13:20 -05:00
20 changed files with 22850 additions and 35921 deletions

3
.vscode/launch.json vendored
View File

@@ -15,6 +15,9 @@
"**/*.min.js", "**/*.min.js",
"**/vendor/**" "**/vendor/**"
], ],
"runtimeArgs": [
"--autoplay-policy=no-user-gesture-required"
]
}, },
{ {
"name": "Data:stations", "name": "Data:stations",

View File

@@ -129,6 +129,7 @@ Thanks to the WeatherStar community for providing these discussions to further e
* [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.
## Customization ## Customization
A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it. A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.

View File

@@ -1150,7 +1150,7 @@
} }
}, },
{ {
"city": "Tucsan", "city": "Tucson",
"lat": 32.2216, "lat": 32.2216,
"lon": -110.9698, "lon": -110.9698,
"point": { "point": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -575,7 +575,7 @@
"lon": -77.9447 "lon": -77.9447
}, },
{ {
"city": "Tucsan", "city": "Tucson",
"lat": 32.2216, "lat": 32.2216,
"lon": -110.9698 "lon": -110.9698
} }

View File

@@ -9,61 +9,59 @@ import chunk from './chunk.mjs';
// 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'];
// immediately invoked function so we can access async/await // chunk the list of states
const start = async () => { const chunkStates = chunk(states, 1);
// chunk the list of states
const chunkStates = chunk(states, 5);
// store output // store output
const output = {}; const output = {};
// 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];
// loop through states // loop through states
stateChunk.forEach(async (state) => { // eslint-disable-next-line no-await-in-loop
try { await Promise.allSettled(stateChunk.map(async (state) => {
let stations; try {
let next = `https://api.weather.gov/stations?state=${state}`; let stations;
do { let next = `https://api.weather.gov/stations?state=${state}`;
// get list and parse the JSON let round = 0;
// eslint-disable-next-line no-await-in-loop do {
const stationsRaw = await https(next); console.log(`Getting: ${state}-${round}`);
stations = JSON.parse(stationsRaw); // get list and parse the JSON
// filter stations for 4 letter identifiers // eslint-disable-next-line no-await-in-loop
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/)); const stationsRaw = await https(next);
// filter against starting letter stations = JSON.parse(stationsRaw);
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); // filter stations for 4 letter identifiers
// add each resulting station to the output const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
stationsFiltered.forEach((station) => { // filter against starting letter
const id = station.properties.stationIdentifier; const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
if (output[id]) { // add each resulting station to the output
console.log(`Duplicate station: ${state}-${id}`); stationsFiltered.forEach((station) => {
return; const id = station.properties.stationIdentifier;
} if (output[id]) {
output[id] = { console.log(`Duplicate station: ${state}-${id}`);
id, return;
city: station.properties.name, }
state, output[id] = {
lat: station.geometry.coordinates[1], id,
lon: station.geometry.coordinates[0], city: station.properties.name,
}; state,
}); lat: station.geometry.coordinates[1],
next = stations?.pagination?.next; lon: station.geometry.coordinates[0],
// write the output };
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2)); });
} next = stations?.pagination?.next;
while (next && stations.features.length > 0); round += 1;
console.log(`Complete: ${state}`); // write the output
return true; writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2));
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
} }
}); while (next && stations.features.length > 0);
} console.log(`Complete: ${state}`);
}; return true;
} catch (e) {
// immediately invoked function allows access to async console.error(`Unable to get state: ${state}`);
await start(); return false;
}
}));
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "5.16.3", "version": "5.16.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ws4kp", "name": "ws4kp",
"version": "5.16.3", "version": "5.16.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ejs": "^3.1.5", "ejs": "^3.1.5",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ws4kp", "name": "ws4kp",
"version": "5.16.3", "version": "5.16.6",
"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",

View File

@@ -1151,7 +1151,7 @@ const RegionalCities = [
} }
}, },
{ {
"city": "Tucsan", "city": "Tucson",
"lat": 32.2216, "lat": 32.2216,
"lon": -110.9698, "lon": -110.9698,
"point": { "point": {

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,8 @@ class CurrentWeather extends WeatherDisplay {
// make data available outside this class // make data available outside this class
// promise allows for data to be requested before it is available // promise allows for data to be requested before it is available
async getCurrentWeather(stillWaiting) { async getCurrentWeather(stillWaiting) {
// an external caller has requested data, set up auto reload
this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.data) resolve(this.data); if (this.data) resolve(this.data);

View File

@@ -76,9 +76,9 @@ class Hourly extends WeatherDisplay {
const temperature = data.temperature.toString().padStart(3); const temperature = data.temperature.toString().padStart(3);
const feelsLike = data.apparentTemperature.toString().padStart(3); const feelsLike = data.apparentTemperature.toString().padStart(3);
fillValues.temp = temperature; fillValues.temp = temperature;
// only plot apparent temperature if there is a difference
// if (temperature !== feelsLike) line.querySelector('.like').innerHTML = feelsLike; // apparent temperature is color coded if different from actual temperature (after fill is applied)
if (temperature !== feelsLike) fillValues.like = feelsLike; fillValues.like = feelsLike;
// wind // wind
let wind = 'Calm'; let wind = 'Calm';
@@ -91,7 +91,17 @@ class Hourly extends WeatherDisplay {
// image // image
fillValues.icon = { type: 'img', src: data.icon }; fillValues.icon = { type: 'img', src: data.icon };
return this.fillTemplate('hourly-row', fillValues); const filledRow = this.fillTemplate('hourly-row', fillValues);
// alter the color of the feels like column to reflect wind chill or heat index
if (feelsLike < temperature) {
filledRow.querySelector('.like').classList.add('wind-chill');
}
if (feelsLike > temperature) {
filledRow.querySelector('.like').classList.add('heat-index');
}
return filledRow;
}); });
list.append(...lines); list.append(...lines);
@@ -129,6 +139,8 @@ class Hourly extends WeatherDisplay {
// promise allows for data to be requested before it is available // promise allows for data to be requested before it is available
async getCurrentData(stillWaiting) { async getCurrentData(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
// an external caller has requested data, set up auto reload
this.setAutoReload();
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.data) resolve(this.data); if (this.data) resolve(this.data);
// data not available, put it into the data callback queue // data not available, put it into the data callback queue

View File

@@ -5,7 +5,12 @@ let playlist;
let currentTrack = 0; let currentTrack = 0;
let player; let player;
const mediaPlaying = new Setting('mediaPlaying', 'Media Playing', 'boolean', false, null, true); const mediaPlaying = new Setting('mediaPlaying', {
name: 'Media Playing',
type: 'boolean',
defaultValue: false,
sticky: true,
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// add the event handler to the page // add the event handler to the page

View File

@@ -8,27 +8,54 @@ document.addEventListener('DOMContentLoaded', () => {
const settings = { speed: { value: 1.0 } }; const settings = { speed: { value: 1.0 } };
const init = () => { const init = () => {
// create settings // create settings see setting.mjs for defaults
settings.wide = new Setting('wide', 'Widescreen', 'checkbox', false, wideScreenChange, true); settings.wide = new Setting('wide', {
settings.kiosk = new Setting('kiosk', 'Kiosk', 'checkbox', false, kioskChange, false); name: 'Widescreen',
settings.speed = new Setting('speed', 'Speed', 'select', 1.0, null, true, [ defaultValue: false,
[0.5, 'Very Fast'], changeAction: wideScreenChange,
[0.75, 'Fast'], sticky: true,
[1.0, 'Normal'], });
[1.25, 'Slow'], settings.kiosk = new Setting('kiosk', {
[1.5, 'Very Slow'], name: 'Kiosk',
]); defaultValue: false,
settings.units = new Setting('units', 'Units', 'select', 'us', unitChange, true, [ changeAction: kioskChange,
['us', 'US'], sticky: false,
['si', 'Metric'], });
]); settings.speed = new Setting('speed', {
settings.refreshTime = new Setting('refreshTime', 'Refresh Time', 'select', 600_000, null, false, [ name: 'Speed',
[30_000, 'TESTING'], type: 'select',
[300_000, '5 minutes'], defaultValue: 1.0,
[600_000, '10 minutes'], values: [
[900_000, '15 minutes'], [0.5, 'Very Fast'],
[1_800_000, '30 minutes'], [0.75, 'Fast'],
]); [1.0, 'Normal'],
[1.25, 'Slow'],
[1.5, 'Very Slow'],
],
});
settings.units = new Setting('units', {
name: 'Units',
type: 'select',
defaultValue: 'us',
changeAction: unitChange,
values: [
['us', 'US'],
['si', 'Metric'],
],
});
settings.refreshTime = new Setting('refreshTime', {
type: 'select',
defaultValue: 600_000,
sticky: false,
values: [
[30_000, 'TESTING'],
[300_000, '5 minutes'],
[600_000, '10 minutes'],
[900_000, '15 minutes'],
[1_800_000, '30 minutes'],
],
visible: false,
});
// generate html objects // generate html objects
const settingHtml = Object.values(settings).map((d) => d.generate()); const settingHtml = Object.values(settings).map((d) => d.generate());

View File

@@ -2,41 +2,58 @@ import { parseQueryString } from '../share.mjs';
const SETTINGS_KEY = 'Settings'; const SETTINGS_KEY = 'Settings';
const DEFAULTS = {
shortName: undefined,
name: undefined,
type: 'checkbox',
defaultValue: undefined,
changeAction: () => { },
sticky: true,
values: [],
visible: true,
};
class Setting { class Setting {
constructor(shortName, name, type, defaultValue, changeAction, sticky, values) { constructor(shortName, _options) {
// store values if (shortName === undefined) {
throw new Error('No name provided for setting');
}
// merge options with defaults
const options = { ...DEFAULTS, ...(_options ?? {}) };
// store values and combine with defaults
this.shortName = shortName; this.shortName = shortName;
this.name = name; this.name = options.name ?? shortName;
this.defaultValue = defaultValue; this.defaultValue = options.defaultValue;
this.myValue = defaultValue; this.myValue = this.defaultValue;
this.type = type ?? 'checkbox'; this.type = options?.type;
this.sticky = sticky; this.sticky = options.sticky;
this.values = values; this.values = options.values;
// a default blank change function is provided this.visible = options.visible;
this.changeAction = changeAction ?? (() => { }); this.changeAction = options.changeAction;
// get value from url // get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${type}`]; const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
let urlState; let urlState;
if (type === 'checkbox' && urlValue !== undefined) { if (this.type === 'checkbox' && urlValue !== undefined) {
urlState = urlValue === 'true'; urlState = urlValue === 'true';
} }
if (type === 'select' && urlValue !== undefined) { if (this.type === 'select' && urlValue !== undefined) {
urlState = parseFloat(urlValue); urlState = parseFloat(urlValue);
} }
if (type === 'select' && urlValue !== undefined && Number.isNaN(urlState)) { if (this.type === 'select' && urlValue !== undefined && Number.isNaN(urlState)) {
// couldn't parse as a float, store as a string // couldn't parse as a float, store as a string
urlState = urlValue; urlState = urlValue;
} }
// get existing value if present // get existing value if present
const storedValue = urlState ?? this.getFromLocalStorage(); const storedValue = urlState ?? this.getFromLocalStorage();
if (sticky && storedValue !== null) { if ((this.sticky || urlValue !== undefined) && storedValue !== null) {
this.myValue = storedValue; this.myValue = storedValue;
} }
// call the change function on startup // call the change function on startup
switch (type) { switch (this.type) {
case 'select': case 'select':
this.selectChange({ target: { value: this.myValue } }); this.selectChange({ target: { value: this.myValue } });
break; break;
@@ -142,6 +159,7 @@ class Setting {
if (storedValue !== undefined) { if (storedValue !== undefined) {
switch (this.type) { switch (this.type) {
case 'boolean': case 'boolean':
case 'checkbox':
return storedValue; return storedValue;
case 'select': case 'select':
return storedValue; return storedValue;
@@ -187,6 +205,9 @@ class Setting {
} }
generate() { generate() {
// don't generate a control for not visible items
if (!this.visible) return '';
// call the appropriate control generator
switch (this.type) { switch (this.type) {
case 'select': case 'select':
return this.generateSelect(); return this.generateSelect();

View File

@@ -134,10 +134,9 @@ class WeatherDisplay {
// refresh doesn't delete existing data, and is reused if the silent refresh fails // refresh doesn't delete existing data, and is reused if the silent refresh fails
if (!refresh) { if (!refresh) {
this.data = undefined; this.data = undefined;
// clear any refresh timers
this.clearAutoReload();
} }
// clear any refresh timers
clearTimeout(this.autoRefreshHandle);
this.autoRefreshHandle = null;
// store weatherParameters locally in case we need them later // store weatherParameters locally in case we need them later
if (weatherParameters) this.weatherParameters = weatherParameters; if (weatherParameters) this.weatherParameters = weatherParameters;
@@ -150,8 +149,8 @@ class WeatherDisplay {
return false; return false;
} }
// set up auto reload // set up auto reload if necessary
this.autoRefreshHandle = setTimeout(() => this.getData(false, true), settings.refreshTime.value); this.setAutoReload();
// recalculate navigation timing (in case it was modified in the constructor) // recalculate navigation timing (in case it was modified in the constructor)
this.calcNavTiming(); this.calcNavTiming();
@@ -435,6 +434,15 @@ class WeatherDisplay {
this.stillWaitingCallbacks.forEach((callback) => callback()); this.stillWaitingCallbacks.forEach((callback) => callback());
this.stillWaitingCallbacks = []; this.stillWaitingCallbacks = [];
} }
clearAutoReload() {
clearInterval(this.autoRefreshHandle);
this.autoRefreshHandle = null;
}
setAutoReload() {
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(false, true), settings.refreshTime.value);
}
} }
export default WeatherDisplay; export default WeatherDisplay;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -82,6 +82,14 @@
.like { .like {
left: 425px; left: 425px;
&.heat-index {
color: #e00;
}
&.wind-chill {
color: c.$extended-low;
}
} }
.wind { .wind {