diff --git a/PLAN.md b/PLAN.md index 6127138..fe55e64 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1009,21 +1009,49 @@ the remaining readings. The endpoint exposes structured current and historical f without overflow or new console errors. Also fixed disconnected OBD speed showing `undefined` and deferred offline map tiles until a real GPS fix, avoiding irrelevant placeholder requests. -### Phase 20: Alerts/notifications — 🚧 PLANNED -**New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to -alert on. Right now, finding out about a problem (check-engine light on, dashcam SD card nearly -full, upload backlog growing unbounded, Pi overheating, GPS fix lost for a long stretch) requires -manually opening the web UI. Add a lightweight push-notification integration - no new -infrastructure needed, just an outbound webhook to something like `ntfy.sh` or a Telegram bot - -fired on a handful of conditions: -- DTC/MIL status changes to "on" (from Phase 13's expanded OBD data). -- Dashcam USB storage or local `dash/videos/` disk usage crossing a configurable threshold. -- Upload backlog (pending tracking rows or dashcam files) growing past a configurable size, - suggesting the Pi's been offline/uploading too slowly for a while. -- Pi CPU temperature crossing a configurable threshold (from Phase 19). -- GPS fix lost for longer than a configurable duration while the car appears to be moving - (OBD speed > 0 with no fix - needs Phase 13's data). -Webhook target/thresholds configurable via the Phase 21 Settings page rather than hardcoded. +### Phase 20: Alerts/notifications — ✅ DONE +Added an offline-first notification system. A shared bell in the top bar on every web page shows +active local alerts with no modem, SIM card, or internet connection. Optional ntfy delivery mirrors +the same conditions only when explicitly enabled and configured; it defaults off until Phase 21 +exposes its topic URL, optional access token, and thresholds. + +Monitored conditions: +- MIL/check-engine state and DTC codes. +- Pi filesystem pressure and the physical dashcam USB filesystem, checked read-only and cached for + five minutes to avoid repeated mounts. +- Pending tracking-row or local dashcam-video upload backlog. +- Pi CPU temperature and Raspberry Pi firmware undervoltage/throttling flags. +- GPS fix loss for a configured duration while fresh OBD wheel speed confirms movement. + +The alert engine runs independently every minute and persists active state, first-active time, +last attempt/success, delivery errors, recovery state, and last value in SQLite. It sends once on a +transition, repeats only after the configured cooldown, retries failed deliveries after five +minutes, and sends a recovery notification after a delivered condition clears. GPS cold starts +must remain lost for the full duration before alerting, and stale MIL/DTC values are suppressed +after OBD communication stops. Firmware current flags alert immediately; new historical bits +remain visible for five minutes, and cleared bits are reconciled so the same fault can alert again +after reboot. Failed transient deliveries retry every minute during that window. The bell +explicitly reports a dead/stale monitor instead of silently showing old conditions. +`/api/alerts/status` exposes sanitized diagnostics without the ntfy URL, token, or request errors +that could contain a private topic URL. + +**Implemented and verified 2026-08-09:** +- Thirteen focused alert tests cover ntfy request shape/authentication/error redaction, transition + deduplication, cooldown repeats, recovery, failed-delivery retry, disabled delivery, GPS + duration/reset, firmware-event recurrence, diagnostic freshness, and dashcam-usage caching. The + combined targeted suite is 33 tests. +- Deployed uncommitted code and verified the alert thread, state table, status API, and service on + the Pi. `enabled=false` and `configured=false` produced no external attempt while local alerts + continued working. +- Tested the ntfy transport against a temporary HTTP receiver bound only to `127.0.0.1` on the Pi: + path, UTF-8 body, title, priority, tags, and bearer Authorization header all arrived correctly; + no third-party request or real token was used. +- The real read-only physical-dashcam check found 96.1% usage/1.23GB free, unmounted afterward, and + the original library remained exactly 87 files/29,615,111,758 bytes. The live bell correctly + showed that storage alert alongside the real Pi undervoltage/throttling alert. +- Browser-tested the real local bell/dropdown at 390x844 on the dashboard and trip-history page: + badge counts, alert titles/details, no-alert state, synthetic monitor-failure state, shared-shell + availability, and no-horizontal-overflow behavior passed with no new console errors. ### Phase 21: Settings page — 🚧 PLANNED **New phase - depends on Phase 15's shared components, and benefits from Phases 17/20 existing** diff --git a/app.py b/app.py index 19fd03e..5439ddc 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file -from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health +from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts import os, time from gevent.pywsgi import WSGIServer @@ -13,6 +13,7 @@ car = app_car.Car() gps = app_gps.start_gps() app_sql.start_logging() app_upload.start_uploading() +app_alerts.start_alerting() @app.route('/') def index(): @@ -144,6 +145,10 @@ def upload_status(): def health(): return jsonify(app_health.collect_health()) +@app.route('/api/alerts/status') +def alert_status(): + return jsonify(app_alerts.get_status()) + @app.route('/api/upload/dashcam_toggle', methods=['POST']) def upload_dashcam_toggle(): body = request.get_json(silent=True) or {} diff --git a/modules/app_alerts.py b/modules/app_alerts.py new file mode 100644 index 0000000..3d8a3b9 --- /dev/null +++ b/modules/app_alerts.py @@ -0,0 +1,489 @@ +import json +import sqlite3 +import threading +import time +from urllib.parse import urlparse + +import requests + +from modules import app_dash, app_gps, app_health, app_obd, app_sql, app_video + + +CHECK_INTERVAL = 60 +RETRY_INTERVAL = 5 * 60 +DASHCAM_CHECK_INTERVAL = 5 * 60 +POWER_EVENT_GRACE_SECONDS = 5 * 60 + +SETTING_DEFAULTS = { + "alerts_enabled": "0", + "alert_ntfy_url": "", + "alert_ntfy_token": "", + "alert_temperature_c": "80", + "alert_disk_percent": "90", + "alert_tracking_backlog": "1000", + "alert_video_backlog": "10", + "alert_gps_loss_seconds": "60", + "alert_repeat_seconds": "3600", +} + +ALERT_TITLES = { + "mil": "Check-engine light", + "disk": "Pi storage nearly full", + "upload_backlog": "Upload backlog growing", + "temperature": "Pi temperature high", + "power": "Pi power or throttling warning", + "gps_loss": "GPS fix lost while moving", +} + +_thread = None +_last_run_at = None +_last_run_error = None +_last_conditions = {} +_moving_started_at = None +_last_dashcam_check_at = None +_last_dashcam_usage = {"status": "not_checked"} + + +def _number_setting(key, minimum): + raw = app_sql.get_setting(key, SETTING_DEFAULTS[key]) + try: + value = float(raw) + except (TypeError, ValueError): + return float(SETTING_DEFAULTS[key]), f"{key} must be numeric" + if value < minimum: + return float(SETTING_DEFAULTS[key]), f"{key} must be at least {minimum}" + return value, None + + +def get_config(): + errors = {} + values = {} + minimums = { + "alert_temperature_c": 1, + "alert_disk_percent": 1, + "alert_tracking_backlog": 1, + "alert_video_backlog": 1, + "alert_gps_loss_seconds": 1, + "alert_repeat_seconds": 60, + } + for key, minimum in minimums.items(): + values[key], error = _number_setting(key, minimum) + if error: + errors[key] = error + + url = app_sql.get_setting("alert_ntfy_url", "") + parsed = urlparse(url) + if url and (parsed.scheme not in {"http", "https"} or not parsed.netloc): + errors["alert_ntfy_url"] = "alert_ntfy_url must be an http(s) URL" + + values.update({ + "enabled": app_sql.get_setting("alerts_enabled", "0") == "1", + "ntfy_url": url, + "ntfy_token": app_sql.get_setting("alert_ntfy_token", ""), + "errors": errors, + }) + return values + + +def send_ntfy(url, token, title, message, priority="default", tags=None): + headers = { + "Title": title, + "Priority": priority, + } + if tags: + headers["Tags"] = ",".join(tags) + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.post( + url, + data=message.encode("utf-8"), + headers=headers, + timeout=5, + ) + response.raise_for_status() + + +def _transport_error(error): + if isinstance(error, requests.Timeout): + return "Notification request timed out" + if isinstance(error, requests.HTTPError): + status_code = ( + error.response.status_code + if error.response is not None + else "unknown" + ) + return f"Notification server returned HTTP {status_code}" + if isinstance(error, requests.ConnectionError): + return "Notification connection failed" + return "Notification request failed" + + +def gps_loss_while_moving(speed, fix_age, threshold, now=None): + global _moving_started_at + now = time.monotonic() if now is None else now + if speed is None or speed <= 1: + _moving_started_at = None + return False + if _moving_started_at is None: + _moving_started_at = now + last_fix_at = None if fix_age is None else now - fix_age + loss_started_at = ( + _moving_started_at + if last_fix_at is None + else max(_moving_started_at, last_fix_at) + ) + return now - loss_started_at >= threshold + + +def _dashcam_usage(now=None): + global _last_dashcam_check_at, _last_dashcam_usage + now = time.monotonic() if now is None else now + if ( + _last_dashcam_check_at is None + or now - _last_dashcam_check_at >= DASHCAM_CHECK_INTERVAL + ): + _last_dashcam_usage = app_dash.dashcam_storage_usage() + _last_dashcam_check_at = now + return _last_dashcam_usage + + +def _power_condition(throttling, now=None): + now = time.time() if now is None else now + try: + event_until = float( + app_sql.get_setting("alert_power_event_until", "0") + ) + except (TypeError, ValueError) as error: + raise ValueError("alert_power_event_until is invalid") from error + if throttling is None: + return { + "active": now < event_until, + "current": [], + "occurred": [], + "newly_occurred": [], + "recent_event": now < event_until, + "raw": None, + } + current = set(throttling["current"] if throttling else []) + occurred = set(throttling["occurred"] if throttling else []) + raw_seen = app_sql.get_setting("alert_throttling_seen") + if raw_seen is None: + seen = occurred + app_sql.set_setting( + "alert_throttling_seen", json.dumps(sorted(occurred)) + ) + else: + try: + seen = set(json.loads(raw_seen)) + except (json.JSONDecodeError, TypeError) as error: + raise ValueError("alert_throttling_seen is invalid") from error + newly_occurred = occurred - seen + if raw_seen is not None and occurred != seen: + app_sql.set_setting( + "alert_throttling_seen", json.dumps(sorted(occurred)) + ) + if newly_occurred: + event_until = now + POWER_EVENT_GRACE_SECONDS + app_sql.set_setting( + "alert_power_event_until", + str(event_until), + ) + return { + "active": bool(current) or now < event_until, + "current": sorted(current), + "occurred": sorted(occurred), + "newly_occurred": sorted(newly_occurred), + "recent_event": now < event_until, + "raw": throttling["raw"] if throttling else None, + } + + +def collect_conditions(config): + health = app_health.collect_health() + videos = app_video.list_local_videos() + pending_videos = sum(1 for video in videos["files"] if not video["uploaded"]) + pending_tracking = app_sql.count_unsent_tracking_rows() + speed = app_obd.get_fresh_speed() + fix_age = app_gps.get_fix_age() + + temperature = health["temperature_c"] + disk = health["disk"] + throttling = health["throttling"] + dashcam_disk = _dashcam_usage() + power = _power_condition(throttling) + mil_on = app_obd.get_fresh_mil() + dtc_codes = [ + item["code"] for item in app_obd.get_fresh_dtc_codes() + ] + moving_without_fix = gps_loss_while_moving( + speed, + fix_age, + config["alert_gps_loss_seconds"], + ) + + return { + "mil": { + "active": mil_on is True, + "value": { + "mil_on": mil_on, + "dtc_codes": dtc_codes, + }, + "message": ( + "The vehicle MIL is on." + + (f" Codes: {', '.join(dtc_codes)}" if dtc_codes else "") + ), + "priority": "high", + "tags": ["warning", "car"], + }, + "disk": { + "active": ( + ( + disk is not None + and disk["used_percent"] >= config["alert_disk_percent"] + ) + or ( + dashcam_disk["status"] == "ok" + and dashcam_disk["used_percent"] + >= config["alert_disk_percent"] + ) + ), + "value": { + "pi": disk, + "dashcam": dashcam_disk, + }, + "message": ( + ( + f"Pi storage is {disk['used_percent']:.1f}% full. " + if disk + else "Pi disk usage is unavailable. " + ) + + ( + f"Dashcam storage is {dashcam_disk['used_percent']:.1f}% full." + if dashcam_disk["status"] == "ok" + else f"Dashcam storage status: {dashcam_disk['status']}." + ) + ), + "priority": "high", + "tags": ["warning", "floppy_disk"], + }, + "upload_backlog": { + "active": ( + pending_tracking >= config["alert_tracking_backlog"] + or pending_videos >= config["alert_video_backlog"] + ), + "value": { + "pending_tracking_rows": pending_tracking, + "pending_videos": pending_videos, + }, + "message": ( + f"{pending_tracking} tracking rows and {pending_videos} " + "dashcam videos are waiting to upload." + ), + "priority": "default", + "tags": ["warning", "outbox_tray"], + }, + "temperature": { + "active": ( + temperature is not None + and temperature >= config["alert_temperature_c"] + ), + "value": {"temperature_c": temperature}, + "message": ( + f"Pi CPU temperature is {temperature:.1f} °C." + if temperature is not None + else "Pi temperature is unavailable." + ), + "priority": "high", + "tags": ["warning", "thermometer"], + }, + "power": { + "active": power["active"], + "value": power, + "message": ( + "Pi firmware reports: " + + ", ".join(power["current"]).replace("_", " ") + + "." + if power["current"] + else ( + "Pi firmware recorded a recent power/throttling event: " + + ", ".join( + power["newly_occurred"] or power["occurred"] + ).replace("_", " ") + + "." + if power["recent_event"] + else "Pi power and throttling state is normal." + ) + ), + "priority": "high", + "tags": ["warning", "electric_plug"], + }, + "gps_loss": { + "active": moving_without_fix, + "value": { + "speed_kmh": speed, + "fix_age_seconds": fix_age, + }, + "message": ( + f"The vehicle is moving at {speed:.1f} km/h but the GPS fix " + + ( + "has never been acquired." + if fix_age is None + else f"is {fix_age:.0f} seconds old." + ) + if speed is not None + else "GPS state unavailable." + ), + "priority": "high", + "tags": ["warning", "round_pushpin"], + }, + } + + +def _blank_state(): + return { + "active": False, + "active_since": None, + "last_attempt_at": None, + "last_sent_at": None, + "recovery_pending": False, + "last_recovery_at": None, + "last_error": None, + "last_value": None, + } + + +def process_conditions(conditions, config, now=None, sender=send_ntfy): + now = time.time() if now is None else now + states = app_sql.get_alert_states() + configured = ( + config["enabled"] + and bool(config["ntfy_url"]) + and not config["errors"] + ) + + for key, condition in conditions.items(): + previous = states.get(key, _blank_state()) + state = previous.copy() + was_active = previous["active"] + state["active"] = condition["active"] + state["last_value"] = json.dumps(condition["value"], sort_keys=True) + + if condition["active"]: + if not was_active: + state["active_since"] = now + state["recovery_pending"] = False + due = ( + not was_active + or state["last_sent_at"] is None + or now - state["last_sent_at"] >= config["alert_repeat_seconds"] + ) + retry_ready = ( + not was_active + or state["last_attempt_at"] is None + or now - state["last_attempt_at"] >= ( + CHECK_INTERVAL + if key == "power" + and condition["value"].get("recent_event") + else RETRY_INTERVAL + ) + ) + if configured and due and retry_ready: + state["last_attempt_at"] = now + try: + sender( + config["ntfy_url"], + config["ntfy_token"], + ALERT_TITLES[key], + condition["message"], + condition["priority"], + condition["tags"], + ) + except requests.RequestException as error: + state["last_error"] = _transport_error(error) + else: + state["last_sent_at"] = now + state["last_error"] = None + else: + state["active_since"] = None + if was_active and state["last_sent_at"] is not None: + state["recovery_pending"] = True + retry_ready = ( + was_active + or state["last_attempt_at"] is None + or now - state["last_attempt_at"] >= RETRY_INTERVAL + ) + if configured and state["recovery_pending"] and retry_ready: + state["last_attempt_at"] = now + try: + sender( + config["ntfy_url"], + config["ntfy_token"], + f"Resolved: {ALERT_TITLES[key]}", + f"{ALERT_TITLES[key]} is no longer active.", + "default", + ["white_check_mark"], + ) + except requests.RequestException as error: + state["last_error"] = _transport_error(error) + else: + state["recovery_pending"] = False + state["last_recovery_at"] = now + state["last_error"] = None + + app_sql.save_alert_state(key, state) + + +def check_alerts(): + global _last_run_at, _last_run_error, _last_conditions + config = get_config() + conditions = collect_conditions(config) + process_conditions(conditions, config) + _last_conditions = conditions + _last_run_at = time.time() + _last_run_error = None + return conditions + + +def _loop(): + global _last_run_error + while True: + try: + check_alerts() + except ( + OSError, + ValueError, + RuntimeError, + sqlite3.Error, + requests.RequestException, + ) as error: + _last_run_error = str(error) + print("Alert check failed:", error) + time.sleep(CHECK_INTERVAL) + + +def start_alerting(): + global _thread + if _thread is None or not _thread.is_alive(): + _thread = threading.Thread(target=_loop, name="alerts", daemon=True) + _thread.start() + print(f"Alert thread started, alive: {_thread.is_alive()}") + return _thread + + +def get_status(): + config = get_config() + states = app_sql.get_alert_states() + thread_alive = _thread is not None and _thread.is_alive() + return { + "enabled": config["enabled"], + "configured": bool(config["ntfy_url"]) and not config["errors"], + "config_errors": config["errors"], + "thread_alive": thread_alive, + "last_run_at": _last_run_at, + "last_run_error": ( + _last_run_error + or ("Alert thread is not running." if not thread_alive else None) + ), + "conditions": _last_conditions, + "states": states, + } diff --git a/modules/app_dash.py b/modules/app_dash.py index f163970..62ac732 100644 --- a/modules/app_dash.py +++ b/modules/app_dash.py @@ -274,6 +274,47 @@ def _mounted_source(partition, mount_point, read_only): return mount_point, True, False +def dashcam_storage_usage(mount_point=DEFAULT_MOUNT_POINT): + with _device_lock: + partition = find_dashcam_partition() + if partition is None: + return {"status": "no_device"} + source, mounted_here, _remounted = _mounted_source( + partition, mount_point, read_only=True + ) + if source is None: + return { + "status": "mount_failed", + "path": partition["path"], + } + try: + usage = shutil.disk_usage(source) + used_percent = round( + usage.used / (usage.used + usage.free) * 100, 1 + ) + result = { + "status": "ok", + "path": partition["path"], + "total_bytes": usage.total, + "used_bytes": usage.used, + "free_bytes": usage.free, + "used_percent": used_percent, + } + except OSError as error: + result = { + "status": "read_failed", + "path": partition["path"], + "error": str(error), + } + finally: + if mounted_here and not unmount_device(mount_point): + result = { + "status": "unmount_failed", + "path": partition["path"], + } + return result + + def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT): """List video files on the detected dashcam without copying them.""" with _device_lock: diff --git a/modules/app_gps.py b/modules/app_gps.py index eae16e5..dbc3ef4 100644 --- a/modules/app_gps.py +++ b/modules/app_gps.py @@ -27,6 +27,7 @@ PWRKEY_HOLD_SECONDS = 4 # matches Waveshare's reference script LocationX = None LocationY = None +last_fix_at = None _serial_lock = Lock() _serial = None @@ -148,7 +149,7 @@ def _parse_cgnsinf(resp): def _poll_loop(): - global LocationX, LocationY + global LocationX, LocationY, last_fix_at try: with _serial_lock: if _ensure_module_powered(): @@ -168,11 +169,16 @@ def _poll_loop(): if fix: LocationX = fix["lat"] LocationY = fix["lon"] + last_fix_at = time.monotonic() socketio.emit("gps_location", [LocationX, LocationY]) time.sleep(POLL_INTERVAL) +def get_fix_age(): + return None if last_fix_at is None else time.monotonic() - last_fix_at + + def start_gps(): """Start the background thread that continuously polls the SIM868 for a GNSS fix.""" global _gps_thread diff --git a/modules/app_obd.py b/modules/app_obd.py index b2d01af..c564b42 100644 --- a/modules/app_obd.py +++ b/modules/app_obd.py @@ -22,6 +22,8 @@ throttle_position = None mil_on = None dtc_codes = [] odometer = None +mil_updated_at = None +dtc_updated_at = None class OBD: @@ -251,6 +253,18 @@ def obd_connection(): _emit_disconnected() +def get_fresh_mil(max_age=SENSOR_POLL_INTERVAL * 3): + if mil_updated_at is None or time.monotonic() - mil_updated_at > max_age: + return None + return mil_on + + +def get_fresh_dtc_codes(max_age=SENSOR_POLL_INTERVAL * 3): + if dtc_updated_at is None or time.monotonic() - dtc_updated_at > max_age: + return [] + return list(dtc_codes) + + def obd_speed(): global speed, speed_updated_at with obd_speed_lock: @@ -298,7 +312,7 @@ def _quantity_value(command, unit=None): def _poll_diagnostics(): global coolant_temp, fuel_level, throttle_position - global mil_on, dtc_codes, odometer + global mil_on, dtc_codes, odometer, mil_updated_at, dtc_updated_at coolant_temp = _quantity_value(obd.commands.COOLANT_TEMP, "degC") fuel_level = _quantity_value(obd.commands.FUEL_LEVEL, "percent") @@ -307,6 +321,7 @@ def _poll_diagnostics(): status_response = _query(obd.commands.STATUS) if status_response is not None and not status_response.is_null(): mil_on = bool(status_response.value.MIL) + mil_updated_at = time.monotonic() app_events.socketio.emit( "obd_mil", {"on": mil_on, "dtc_count": status_response.value.DTC_count}, @@ -318,6 +333,7 @@ def _poll_diagnostics(): {"code": code, "description": description} for code, description in dtc_response.value ] + dtc_updated_at = time.monotonic() app_events.socketio.emit("obd_dtc", dtc_codes) odometer_response = _query(ODOMETER_COMMAND, force=True) diff --git a/modules/app_sql.py b/modules/app_sql.py index 9851937..912b0f5 100644 --- a/modules/app_sql.py +++ b/modules/app_sql.py @@ -99,6 +99,18 @@ SQL_CREATE_TRIP_BUILD_STATE_TABLE = """CREATE TABLE IF NOT EXISTS trip_build_sta BUILD_VERSION integer NOT NULL DEFAULT 1 );""" +SQL_CREATE_ALERT_STATE_TABLE = """CREATE TABLE IF NOT EXISTS alert_state ( + ALERT_KEY text PRIMARY KEY, + ACTIVE integer NOT NULL DEFAULT 0, + ACTIVE_SINCE real, + LAST_ATTEMPT_AT real, + LAST_SENT_AT real, + RECOVERY_PENDING integer NOT NULL DEFAULT 0, + LAST_RECOVERY_AT real, + LAST_ERROR text, + LAST_VALUE text + );""" + def create_conn(db_file): """Open a SQLite connection to db_file, creating its parent folder if needed. @@ -143,6 +155,7 @@ def get_conn(): create_table(_conn, SQL_CREATE_DASHCAM_HASH_CACHE_TABLE) create_table(_conn, SQL_CREATE_TRIPS_TABLE) create_table(_conn, SQL_CREATE_TRIP_BUILD_STATE_TABLE) + create_table(_conn, SQL_CREATE_ALERT_STATE_TABLE) _migrate_tracking_table(_conn) _migrate_trips_table(_conn) _migrate_trip_build_state_table(_conn) @@ -392,6 +405,66 @@ def set_setting(key, value): conn.commit() +def get_alert_states(): + conn = get_conn() + if conn is None: + return {} + with _conn_lock: + rows = conn.execute( + """SELECT ALERT_KEY, ACTIVE, ACTIVE_SINCE, LAST_ATTEMPT_AT, + LAST_SENT_AT, RECOVERY_PENDING, LAST_RECOVERY_AT, + LAST_ERROR, LAST_VALUE + FROM alert_state""" + ).fetchall() + return { + row[0]: { + "active": bool(row[1]), + "active_since": row[2], + "last_attempt_at": row[3], + "last_sent_at": row[4], + "recovery_pending": bool(row[5]), + "last_recovery_at": row[6], + "last_error": row[7], + "last_value": row[8], + } + for row in rows + } + + +def save_alert_state(alert_key, state): + conn = get_conn() + if conn is None: + raise RuntimeError("SQLite connection unavailable") + with _conn_lock: + conn.execute( + """INSERT INTO alert_state ( + ALERT_KEY, ACTIVE, ACTIVE_SINCE, LAST_ATTEMPT_AT, + LAST_SENT_AT, RECOVERY_PENDING, LAST_RECOVERY_AT, + LAST_ERROR, LAST_VALUE + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ALERT_KEY) DO UPDATE SET + ACTIVE = excluded.ACTIVE, + ACTIVE_SINCE = excluded.ACTIVE_SINCE, + LAST_ATTEMPT_AT = excluded.LAST_ATTEMPT_AT, + LAST_SENT_AT = excluded.LAST_SENT_AT, + RECOVERY_PENDING = excluded.RECOVERY_PENDING, + LAST_RECOVERY_AT = excluded.LAST_RECOVERY_AT, + LAST_ERROR = excluded.LAST_ERROR, + LAST_VALUE = excluded.LAST_VALUE""", + ( + alert_key, + 1 if state["active"] else 0, + state["active_since"], + state["last_attempt_at"], + state["last_sent_at"], + 1 if state["recovery_pending"] else 0, + state["last_recovery_at"], + state["last_error"], + state["last_value"], + ), + ) + conn.commit() + def get_unsent_tracking_rows(limit=100): """Return up to `limit` tracking rows not yet uploaded to the remote server, oldest first, as a list of dicts ready to be sent as JSON.""" diff --git a/readme.md b/readme.md index b4ac888..29734ad 100644 --- a/readme.md +++ b/readme.md @@ -59,6 +59,9 @@ i have a dream parked writes, plus retry-safe remote sync and automatic backoff - 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit undervoltage/throttling warnings from the Raspberry Pi firmware +- 🔔 **Offline-first alerts** — a notification bell on every page reports check-engine state, + storage pressure, upload backlog, temperature/power faults, and GPS loss while moving. Optional + ntfy delivery is available but remains disabled until configured - 🛣️ **Trip history and route replay** — detects motion from OBD wheel speed with sustained GPS displacement as a fallback, summarizes distance and speed in metric/imperial units, and replays each route as a line on the offline map with a timestamped play/pause timeline and moving marker @@ -98,7 +101,7 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README ## Project status -This project was revived after a couple of years on the shelf. Phases 1-13 and 15-19 are complete: +This project was revived after a couple of years on the shelf. Phases 1-13 and 15-20 are complete: real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging, dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI foundation are implemented. @@ -111,6 +114,6 @@ partition, and clean shutdown still needs delayed-power or ignition-sense hardwa The browser-based phone relay was removed after real-world testing confirmed that mobile browsers cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is -planned later instead. The next roadmap work is alerts, followed by settings and a final UI polish -pass. Full phase history, verification notes, and current scope live in +planned later instead. The next roadmap work is settings, followed by a final UI polish pass. Full +phase history, verification notes, and current scope live in [PLAN.md](./PLAN.md). diff --git a/static/js/alerts.js b/static/js/alerts.js new file mode 100644 index 0000000..856f354 --- /dev/null +++ b/static/js/alerts.js @@ -0,0 +1,91 @@ +(function () { + "use strict"; + + const count = document.getElementById("alert-count"); + const notifications = document.getElementById("alert-notifications"); + + const titles = { + mil: "Check-engine light", + disk: "Pi storage nearly full", + upload_backlog: "Upload backlog growing", + temperature: "Pi temperature high", + power: "Pi power or throttling warning", + gps_loss: "GPS fix lost while moving", + }; + + function renderAlerts(status) { + const active = Object.entries(status.conditions) + .filter(function (entry) { + return entry[1].active; + }); + const stale = status.last_run_at == null + || Date.now() / 1000 - status.last_run_at > 150; + const monitorError = status.last_run_error + || (stale ? "Alert monitor data is stale." : null); + const alertCount = active.length + (monitorError ? 1 : 0); + count.hidden = alertCount === 0; + count.textContent = alertCount; + notifications.replaceChildren(); + + if (monitorError) { + const item = document.createElement("div"); + item.className = "dropdown-item-text border-top py-2"; + const title = document.createElement("strong"); + title.className = "d-block text-danger small"; + title.textContent = "Alert monitor problem"; + const message = document.createElement("span"); + message.className = "d-block small text-wrap"; + message.textContent = monitorError; + item.append(title, message); + notifications.appendChild(item); + } + + if (!active.length && !monitorError) { + const empty = document.createElement("div"); + empty.className = "dropdown-item-text text-muted small"; + empty.textContent = "No active alerts"; + notifications.appendChild(empty); + return; + } + + active.forEach(function (entry) { + const item = document.createElement("div"); + item.className = "dropdown-item-text border-top py-2"; + const title = document.createElement("strong"); + title.className = "d-block text-danger small"; + title.textContent = titles[entry[0]] || entry[0]; + const message = document.createElement("span"); + message.className = "d-block small text-wrap"; + message.textContent = entry[1].message; + item.append(title, message); + notifications.appendChild(item); + }); + } + + function showUnavailable(message) { + count.hidden = true; + notifications.replaceChildren(); + const error = document.createElement("div"); + error.className = "dropdown-item-text text-danger small"; + error.textContent = message; + notifications.appendChild(error); + } + + async function refreshAlerts() { + const response = await fetch("/api/alerts/status"); + const status = await response.json(); + if (!response.ok) { + throw new Error(status.error || "Could not load alerts."); + } + renderAlerts(status); + } + + refreshAlerts().catch(function (error) { + showUnavailable(error.message); + }); + window.setInterval(function () { + refreshAlerts().catch(function (error) { + showUnavailable(error.message); + }); + }, 30000); +}()); diff --git a/static/yaris.css b/static/yaris.css index ace037a..d7ff0d5 100644 --- a/static/yaris.css +++ b/static/yaris.css @@ -5,6 +5,11 @@ min-height: 24rem; } +#alert-menu { + min-width: min(22rem, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); +} + .app-sidebar { color: var(--bs-accordion-btn-focus-border-color); background: #000; diff --git a/templates/base.html b/templates/base.html index 6cd9d0b..99e1f9b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -55,6 +55,18 @@