Add offline and ntfy alerts

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 20:28:42 -04:00
co-authored by Copilot
parent 8b664667e5
commit 96332adab6
12 changed files with 1098 additions and 21 deletions
+43 -15
View File
@@ -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**
+6 -1
View File
@@ -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 {}
+489
View File
@@ -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,
}
+41
View File
@@ -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:
+7 -1
View File
@@ -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
+17 -1
View File
@@ -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)
+73
View File
@@ -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."""
+6 -3
View File
@@ -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).
+91
View File
@@ -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);
}());
+5
View File
@@ -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;
+13
View File
@@ -55,6 +55,18 @@
<i class="fas fa-bars"></i>
</button>
<div class="ms-auto d-flex align-items-center">
<div class="dropdown me-3">
<button class="btn btn-link position-relative p-1" id="alert-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="System alerts">
<i class="fas fa-bell fa-fw"></i>
<span id="alert-count" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" hidden></span>
</button>
<div class="dropdown-menu dropdown-menu-end shadow" id="alert-menu" aria-labelledby="alert-toggle">
<h6 class="dropdown-header">System alerts</h6>
<div id="alert-notifications">
<div class="dropdown-item-text text-muted small">Loading...</div>
</div>
</div>
</div>
<span id="index-pp" class="d-none d-sm-inline me-2 text-gray-600 small">{{ account.name }}</span>
<img class="border rounded-circle img-profile" src="{{ url_for('static', filename='img/yaris.png') }}" alt="">
</div>
@@ -78,6 +90,7 @@
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/data-list.js') }}"></script>
<script src="{{ url_for('static', filename='js/alerts.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
+307
View File
@@ -0,0 +1,307 @@
import unittest
from unittest.mock import patch
import requests
from modules import app_alerts
def condition(active=True):
return {
"active": active,
"value": {"value": 1},
"message": "Test alert",
"priority": "default",
"tags": ["test"],
}
class AlertEngineTest(unittest.TestCase):
def setUp(self):
self.states = {}
self.saved = {}
self.config = {
"enabled": True,
"ntfy_url": "http://127.0.0.1/topic",
"ntfy_token": "",
"alert_repeat_seconds": 3600,
"errors": {},
}
self.send_calls = []
def sender(self, *args):
self.send_calls.append(args)
def process(self, active, now):
with (
patch(
"modules.app_alerts.app_sql.get_alert_states",
return_value=self.states,
),
patch(
"modules.app_alerts.app_sql.save_alert_state",
side_effect=lambda key, state: self.saved.update({key: state}),
),
):
app_alerts.process_conditions(
{"mil": condition(active)},
self.config,
now=now,
sender=self.sender,
)
self.states = {key: value.copy() for key, value in self.saved.items()}
def test_sends_once_on_transition_and_repeats_after_cooldown(self):
self.process(True, 100)
self.process(True, 200)
self.process(True, 3800)
self.assertEqual(len(self.send_calls), 2)
self.assertEqual(self.states["mil"]["last_sent_at"], 3800)
def test_sends_recovery_after_active_alert(self):
self.process(True, 100)
self.process(False, 200)
self.assertEqual(len(self.send_calls), 2)
self.assertTrue(self.send_calls[1][2].startswith("Resolved:"))
self.assertFalse(self.states["mil"]["recovery_pending"])
def test_failed_send_is_retried_after_retry_interval(self):
attempts = []
def failing_sender(*args):
attempts.append(args)
raise requests.ConnectionError("offline")
with (
patch(
"modules.app_alerts.app_sql.get_alert_states",
return_value={},
),
patch(
"modules.app_alerts.app_sql.save_alert_state",
side_effect=lambda key, state: self.saved.update({key: state}),
),
):
app_alerts.process_conditions(
{"mil": condition(True)},
self.config,
now=100,
sender=failing_sender,
)
self.states = {"mil": self.saved["mil"].copy()}
self.assertEqual(
self.states["mil"]["last_error"],
"Notification connection failed",
)
self.process(True, 200)
self.process(True, 401)
self.assertEqual(len(attempts), 1)
self.assertEqual(len(self.send_calls), 1)
self.assertEqual(self.states["mil"]["last_sent_at"], 401)
def test_disabled_alerts_track_condition_without_sending(self):
self.config["enabled"] = False
self.process(True, 100)
self.assertEqual(self.send_calls, [])
self.assertTrue(self.states["mil"]["active"])
self.assertIsNone(self.states["mil"]["last_sent_at"])
class NtfyTransportTest(unittest.TestCase):
@patch("modules.app_alerts.requests.post")
def test_sends_ntfy_headers_body_and_token(self, post):
response = post.return_value
response.raise_for_status.return_value = None
app_alerts.send_ntfy(
"https://ntfy.example/topic",
"secret",
"Alert title",
"Alert body",
"high",
["warning", "car"],
)
post.assert_called_once_with(
"https://ntfy.example/topic",
data=b"Alert body",
headers={
"Title": "Alert title",
"Priority": "high",
"Tags": "warning,car",
"Authorization": "Bearer secret",
},
timeout=5,
)
response.raise_for_status.assert_called_once_with()
def test_transport_error_does_not_expose_url(self):
response = requests.Response()
response.status_code = 401
error = requests.HTTPError(
"401 for https://ntfy.example/private-topic",
response=response,
)
message = app_alerts._transport_error(error)
self.assertEqual(message, "Notification server returned HTTP 401")
self.assertNotIn("private-topic", message)
class GpsLossTest(unittest.TestCase):
def setUp(self):
app_alerts._moving_started_at = None
def test_waits_for_full_timeout_before_first_fix(self):
self.assertFalse(
app_alerts.gps_loss_while_moving(20, None, 60, now=100)
)
self.assertFalse(
app_alerts.gps_loss_while_moving(20, None, 60, now=159)
)
self.assertTrue(
app_alerts.gps_loss_while_moving(20, None, 60, now=160)
)
def test_stopping_resets_moving_timer(self):
app_alerts.gps_loss_while_moving(20, None, 60, now=100)
self.assertFalse(
app_alerts.gps_loss_while_moving(0, None, 60, now=170)
)
self.assertFalse(
app_alerts.gps_loss_while_moving(20, None, 60, now=171)
)
class PowerConditionTest(unittest.TestCase):
@patch("modules.app_alerts.app_sql.set_setting")
@patch("modules.app_alerts.app_sql.get_setting")
def test_new_historical_flag_stays_active_during_grace(
self, get_setting, set_setting
):
get_setting.side_effect = lambda key, default=None: {
"alert_throttling_seen": "[]",
"alert_power_event_until": "0",
}.get(key, default)
power = app_alerts._power_condition(
{
"current": [],
"occurred": ["under_voltage"],
"raw": "0x10000",
},
now=100,
)
self.assertTrue(power["active"])
self.assertEqual(power["newly_occurred"], ["under_voltage"])
self.assertEqual(set_setting.call_count, 2)
def test_cleared_firmware_flag_can_alert_again(self):
settings = {
"alert_throttling_seen": '["under_voltage"]',
"alert_power_event_until": "0",
}
def get_setting(key, default=None):
return settings.get(key, default)
def set_setting(key, value):
settings[key] = value
with (
patch(
"modules.app_alerts.app_sql.get_setting",
side_effect=get_setting,
),
patch(
"modules.app_alerts.app_sql.set_setting",
side_effect=set_setting,
),
):
cleared = app_alerts._power_condition(
{"current": [], "occurred": [], "raw": "0x0"},
now=100,
)
recurred = app_alerts._power_condition(
{
"current": [],
"occurred": ["under_voltage"],
"raw": "0x10000",
},
now=200,
)
self.assertFalse(cleared["active"])
self.assertTrue(recurred["active"])
self.assertEqual(recurred["newly_occurred"], ["under_voltage"])
@patch("modules.app_alerts.app_sql.set_setting")
@patch("modules.app_alerts.app_sql.get_setting")
def test_unavailable_read_preserves_seen_flags(
self, get_setting, set_setting
):
get_setting.side_effect = lambda key, default=None: {
"alert_throttling_seen": '["under_voltage"]',
"alert_power_event_until": "0",
}.get(key, default)
power = app_alerts._power_condition(None, now=100)
self.assertFalse(power["active"])
set_setting.assert_not_called()
class DashcamUsageTest(unittest.TestCase):
def setUp(self):
app_alerts._last_dashcam_check_at = None
app_alerts._last_dashcam_usage = {"status": "not_checked"}
@patch("modules.app_alerts.app_dash.dashcam_storage_usage")
def test_caches_dashcam_usage_for_five_minutes(self, storage_usage):
storage_usage.return_value = {
"status": "ok",
"used_percent": 75,
}
first = app_alerts._dashcam_usage(now=100)
cached = app_alerts._dashcam_usage(now=200)
refreshed = app_alerts._dashcam_usage(now=401)
self.assertEqual(first, cached)
self.assertEqual(refreshed["used_percent"], 75)
self.assertEqual(storage_usage.call_count, 2)
class DiagnosticFreshnessTest(unittest.TestCase):
def setUp(self):
self.original_mil = app_alerts.app_obd.mil_on
self.original_mil_updated = app_alerts.app_obd.mil_updated_at
self.original_dtc = app_alerts.app_obd.dtc_codes
self.original_dtc_updated = app_alerts.app_obd.dtc_updated_at
def tearDown(self):
app_alerts.app_obd.mil_on = self.original_mil
app_alerts.app_obd.mil_updated_at = self.original_mil_updated
app_alerts.app_obd.dtc_codes = self.original_dtc
app_alerts.app_obd.dtc_updated_at = self.original_dtc_updated
@patch("modules.app_obd.time.monotonic", return_value=100)
def test_stale_mil_and_dtc_values_are_suppressed(self, _monotonic):
app_alerts.app_obd.mil_on = True
app_alerts.app_obd.mil_updated_at = 50
app_alerts.app_obd.dtc_codes = [{"code": "P0001"}]
app_alerts.app_obd.dtc_updated_at = 50
self.assertIsNone(app_alerts.app_obd.get_fresh_mil())
self.assertEqual(app_alerts.app_obd.get_fresh_dtc_codes(), [])
if __name__ == "__main__":
unittest.main()