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`
|
||||
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**
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
#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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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