@@ -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+
25722596def 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
26072638def 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+
32463296def _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 :
0 commit comments