Skip to content

Commit 200175d

Browse files
cdibonaclaude
andcommitted
Skip Vestaboard/TRMNL pushes when WSDOT data is unavailable
When WSDOT's terminalsailingspace feed times out or returns empty, both the next-departure time and the drive-up space count go missing (they come from that one endpoint), so a scheduled push would flip the board to "BAIN-SEA --" / "SPACES: N/A" — or a blank "--" TRMNL screen. push_vestaboard_target() and push_trmnl_target() now detect this blackout via _ferry_data_is_stale() and return {"skipped": reason} without sending. The scheduler leaves last_push untouched on a skip, so the device keeps its last good content and retries next tick; the WSDOT fetch is cached (~5 min) so retries don't hammer the API, and it pushes again as soon as real data returns. "Stale" means BOTH the departure time and spaces are missing — a real departure with an unknown space count still pushes. A manual "Push now" during an outage reports "Skipped — WSDOT data unavailable" instead of blanking the display. ferry_merge_variables() is left unguarded so the polling/JSON API still returns data. Verified against a live WSDOT blackout (the feed was empty at the time): the push skipped with send called 0 times, and a good read still sends. 28 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14ffa1e commit 200175d

4 files changed

Lines changed: 208 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ minutes (default 3) *before* our quiet window's start time, and ferry pushes sto
9999
same early moment so nothing overwrites the goodnight. `_in_quiet_hours()` implements the
100100
shift; the wake time is not shifted.
101101

102+
When WSDOT glitches, the `terminalsailingspace` fetch times out or returns empty, and since
103+
both the departure list and the space counts come from that one endpoint, the board would
104+
flip to `BAIN-SEA --` / `SPACES: N/A`. `push_vestaboard_target()` guards against this with
105+
`_ferry_data_is_stale()` and returns `{"skipped": reason}` instead of sending; the scheduler
106+
then leaves `last_push` untouched so the board keeps its last good message and retries next
107+
tick (fetch is cached ~5 min, so retries don't hammer WSDOT). "Stale" for a routed board
108+
means **both** the next departure and the spaces count are missing — a real departure with an
109+
unknown space count still pushes. Note `fetch_ferry_status` caches even a partial (space-fetch
110+
-failed) read, so a glitch persists in-app for up to the cache TTL after WSDOT recovers.
111+
`push_trmnl_target()` has the same guard (a blackout would push a blank `--` TRMNL screen);
112+
`ferry_merge_variables()` itself is left unguarded because the polling/JSON API should still
113+
return data.
114+
102115
A board can override the built-in layout with `board['template']` — a sandboxed Jinja
103116
template, one rendered line per board row (`LEFT | RIGHT` splits a row, anything else
104117
centers). Two gotchas: the env uses `trim_blocks`/`lstrip_blocks` so an `{% if %}` on its

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ the exact start time can be swallowed and leave the ferry layout up all night. F
114114
pushes stop at the same early moment, so nothing overwrites the goodnight. Set the lead
115115
to 0 to fire exactly at the start time.
116116

117+
**Glitch-resistant pushes.** If a WSDOT read comes back empty (its data feed drops out
118+
briefly), a scheduled push would otherwise flip the display to `-- / SPACES: N/A` (or a blank
119+
`--` TRMNL screen). Instead the push is skipped and the device keeps its last good content;
120+
the scheduler retries on the next tick and updates as soon as real data returns. This covers
121+
both Vestaboard and TRMNL. A manual **Push now** during an outage reports "Skipped — WSDOT
122+
data unavailable" rather than blanking the display.
123+
117124
**Custom board layouts.** Under **Advanced** in the board editor, a template can replace
118125
the built-in Vestaboard layout without touching the code. One rendered line per board row;
119126
a line containing `|` splits into left- and right-aligned halves, everything else is

web/app.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,7 +1452,8 @@ def _trmnl_mk(template: str) -> str:
14521452
try {
14531453
const r = await fetch(siteBase() + '/api/push/vestaboard/' + encodeURIComponent(id), { method: 'POST' });
14541454
const d = await r.json();
1455-
setStatus('vbDot', 'vbStatus', 'vbTime', d.error ? 'error' : 'success', d.error || 'Pushed to ' + b.name);
1455+
if (d.skipped) setStatus('vbDot', 'vbStatus', 'vbTime', 'error', 'Skipped — ' + d.skipped + ' (board unchanged)');
1456+
else setStatus('vbDot', 'vbStatus', 'vbTime', d.error ? 'error' : 'success', d.error || 'Pushed to ' + b.name);
14561457
} catch (e) { setStatus('vbDot', 'vbStatus', 'vbTime', 'error', 'Error: ' + e.message); }
14571458
finally { btn.disabled = false; loadScheduleStatus(); }
14581459
}
@@ -1543,7 +1544,8 @@ def _trmnl_mk(template: str) -> str:
15431544
try {
15441545
const r = await fetch(siteBase() + '/api/push/trmnl/' + encodeURIComponent(id), { method: 'POST' });
15451546
const j = await r.json();
1546-
setStatus('trmnlDot', 'trmnlStatus', 'trmnlTime', j.error ? 'error' : 'success', j.error || 'Pushed to ' + d.name);
1547+
if (j.skipped) setStatus('trmnlDot', 'trmnlStatus', 'trmnlTime', 'error', 'Skipped — ' + j.skipped + ' (screen unchanged)');
1548+
else setStatus('trmnlDot', 'trmnlStatus', 'trmnlTime', j.error ? 'error' : 'success', j.error || 'Pushed to ' + d.name);
15471549
} catch (e) { setStatus('trmnlDot', 'trmnlStatus', 'trmnlTime', 'error', 'Error: ' + e.message); }
15481550
finally { btn.disabled = false; loadScheduleStatus(); }
15491551
}
@@ -2569,11 +2571,40 @@ def send_to_trmnl(webhook_url: str, merge_variables: Dict[str, Any]) -> Dict[str
25692571
return {"error": f"Failed to send to TRMNL: {str(e)}"}
25702572

25712573

2574+
def _ferry_data_is_stale(data: Dict[str, Any], status: Optional[Dict[str, Any]]) -> bool:
2575+
"""
2576+
True when a WSDOT read came back with nothing worth showing, so a push
2577+
would just flip the board to a blank "-- / SPACES: N/A" state.
2578+
2579+
This happens when the ``terminalsailingspace`` fetch times out or returns
2580+
empty (both the departure list and the space counts come from that one
2581+
endpoint), or on a hard fetch error. Callers skip the push and leave the
2582+
board's last good message sitting until WSDOT recovers.
2583+
"""
2584+
if data is None or data.get("error"):
2585+
return True
2586+
if status is not None:
2587+
# Routed board: only stale when BOTH the next departure and the space
2588+
# count are missing — the exact "BAIN-SEA --" + "SPACES: N/A" blackout.
2589+
# A real departure with an unknown space count still pushes.
2590+
return status.get("departure_time") is None and status.get("spaces") is None
2591+
# Route-less board (vessel-list layout): need a vessel or a space count.
2592+
has_vessel = any(v.get("VesselName") for v in data.get("vessels", []))
2593+
return not (has_vessel or data.get("terminal_spaces"))
2594+
2595+
25722596
def push_vestaboard_target(board: Dict[str, Any], wsdot_key: Optional[str] = None) -> Dict[str, Any]:
2573-
"""Fetch ferry data for a saved board's route/direction and push to it."""
2597+
"""
2598+
Fetch ferry data for a saved board's route/direction and push to it.
2599+
2600+
Returns ``{"skipped": reason}`` without sending when WSDOT data is
2601+
unavailable, so a glitchy read never overwrites the board's last update.
2602+
"""
25742603
route = board.get("route") or None
25752604
data = fetch_ferry_status(route, api_key=wsdot_key)
25762605
status = compute_direction_status(data, route, board.get("direction")) if route else None
2606+
if _ferry_data_is_stale(data, status):
2607+
return {"skipped": data.get("error") or "WSDOT data unavailable"}
25772608
formatted = format_ferry_data(data)
25782609
characters = format_vestaboard_message(formatted, status, model=board.get("model", "flagship"),
25792610
template=board.get("template"))
@@ -2605,8 +2636,20 @@ def push_sleep_message(board: Dict[str, Any]) -> Dict[str, Any]:
26052636

26062637

26072638
def push_trmnl_target(device: Dict[str, Any], wsdot_key: Optional[str] = None) -> Dict[str, Any]:
2608-
"""Fetch ferry data for a TRMNL device's route/direction and push via webhook."""
2609-
mv = ferry_merge_variables(device.get("route") or None, device.get("direction"), wsdot_key)
2639+
"""
2640+
Fetch ferry data for a TRMNL device's route/direction and push via webhook.
2641+
2642+
Like the Vestaboard path, returns ``{"skipped": reason}`` without sending
2643+
when WSDOT data is unavailable, so a glitch doesn't push a blank "--" screen.
2644+
(The fetch is cached, so the read here and inside ferry_merge_variables share
2645+
one WSDOT call.)
2646+
"""
2647+
route = device.get("route") or None
2648+
data = fetch_ferry_status(route, api_key=wsdot_key)
2649+
status = compute_direction_status(data, route, device.get("direction")) if route else None
2650+
if _ferry_data_is_stale(data, status):
2651+
return {"skipped": data.get("error") or "WSDOT data unavailable"}
2652+
mv = ferry_merge_variables(route, device.get("direction"), wsdot_key)
26102653
return send_to_trmnl(device.get("webhook_url"), mv)
26112654

26122655

@@ -3243,13 +3286,20 @@ def _save_state(state: Dict[str, Any]) -> None:
32433286
logger.warning(f"Could not persist schedule state: {e}")
32443287

32453288

3289+
def _result_message(result: Dict[str, Any]) -> str:
3290+
"""Human-readable one-liner for a push result (error / skipped / sent)."""
3291+
if result.get("skipped"):
3292+
return f"skipped: {result['skipped']}"
3293+
return result.get("error") or result.get("status") or "sent"
3294+
3295+
32463296
def _record_push(kind: str, target_id: str, result: Dict[str, Any]) -> None:
32473297
"""Record the outcome of a push for the admin status view (UTC timestamp)."""
32483298
state = _load_state()
32493299
state.setdefault(kind, {})[target_id] = {
32503300
"last_push": datetime.now(timezone.utc).isoformat(),
32513301
"ok": "error" not in result,
3252-
"message": result.get("error") or result.get("status") or "sent",
3302+
"message": _result_message(result),
32533303
}
32543304
_save_state(state)
32553305

@@ -3360,7 +3410,7 @@ def _scheduler_tick() -> None:
33603410
def _finish_push(entry, result):
33613411
entry["last_push"] = now.isoformat()
33623412
entry["ok"] = "error" not in result
3363-
entry["message"] = result.get("error") or result.get("status") or "sent"
3413+
entry["message"] = _result_message(result)
33643414

33653415
for board in settings["vestaboard"]["boards"]:
33663416
sch = board.get("schedule") or {}
@@ -3414,11 +3464,19 @@ def _finish_push(entry, result):
34143464
reasons = (reasons or []) + ["wake"]
34153465

34163466
if do_push:
3417-
logger.info(f"Scheduled push -> Vestaboard '{board['name']}' ({mode}{': ' + ','.join(reasons) if reasons else ''})")
3418-
_finish_push(entry, push_vestaboard_target(board, wsdot))
3419-
if observed is not None:
3420-
entry["pushed_spaces"] = observed["spaces"]
3421-
entry["observed_docked"] = observed["docked"]
3467+
result = push_vestaboard_target(board, wsdot)
3468+
if result.get("skipped"):
3469+
# WSDOT data unavailable: leave the board's last message sitting
3470+
# and don't advance last_push, so we retry and push once it's back.
3471+
logger.info(f"Skipped Vestaboard '{board['name']}' — {result['skipped']}; keeping last message")
3472+
entry["ok"] = True
3473+
entry["message"] = _result_message(result)
3474+
else:
3475+
logger.info(f"Scheduled push -> Vestaboard '{board['name']}' ({mode}{': ' + ','.join(reasons) if reasons else ''})")
3476+
_finish_push(entry, result)
3477+
if observed is not None:
3478+
entry["pushed_spaces"] = observed["spaces"]
3479+
entry["observed_docked"] = observed["docked"]
34223480
dirty = True
34233481

34243482
for dev in settings["trmnl"]["devices"]:
@@ -3432,8 +3490,14 @@ def _finish_push(entry, result):
34323490
else:
34333491
do_push = _interval_due(entry, sch.get("interval_min", 15), TRMNL_MIN_INTERVAL_MIN, now)
34343492
if do_push:
3435-
logger.info(f"Scheduled push -> TRMNL '{dev['name']}' ({mode})")
3436-
_finish_push(entry, push_trmnl_target(dev, wsdot))
3493+
result = push_trmnl_target(dev, wsdot)
3494+
if result.get("skipped"):
3495+
logger.info(f"Skipped TRMNL '{dev['name']}' — {result['skipped']}; keeping last screen")
3496+
entry["ok"] = True
3497+
entry["message"] = _result_message(result)
3498+
else:
3499+
logger.info(f"Scheduled push -> TRMNL '{dev['name']}' ({mode})")
3500+
_finish_push(entry, result)
34373501
dirty = True
34383502

34393503
if dirty:

web/test_app.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,26 @@ def test_scheduler_pushes_due_targets(tmp_path):
480480
'webhook_url': 'https://usetrmnl.com/api/custom_plugins/x',
481481
'schedule': {'enabled': True, 'interval_min': 15}}]},
482482
})
483-
with patch('app.requests.get') as mg, patch('app.requests.post') as mp:
484-
gr = MagicMock(); gr.json.return_value = []; gr.raise_for_status = MagicMock(); mg.return_value = gr
483+
# A real future Seattle->Bainbridge departure, so the push isn't skipped
484+
# as a WSDOT blackout (see test_ferry_data_is_stale).
485+
from datetime import datetime, timedelta
486+
dep_ms = int((datetime.now() + timedelta(minutes=30)).timestamp() * 1000)
487+
sailing_space = [{
488+
"TerminalName": "Seattle",
489+
"DepartingSpaces": [{
490+
"Departure": f"/Date({dep_ms}-0800)/", "VesselName": "Tacoma",
491+
"SpaceForArrivalTerminals": [{
492+
"TerminalName": "Bainbridge Island", "VesselName": "Tacoma",
493+
"DriveUpSpaceCount": 90, "MaxSpaceCount": 200}],
494+
}],
495+
}]
496+
497+
def _wsdot_get(url, *a, **k):
498+
r = MagicMock(); r.raise_for_status = MagicMock()
499+
r.json.return_value = sailing_space if "terminalsailingspace" in url else []
500+
return r
501+
502+
with patch('app.requests.get', side_effect=_wsdot_get) as mg, patch('app.requests.post') as mp:
485503
pr = MagicMock(); pr.status_code = 200; pr.content = b'{}'; pr.json.return_value = {}; pr.raise_for_status = MagicMock(); mp.return_value = pr
486504
app._scheduler_tick()
487505
posts = [c.args[0] for c in mp.call_args_list]
@@ -618,6 +636,96 @@ def test_scheduler_sleeps_early_by_lead(tmp_path):
618636
assert sleep_m.call_count == 1 and ferry_m.call_count == 0
619637

620638

639+
@patch.dict(os.environ, {'WSDOT_API_KEY': 'test', 'FLASK_PORT': '5050'})
640+
def test_ferry_data_is_stale():
641+
"""Only the total 'no departure AND no spaces' blackout counts as stale."""
642+
import app
643+
routed = {'departure_time': '2026-08-02T11:30:00', 'spaces': 100}
644+
assert app._ferry_data_is_stale({'terminal_departures': {'x': [1]}}, routed) is False
645+
# a real departure with unknown spaces still pushes
646+
assert app._ferry_data_is_stale({}, {'departure_time': '2026-08-02T11:30:00', 'spaces': None}) is False
647+
# spaces known but no next departure still pushes
648+
assert app._ferry_data_is_stale({}, {'departure_time': None, 'spaces': 42}) is False
649+
# the blackout: both missing -> stale
650+
assert app._ferry_data_is_stale({'terminal_spaces': {}}, {'departure_time': None, 'spaces': None}) is True
651+
# hard fetch error -> stale
652+
assert app._ferry_data_is_stale({'error': 'boom'}, routed) is True
653+
# route-less board: vessels present -> not stale; nothing -> stale
654+
assert app._ferry_data_is_stale({'vessels': [{'VesselName': 'Tacoma'}]}, None) is False
655+
assert app._ferry_data_is_stale({'vessels': [], 'terminal_spaces': {}}, None) is True
656+
657+
658+
@patch.dict(os.environ, {'WSDOT_API_KEY': 'test', 'FLASK_PORT': '5050'})
659+
def test_push_skips_on_stale_wsdot():
660+
"""A blackout read returns skipped and never touches the board."""
661+
import app
662+
from datetime import datetime, timedelta
663+
board = {'name': 'B', 'model': 'note', 'route': 'sea-bi', 'direction': 'Bainbridge Island', 'key': 'k'}
664+
empty = {'route_id': 'sea-bi', 'vessels': [], 'terminal_spaces': {}, 'terminal_departures': {}, 'alerts': []}
665+
with patch('app.fetch_ferry_status', return_value=empty), \
666+
patch('app.send_to_vestaboard') as send_m:
667+
result = app.push_vestaboard_target(board, 'wk')
668+
assert result.get('skipped') and send_m.call_count == 0
669+
670+
# A good read still pushes: a real departure -> send is called, no skip.
671+
good = dict(empty, terminal_departures={'Bainbridge Island': [
672+
{'time': datetime.now() + timedelta(minutes=20), 'arrival': 'Seattle',
673+
'vessel': 'Tacoma', 'drive_up': 90}]})
674+
with patch('app.fetch_ferry_status', return_value=good), \
675+
patch('app.send_to_vestaboard', return_value={'status': 'sent'}) as send_ok:
676+
result = app.push_vestaboard_target(board, 'wk')
677+
assert 'skipped' not in result and send_ok.call_count == 1
678+
679+
680+
@patch.dict(os.environ, {'WSDOT_API_KEY': 'test', 'FLASK_PORT': '5050'})
681+
def test_trmnl_push_skips_on_stale_wsdot():
682+
"""A blackout read skips the TRMNL webhook too, so the device keeps its screen."""
683+
import app
684+
dev = {'name': 'D', 'route': 'sea-bi', 'direction': 'Bainbridge Island',
685+
'webhook_url': 'https://usetrmnl.com/api/custom_plugins/x'}
686+
empty = {'route_id': 'sea-bi', 'vessels': [], 'terminal_spaces': {}, 'terminal_departures': {}, 'alerts': []}
687+
with patch('app.fetch_ferry_status', return_value=empty), \
688+
patch('app.send_to_trmnl') as send_m:
689+
result = app.push_trmnl_target(dev, 'wk')
690+
assert result.get('skipped') and send_m.call_count == 0
691+
692+
693+
@patch.dict(os.environ, {'WSDOT_API_KEY': 'test', 'FLASK_PORT': '5050'})
694+
def test_scheduler_skip_keeps_last_message_and_retries(tmp_path):
695+
"""When WSDOT is glitching the scheduler leaves the board and retries next tick."""
696+
import app
697+
from datetime import datetime, timezone, timedelta
698+
with patch.object(app, 'SETTINGS_PATH', str(tmp_path / 's.json')), \
699+
patch.object(app, 'SCHEDULE_STATE_PATH', str(tmp_path / 'st.json')):
700+
client = app.app.test_client()
701+
client.post('/api/settings', json={'wsdot_key': 'wk', 'vestaboard': {'boards': [{
702+
'name': 'Hall', 'model': 'note', 'route': 'sea-bi', 'direction': 'Bainbridge Island', 'key': 'k',
703+
'schedule': {'enabled': True, 'mode': 'interval', 'interval_min': 15}}]}})
704+
bid = client.get('/api/settings').get_json()['vestaboard']['boards'][0]['id']
705+
706+
# Seed a real last push an interval ago (the scheduler's interval clock is
707+
# wall-clock UTC, not _now()). This is the board's last good message.
708+
first_push = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
709+
st = app._load_state()
710+
st['vestaboard'][bid] = {'last_push': first_push, 'ok': True, 'message': 'sent'}
711+
app._save_state(st)
712+
713+
# Tick with WSDOT down: push returns skipped, board left alone.
714+
with patch('app.push_vestaboard_target', return_value={'skipped': 'WSDOT data unavailable'}) as skip_m:
715+
app._scheduler_tick()
716+
assert skip_m.call_count == 1
717+
entry = app._load_state()['vestaboard'][bid]
718+
# last_push did NOT advance -> the last good message sits and it stays due.
719+
assert entry['last_push'] == first_push
720+
assert 'skipped' in entry['message'] and entry['ok'] is True
721+
722+
# Next tick: WSDOT recovers -> it pushes without waiting another interval.
723+
with patch('app.push_vestaboard_target', return_value={'status': 'sent'}) as back_m:
724+
app._scheduler_tick()
725+
assert back_m.call_count == 1
726+
assert app._load_state()['vestaboard'][bid]['last_push'] != first_push
727+
728+
621729
if __name__ == '__main__':
622730
print("Running basic tests...")
623731

0 commit comments

Comments
 (0)