Add offline and ntfy alerts
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -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`
|
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.
|
and deferred offline map tiles until a real GPS fix, avoiding irrelevant placeholder requests.
|
||||||
|
|
||||||
### Phase 20: Alerts/notifications — 🚧 PLANNED
|
### Phase 20: Alerts/notifications — ✅ DONE
|
||||||
**New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to
|
Added an offline-first notification system. A shared bell in the top bar on every web page shows
|
||||||
alert on. Right now, finding out about a problem (check-engine light on, dashcam SD card nearly
|
active local alerts with no modem, SIM card, or internet connection. Optional ntfy delivery mirrors
|
||||||
full, upload backlog growing unbounded, Pi overheating, GPS fix lost for a long stretch) requires
|
the same conditions only when explicitly enabled and configured; it defaults off until Phase 21
|
||||||
manually opening the web UI. Add a lightweight push-notification integration - no new
|
exposes its topic URL, optional access token, and thresholds.
|
||||||
infrastructure needed, just an outbound webhook to something like `ntfy.sh` or a Telegram bot -
|
|
||||||
fired on a handful of conditions:
|
Monitored conditions:
|
||||||
- DTC/MIL status changes to "on" (from Phase 13's expanded OBD data).
|
- MIL/check-engine state and DTC codes.
|
||||||
- Dashcam USB storage or local `dash/videos/` disk usage crossing a configurable threshold.
|
- Pi filesystem pressure and the physical dashcam USB filesystem, checked read-only and cached for
|
||||||
- Upload backlog (pending tracking rows or dashcam files) growing past a configurable size,
|
five minutes to avoid repeated mounts.
|
||||||
suggesting the Pi's been offline/uploading too slowly for a while.
|
- Pending tracking-row or local dashcam-video upload backlog.
|
||||||
- Pi CPU temperature crossing a configurable threshold (from Phase 19).
|
- Pi CPU temperature and Raspberry Pi firmware undervoltage/throttling flags.
|
||||||
- GPS fix lost for longer than a configurable duration while the car appears to be moving
|
- GPS fix loss for a configured duration while fresh OBD wheel speed confirms movement.
|
||||||
(OBD speed > 0 with no fix - needs Phase 13's data).
|
|
||||||
Webhook target/thresholds configurable via the Phase 21 Settings page rather than hardcoded.
|
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
|
### Phase 21: Settings page — 🚧 PLANNED
|
||||||
**New phase - depends on Phase 15's shared components, and benefits from Phases 17/20 existing**
|
**New phase - depends on Phase 15's shared components, and benefits from Phases 17/20 existing**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
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
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ car = app_car.Car()
|
|||||||
gps = app_gps.start_gps()
|
gps = app_gps.start_gps()
|
||||||
app_sql.start_logging()
|
app_sql.start_logging()
|
||||||
app_upload.start_uploading()
|
app_upload.start_uploading()
|
||||||
|
app_alerts.start_alerting()
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -144,6 +145,10 @@ def upload_status():
|
|||||||
def health():
|
def health():
|
||||||
return jsonify(app_health.collect_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'])
|
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
||||||
def upload_dashcam_toggle():
|
def upload_dashcam_toggle():
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -274,6 +274,47 @@ def _mounted_source(partition, mount_point, read_only):
|
|||||||
return mount_point, True, False
|
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):
|
def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT):
|
||||||
"""List video files on the detected dashcam without copying them."""
|
"""List video files on the detected dashcam without copying them."""
|
||||||
with _device_lock:
|
with _device_lock:
|
||||||
|
|||||||
+7
-1
@@ -27,6 +27,7 @@ PWRKEY_HOLD_SECONDS = 4 # matches Waveshare's reference script
|
|||||||
|
|
||||||
LocationX = None
|
LocationX = None
|
||||||
LocationY = None
|
LocationY = None
|
||||||
|
last_fix_at = None
|
||||||
|
|
||||||
_serial_lock = Lock()
|
_serial_lock = Lock()
|
||||||
_serial = None
|
_serial = None
|
||||||
@@ -148,7 +149,7 @@ def _parse_cgnsinf(resp):
|
|||||||
|
|
||||||
|
|
||||||
def _poll_loop():
|
def _poll_loop():
|
||||||
global LocationX, LocationY
|
global LocationX, LocationY, last_fix_at
|
||||||
try:
|
try:
|
||||||
with _serial_lock:
|
with _serial_lock:
|
||||||
if _ensure_module_powered():
|
if _ensure_module_powered():
|
||||||
@@ -168,11 +169,16 @@ def _poll_loop():
|
|||||||
if fix:
|
if fix:
|
||||||
LocationX = fix["lat"]
|
LocationX = fix["lat"]
|
||||||
LocationY = fix["lon"]
|
LocationY = fix["lon"]
|
||||||
|
last_fix_at = time.monotonic()
|
||||||
socketio.emit("gps_location", [LocationX, LocationY])
|
socketio.emit("gps_location", [LocationX, LocationY])
|
||||||
|
|
||||||
time.sleep(POLL_INTERVAL)
|
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():
|
def start_gps():
|
||||||
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
|
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
|
||||||
global _gps_thread
|
global _gps_thread
|
||||||
|
|||||||
+17
-1
@@ -22,6 +22,8 @@ throttle_position = None
|
|||||||
mil_on = None
|
mil_on = None
|
||||||
dtc_codes = []
|
dtc_codes = []
|
||||||
odometer = None
|
odometer = None
|
||||||
|
mil_updated_at = None
|
||||||
|
dtc_updated_at = None
|
||||||
|
|
||||||
|
|
||||||
class OBD:
|
class OBD:
|
||||||
@@ -251,6 +253,18 @@ def obd_connection():
|
|||||||
_emit_disconnected()
|
_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():
|
def obd_speed():
|
||||||
global speed, speed_updated_at
|
global speed, speed_updated_at
|
||||||
with obd_speed_lock:
|
with obd_speed_lock:
|
||||||
@@ -298,7 +312,7 @@ def _quantity_value(command, unit=None):
|
|||||||
|
|
||||||
def _poll_diagnostics():
|
def _poll_diagnostics():
|
||||||
global coolant_temp, fuel_level, throttle_position
|
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")
|
coolant_temp = _quantity_value(obd.commands.COOLANT_TEMP, "degC")
|
||||||
fuel_level = _quantity_value(obd.commands.FUEL_LEVEL, "percent")
|
fuel_level = _quantity_value(obd.commands.FUEL_LEVEL, "percent")
|
||||||
@@ -307,6 +321,7 @@ def _poll_diagnostics():
|
|||||||
status_response = _query(obd.commands.STATUS)
|
status_response = _query(obd.commands.STATUS)
|
||||||
if status_response is not None and not status_response.is_null():
|
if status_response is not None and not status_response.is_null():
|
||||||
mil_on = bool(status_response.value.MIL)
|
mil_on = bool(status_response.value.MIL)
|
||||||
|
mil_updated_at = time.monotonic()
|
||||||
app_events.socketio.emit(
|
app_events.socketio.emit(
|
||||||
"obd_mil",
|
"obd_mil",
|
||||||
{"on": mil_on, "dtc_count": status_response.value.DTC_count},
|
{"on": mil_on, "dtc_count": status_response.value.DTC_count},
|
||||||
@@ -318,6 +333,7 @@ def _poll_diagnostics():
|
|||||||
{"code": code, "description": description}
|
{"code": code, "description": description}
|
||||||
for code, description in dtc_response.value
|
for code, description in dtc_response.value
|
||||||
]
|
]
|
||||||
|
dtc_updated_at = time.monotonic()
|
||||||
app_events.socketio.emit("obd_dtc", dtc_codes)
|
app_events.socketio.emit("obd_dtc", dtc_codes)
|
||||||
|
|
||||||
odometer_response = _query(ODOMETER_COMMAND, force=True)
|
odometer_response = _query(ODOMETER_COMMAND, force=True)
|
||||||
|
|||||||
@@ -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
|
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):
|
def create_conn(db_file):
|
||||||
"""Open a SQLite connection to db_file, creating its parent folder if needed.
|
"""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_DASHCAM_HASH_CACHE_TABLE)
|
||||||
create_table(_conn, SQL_CREATE_TRIPS_TABLE)
|
create_table(_conn, SQL_CREATE_TRIPS_TABLE)
|
||||||
create_table(_conn, SQL_CREATE_TRIP_BUILD_STATE_TABLE)
|
create_table(_conn, SQL_CREATE_TRIP_BUILD_STATE_TABLE)
|
||||||
|
create_table(_conn, SQL_CREATE_ALERT_STATE_TABLE)
|
||||||
_migrate_tracking_table(_conn)
|
_migrate_tracking_table(_conn)
|
||||||
_migrate_trips_table(_conn)
|
_migrate_trips_table(_conn)
|
||||||
_migrate_trip_build_state_table(_conn)
|
_migrate_trip_build_state_table(_conn)
|
||||||
@@ -392,6 +405,66 @@ def set_setting(key, value):
|
|||||||
conn.commit()
|
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):
|
def get_unsent_tracking_rows(limit=100):
|
||||||
"""Return up to `limit` tracking rows not yet uploaded to the remote server,
|
"""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."""
|
oldest first, as a list of dicts ready to be sent as JSON."""
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ i have a dream
|
|||||||
parked writes, plus retry-safe remote sync and automatic backoff
|
parked writes, plus retry-safe remote sync and automatic backoff
|
||||||
- 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit
|
- 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit
|
||||||
undervoltage/throttling warnings from the Raspberry Pi firmware
|
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
|
- 🛣️ **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
|
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
|
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
|
## 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,
|
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
|
dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI
|
||||||
foundation are implemented.
|
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
|
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
|
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
|
planned later instead. The next roadmap work is settings, followed by a final UI polish pass. Full
|
||||||
pass. Full phase history, verification notes, and current scope live in
|
phase history, verification notes, and current scope live in
|
||||||
[PLAN.md](./PLAN.md).
|
[PLAN.md](./PLAN.md).
|
||||||
|
|||||||
@@ -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,6 +5,11 @@
|
|||||||
min-height: 24rem;
|
min-height: 24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#alert-menu {
|
||||||
|
min-width: min(22rem, calc(100vw - 2rem));
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
color: var(--bs-accordion-btn-focus-border-color);
|
color: var(--bs-accordion-btn-focus-border-color);
|
||||||
background: #000;
|
background: #000;
|
||||||
|
|||||||
@@ -55,6 +55,18 @@
|
|||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="ms-auto d-flex align-items-center">
|
<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>
|
<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="">
|
<img class="border rounded-circle img-profile" src="{{ url_for('static', filename='img/yaris.png') }}" alt="">
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +90,7 @@
|
|||||||
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
<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/theme.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/data-list.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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user