Compare commits

...

18 Commits

Author SHA1 Message Date
Matt Walsh
2db7f30de7 5.27.1 2025-07-11 22:36:57 -05:00
Matt Walsh
5c7a6ab1a4 fix for rss feed encoding types close #124 2025-07-11 22:36:47 -05:00
Matt Walsh
4b63328b74 update dependencies 2025-07-06 10:54:20 -05:00
Matt Walsh
ae1d004f60 Merge pull request #123 from rmitchellscott/fix-static-envs
fix: url encode envs in static-env-handler. Fixes #122.
2025-07-06 10:36:51 -05:00
Mitchell Scott
7dd4c1dd24 fix: url encode envs in static-env-handler. Fixes #122. 2025-07-04 04:41:56 -06:00
Matt Walsh
1120247c99 include custom rss feed in build #57 2025-06-30 23:32:30 -05:00
Matt Walsh
c5c01e5450 5.27.0 2025-06-30 23:29:46 -05:00
Matt Walsh
0a65221905 update readme for custom rss close #57 2025-06-30 23:29:39 -05:00
Matt Walsh
9f9667c895 add single text scroll option #57 2025-06-28 09:20:36 -05:00
Matt Walsh
fda44e95fc rss feeds scroll, needs additional testing #57 2025-06-28 00:59:40 -05:00
Matt Walsh
945c12e6c6 parse rss feed #57 2025-06-28 00:29:55 -05:00
Matt Walsh
0fde88cd8f restructure current weather scroll to allow add/remove of rss feed #57 2025-06-28 00:29:47 -05:00
Matt Walsh
c6af9a2913 5.26.2 2025-06-27 22:30:05 -05:00
Matt Walsh
11eba84cdb fix for calm/0mph wind close #121 2025-06-27 22:29:56 -05:00
Matt Walsh
b9ead38015 5.26.1 2025-06-27 22:17:00 -05:00
Matt Walsh
3d0178faa1 radar scrolling fix for ios 2025-06-27 22:16:51 -05:00
Matt Walsh
8a2907e02c fix display of null wind speed 2025-06-27 15:35:15 -05:00
Matt Walsh
b870ce1c01 store already processed radar images for reuse on silent reload 2025-06-27 15:29:20 -05:00
12 changed files with 668 additions and 437 deletions

View File

@@ -176,6 +176,9 @@ A hook is provided as `/server/scripts/custom.js` to allow customizations to you
When using Docker, mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js` to customize the static build.
### RSS feeds and custom scroll
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set.
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. I've also observed that the API can go down on a regional basis (based on NWS office locations). This means that you may have problems getting data for, say, Chicago right now, but Dallas and others are working just fine.

View File

@@ -89,6 +89,7 @@ const mjsSources = [
'server/scripts/modules/travelforecast.mjs',
'server/scripts/modules/progress.mjs',
'server/scripts/modules/media.mjs',
'server/scripts/modules/custom-rss-feed.mjs',
'server/scripts/index.mjs',
];

715
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.26.0",
"version": "5.27.1",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
@@ -49,7 +49,7 @@
"webpack-stream": "^7.0.0"
},
"dependencies": {
"dotenv": "^16.5.0",
"dotenv": "^17.0.1",
"ejs": "^3.1.5",
"express": "^5.1.0"
}

View File

@@ -112,10 +112,12 @@ class CurrentWeather extends WeatherDisplay {
condition = shortConditions(condition);
}
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind: this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' '),
wind,
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
@@ -202,13 +204,15 @@ const parseData = (data) => {
data.WindSpeed = windConverter(observations.windSpeed.value);
data.WindDirection = directionToNSEW(observations.windDirection.value);
data.WindGust = windConverter(observations.windGust.value);
data.WindSpeed = windConverter(data.WindSpeed);
data.WindUnit = windConverter.units;
data.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getLargeIcon(observations.icon);
data.PressureDirection = '';
data.TextConditions = observations.textDescription;
// set wind speed of 0 as calm
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';

View File

@@ -15,6 +15,7 @@ let screenIndex = 0;
let sinceLastUpdate = 0;
let nextUpdate = DEFAULT_UPDATE;
let resetFlag;
let defaultScreensLoaded = true;
// start drawing conditions
// reset starts from the first item in the text scroll list
@@ -60,7 +61,7 @@ const incrementInterval = (force) => {
stop(display?.elemId === 'progress');
return;
}
screenIndex = (screenIndex + 1) % (lastScreen);
screenIndex = (screenIndex + 1) % (workingScreens.length);
// draw new text
drawScreen();
@@ -78,7 +79,7 @@ const drawScreen = async () => {
// nothing to do if there's no data yet
if (!data) return;
const thisScreen = screens[screenIndex](data);
const thisScreen = workingScreens[screenIndex](data);
// update classes on the scroll area
elemForEach('.weather-display .scroll', (elem) => {
@@ -125,8 +126,10 @@ const hazards = (data) => {
};
};
// additional screens are stored in a separate for simple clearing/resettings
let additionalScreens = [];
// the "screens" are stored in an array for easy addition and removal
const screens = [
const baseScreens = [
// hazards
hazards,
// station name
@@ -168,6 +171,9 @@ const screens = [
},
];
// working screens are the combination of base screens (when active) and additional screens
let workingScreens = [...baseScreens, ...additionalScreens];
// internal draw function with preset parameters
const drawCondition = (text) => {
// update all html scroll elements
@@ -183,19 +189,18 @@ const setHeader = (text) => {
});
};
// store the original number of screens
const originalScreens = screens.length;
let lastScreen = originalScreens;
// reset the number of screens
// reset the screens back to the original set
const reset = () => {
lastScreen = originalScreens;
workingScreens = [...baseScreens];
additionalScreens = [];
defaultScreensLoaded = true;
};
// add screen
const addScreen = (screen) => {
screens.push(screen);
lastScreen += 1;
// add screen, keepBase keeps the regular weather crawl
const addScreen = (screen, keepBase = true) => {
defaultScreensLoaded = false;
additionalScreens.push(screen);
workingScreens = [...(keepBase ? baseScreens : []), ...additionalScreens];
};
const drawScrollCondition = (screen) => {
@@ -238,6 +243,9 @@ const parseMessage = (event) => {
}
};
const screenCount = () => workingScreens.length;
const atDefault = () => defaultScreensLoaded;
// add event listener for start message
window.addEventListener('message', parseMessage);
@@ -245,10 +253,14 @@ window.CurrentWeatherScroll = {
addScreen,
reset,
start,
screenCount,
atDefault,
};
export {
addScreen,
reset,
start,
screenCount,
atDefault,
};

View File

@@ -0,0 +1,132 @@
import Setting from './utils/setting.mjs';
import { reset as resetScroll, addScreen as addScroll } from './currentweatherscroll.mjs';
import { json } from './utils/fetch.mjs';
let firstRun = true;
const parser = new DOMParser();
// change of enable handler
const changeEnable = (newValue) => {
let newDisplay;
if (newValue) {
// add the feed to the scroll
parseFeed(customFeed.value);
// show the string box
newDisplay = 'block';
} else {
// set scroll back to original
resetScroll();
// hide the string entry
newDisplay = 'none';
}
const stringEntry = document.getElementById('settings-customFeed-label');
if (stringEntry) {
stringEntry.style.display = newDisplay;
}
};
// parse the feed/text provided
const parseFeed = (textInput) => {
// skip getting the feed on first run
if (firstRun) return;
// test validity
if (textInput === undefined || textInput === '') {
resetScroll();
}
// test for url
if (textInput.match(/https?:\/\//)) {
getFeed(textInput);
return;
}
// add single text scroll
resetScroll();
addScroll(
() => (
{
type: 'scroll',
text: textInput,
}),
// keep the existing scroll
true,
);
};
// get the rss feed and then swap out the current weather scroll
const getFeed = async (url) => {
// get the text as a string
// it needs to be proxied, use a free service
const rssResponse = await json(`https://api.allorigins.win/get?url=${url}`);
// this returns a data url
// a few sanity checks
if (rssResponse.status.content_type.indexOf('xml') < 0) return;
// determine return type
const isBase64 = rssResponse.status.content_type.substring(0, 8) !== 'text/xml';
// base 64 decode everything after the comma
const rss = isBase64 ? atob(rssResponse.contents.split('base64,')[1]) : rssResponse.contents;
// parse the rss
const doc = parser.parseFromString(rss, 'text/xml');
// get the title
const rssTitle = doc.querySelector('channel title').textContent;
// get each item
const titles = [...doc.querySelectorAll('item title')].map((t) => t.textContent);
// reset the scroll, then add the screens
resetScroll();
titles.forEach((title) => {
// data is provided to the screen handler, so we return a function
addScroll(
() => ({
header: rssTitle,
type: 'scroll',
text: title,
}),
// false parameter does not include the default weather scrolls
false,
);
});
};
// change the feed source and re-load if necessary
const changeFeed = (newValue) => {
// first pass through won't have custom feed enable ready
if (firstRun) return;
if (customFeedEnable.value) {
parseFeed(newValue);
}
};
const customFeed = new Setting('customFeed', {
name: 'Custom RSS Feed',
defaultValue: '',
type: 'string',
changeAction: changeFeed,
placeholder: 'Text or URL',
});
const customFeedEnable = new Setting('customFeedEnable', {
name: 'Enable RSS Feed/Text',
defaultValue: false,
changeAction: changeEnable,
});
// initialize the custom feed inputs on the page
document.addEventListener('DOMContentLoaded', () => {
// add the controls to the page
const settingsSection = document.querySelector('#settings');
settingsSection.append(customFeedEnable.generate(), customFeed.generate());
// clear the first run value
firstRun = false;
// call change enable with the current value to show/hide the url box
// and make the call to get the feed if enabled
changeEnable(customFeedEnable.value);
});

View File

@@ -20,45 +20,44 @@ document.addEventListener('DOMContentLoaded', () => {
});
const scanMusicDirectory = async () => {
const parseDirectory = async (path, prefix = "") => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^\"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
const parseDirectory = async (path, prefix = '') => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
try {
let files = await parseDirectory("music/");
if (files.length === 0) {
files = await parseDirectory("music/default/", "default/");
}
return { availableFiles: files };
} catch (e) {
console.error("Unable to scan music directory");
console.error(e);
return { availableFiles: [] };
}
try {
let files = await parseDirectory('music/');
if (files.length === 0) {
files = await parseDirectory('music/default/', 'default/');
}
return { availableFiles: files };
} catch (e) {
console.error('Unable to scan music directory');
console.error(e);
return { availableFiles: [] };
}
};
const getMedia = async () => {
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
enableMediaPlayer();
enableMediaPlayer();
};
const enableMediaPlayer = () => {
@@ -219,11 +218,11 @@ const playerEnded = () => {
};
const setTrackName = (fileName) => {
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, '')
);
document.getElementById('musicTrack').innerHTML = trackName;
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, ''),
);
document.getElementById('musicTrack').innerHTML = trackName;
};
export {

View File

@@ -8,6 +8,10 @@ import * as utils from './radar-utils.mjs';
import setTiles from './radar-tiles.mjs';
import processRadar from './radar-processor.mjs';
// store processed radar as dataURLs to avoid re-processing frames as they slide backwards in time
// this is cleared upon changing the location displayed
let processedRadars = [];
const RADAR_HOST = 'mesonet.agron.iastate.edu';
class Radar extends WeatherDisplay {
constructor(navId, elemId) {
@@ -110,19 +114,44 @@ class Radar extends WeatherDisplay {
elemId: this.elemId,
});
const radarKey = `${radarSourceXY.x.toFixed(0)}-${radarSourceXY.y.toFixed(0)}`;
// reset the "used" flag on pre-processed radars
// items that were not used during this process are deleted (either expired via time or change of location)
processedRadars.forEach((radar) => { radar.used = false; });
// remove any radars that aren't
// Load the most recent doppler radar images.
const radarInfo = await Promise.all(urls.map(async (url) => {
const processedRadar = await processRadar({
// store the time
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
const [, year, month, day, hour, minute] = timeMatch;
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
// check for a pre-processed radar
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
// use the pre-processed radar, or get a new one
const processedRadar = preProcessed?.dataURL ?? await processRadar({
url,
RADAR_HOST,
OVERRIDES,
radarSourceXY,
});
// store the time
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
// store the radar
if (!preProcessed) {
processedRadars.push({
key: radarKeyedTimestamp,
dataURL: processedRadar,
used: true,
});
} else {
// set used flag
preProcessed.used = true;
}
const [, year, month, day, hour, minute] = timeMatch;
const time = DateTime.fromObject({
year,
month,
@@ -150,12 +179,15 @@ class Radar extends WeatherDisplay {
this.times = radarInfo.map((radar) => radar.time);
this.setStatus(STATUS.loaded);
// clean up any unused stored radars
processedRadars = processedRadars.filter((radar) => radar.used);
}
async drawCanvas() {
super.drawCanvas();
const time = this.times[this.screenIndex].toLocaleString(DateTime.TIME_SIMPLE);
const timePadded = time.length >= 8 ? time : `&nbsp;${time}`;
const timePadded = time.length >= 8 ? time : `&nbsp;${time} `;
this.elem.querySelector('.header .right .time').innerHTML = timePadded;
// get image offset calculation

View File

@@ -11,6 +11,7 @@ const DEFAULTS = {
sticky: true,
values: [],
visible: true,
placeholder: '',
};
class Setting {
@@ -31,6 +32,7 @@ class Setting {
this.values = options.values;
this.visible = options.visible;
this.changeAction = options.changeAction;
this.placeholder = options.placeholder;
// get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
@@ -48,6 +50,9 @@ class Setting {
// couldn't parse as a float, store as a string
urlState = urlValue;
}
if (this.type === 'string' && urlValue !== undefined) {
urlState = urlValue;
}
// get existing value if present
const storedValue = urlState ?? this.getFromLocalStorage();
@@ -60,6 +65,9 @@ class Setting {
case 'select':
this.selectChange({ target: { value: this.myValue } });
break;
case 'string':
this.stringChange({ target: { value: this.myValue } });
break;
case 'checkbox':
default:
this.checkboxChange({ target: { checked: this.myValue } });
@@ -124,6 +132,34 @@ class Setting {
return label;
}
generateString() {
// create a string input and accompanying set button
const label = document.createElement('label');
label.for = `settings-${this.shortName}-string`;
label.id = `settings-${this.shortName}-label`;
// text input box
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.value = this.myValue;
textInput.id = `settings-${this.shortName}-string`;
textInput.name = `settings-${this.shortName}-string`;
textInput.placeholder = this.placeholder;
// set button
const setButton = document.createElement('input');
setButton.type = 'button';
setButton.value = 'Set';
setButton.id = `settings-${this.shortName}-button`;
setButton.name = `settings-${this.shortName}-button`;
setButton.addEventListener('click', () => {
this.stringChange({ target: { value: textInput.value } });
});
// assemble
label.append(textInput, setButton);
this.element = label;
return label;
}
checkboxChange(e) {
// update the state
this.myValue = e.target.checked;
@@ -146,6 +182,15 @@ class Setting {
this.changeAction(this.myValue);
}
stringChange(e) {
// update the value
this.myValue = e.target.value;
this.storeToLocalStorage(this.myValue);
// call the change action
this.changeAction(this.myValue);
}
storeToLocalStorage(value) {
if (!this.sticky) return;
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
@@ -163,8 +208,8 @@ class Setting {
switch (this.type) {
case 'boolean':
case 'checkbox':
return storedValue;
case 'select':
case 'string':
return storedValue;
default:
return null;
@@ -214,6 +259,8 @@ class Setting {
switch (this.type) {
case 'select':
return this.generateSelect();
case 'string':
return this.generateString();
case 'checkbox':
default:
return this.generateCheckbox();

35
static-env-handler.sh Normal file → Executable file
View File

@@ -4,22 +4,27 @@ set -eu
ROOT="/usr/share/nginx/html"
QS=""
# URL encode a string
url_encode() {
local string="$1"
printf '%s' "$string" | sed 's/ /%20/g; s/"/%22/g; s/</%3C/g; s/>/%3E/g; s/&/%26/g; s/#/%23/g; s/+/%2B/g'
}
# build query string from WSQS_ env vars
for var in $(env); do
case "$var" in
WSQS_*=*)
key="${var%%=*}"
val="${var#*=}"
key="${key#WSQS_}"
key="${key//_/-}"
if [ -n "$QS" ]; then
QS="$QS&${key}=${val}"
else
QS="${key}=${val}"
fi
;;
esac
done
while IFS='=' read -r key val; do
# Remove WSQS_ prefix and convert underscores to hyphens
key="${key#WSQS_}"
key="${key//_/-}"
# URL encode the value
encoded_val=$(url_encode "$val")
if [ -n "$QS" ]; then
QS="$QS&${key}=${encoded_val}"
else
QS="${key}=${encoded_val}"
fi
done << EOF
$(env | grep '^WSQS_')
EOF
if [ -n "$QS" ]; then

View File

@@ -54,6 +54,7 @@
<script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script>
<script type="module" src="scripts/index.mjs"></script>
<!-- data -->
<script type="text/javascript" src="scripts/data/travelcities.js"></script>