From 32de77131c372d2383c2b50be4a940d007695b68 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Tue, 11 Aug 2026 19:31:52 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20odometer=20baseline=20syncs=20to=20serv?= =?UTF-8?q?er=20=E2=80=94=20push=20on=20save,=20pull=20on=20sync,=20+/api/?= =?UTF-8?q?device-config/push=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/app_device_config.py | 1 + modules/app_device_sync.py | 2 ++ modules/app_settings.py | 25 ++++++++++++++++ server/app.py | 16 ++++++++++ server/modules/device_config.py | 40 +++++++++++-------------- server/sql/migrate_phase27_odometer.sql | 2 ++ 6 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 server/sql/migrate_phase27_odometer.sql diff --git a/modules/app_device_config.py b/modules/app_device_config.py index 890e666..225f2ff 100644 --- a/modules/app_device_config.py +++ b/modules/app_device_config.py @@ -16,6 +16,7 @@ def get_device_config(): "car_model": app_sql.get_setting("device_car_model", ""), "device_name": app_sql.get_setting("device_device_name", ""), "avatar_path": app_sql.get_setting("device_avatar_path", ""), + "odometer_baseline": float(app_sql.get_setting("odometer_baseline", "0") or "0"), "sync_enabled": sync_enabled, } # Fill in any missing values from config file or defaults diff --git a/modules/app_device_sync.py b/modules/app_device_sync.py index 122890a..4e40566 100644 --- a/modules/app_device_sync.py +++ b/modules/app_device_sync.py @@ -122,6 +122,8 @@ def _store_synced_config(config): app_sql.set_setting("device_avatar_path", config.get("avatar_path", "img/yaris.png")) app_sql.set_setting("device_config_version", str(config.get("config_version", 0))) app_sql.set_setting("device_config_synced_at", str(int(time.time()))) + if config.get("odometer_baseline") is not None: + app_sql.set_setting("odometer_baseline", str(config["odometer_baseline"])) def _on_config_changed(): diff --git a/modules/app_settings.py b/modules/app_settings.py index 614dabb..14cb1aa 100644 --- a/modules/app_settings.py +++ b/modules/app_settings.py @@ -220,4 +220,29 @@ def update_settings(body): settings["alert_ntfy_token"] = ntfy_token app_sql.set_settings(settings) + + # Push odometer_baseline to server if configured and non-zero + if values["odometer_baseline"] is not None and values["odometer_baseline"] > 0: + _push_odometer_to_server(values["odometer_baseline"]) + return {} + + +def _push_odometer_to_server(baseline): + try: + from . import app_upload + creds = app_upload._credentials() + if not creds["server_url"] or not creds["device_name"] or not creds["api_key"]: + return + import requests + requests.post( + f"{creds['server_url']}/api/device-config/push", + headers={ + "X-Device-Name": creds["device_name"], + "X-Api-Key": creds["api_key"], + }, + json={"odometer_baseline": baseline}, + timeout=5, + ) + except Exception: + pass diff --git a/server/app.py b/server/app.py index d7fa7ae..6a23130 100644 --- a/server/app.py +++ b/server/app.py @@ -781,6 +781,22 @@ def api_device_config_get(): return jsonify(config) +@app.post("/api/device-config/push") +@require_device_auth +def api_device_config_push(): + """Update device configuration from the Pi (device-authenticated). + + Pi pushes local config changes to server. Accepts a partial update: + any of owner_name, car_model, device_name, avatar_path, odometer_baseline. + """ + body = request.get_json(silent=True) or {} + success, errors = device_config.update_device_config(g.device_id, body) + if not success: + return jsonify(error="Invalid device config.", fields=errors), 400 + config = device_config.get_device_config(g.device_id) + return jsonify(config) + + @app.post("/api/v1/device-config/ack") @require_device_auth def api_device_config_ack(): diff --git a/server/modules/device_config.py b/server/modules/device_config.py index 7c32f28..7fd44aa 100644 --- a/server/modules/device_config.py +++ b/server/modules/device_config.py @@ -20,6 +20,7 @@ def get_device_config(device_id): "car_model": dev.get("car_model") or "", "device_name": dev.get("device_name") or "", "avatar_path": dev.get("avatar_path") or "img/yaris.png", + "odometer_baseline": float(dev.get("odometer_baseline") or 0), "config_version": dev.get("config_version") or 0, } @@ -41,51 +42,44 @@ def update_device_config(device_id, data): car_model = data.get("car_model", "").strip() if "car_model" in data else None device_name = data.get("device_name", "").strip() if "device_name" in data else None avatar_path = data.get("avatar_path", "").strip() if "avatar_path" in data else None + odometer_baseline = data.get("odometer_baseline") if "odometer_baseline" in data else None if owner_name is not None and len(owner_name) > 100: errors["owner_name"] = "Owner name must be 1-100 characters." - if car_model is not None and len(car_model) > 100: errors["car_model"] = "Car model must be 1-100 characters." - if device_name is not None and len(device_name) > 100: errors["device_name"] = "Device name must be 1-100 characters." - if avatar_path is not None and len(avatar_path) > 255: errors["avatar_path"] = "Avatar path must be 1-255 characters." + if odometer_baseline is not None: + try: + odometer_baseline = float(odometer_baseline) + if odometer_baseline < 0 or odometer_baseline > 999999: + errors["odometer_baseline"] = "Must be between 0 and 999,999 km." + except (TypeError, ValueError): + errors["odometer_baseline"] = "Must be a number." if errors: return False, errors - # Build update dict (only include provided fields) with db.get_cursor(commit=True) as cur: - # Increment config_version if any field changed - if owner_name is not None or car_model is not None or device_name is not None or avatar_path is not None: + changed = owner_name is not None or car_model is not None or device_name is not None or avatar_path is not None or odometer_baseline is not None + if changed: cur.execute( "UPDATE devices SET config_version = config_version + 1, config_updated_at = now() WHERE id = %s", (device_id,) ) - # Update individual fields if owner_name is not None: - cur.execute( - "UPDATE devices SET owner_name = %s WHERE id = %s", - (owner_name, device_id) - ) + cur.execute("UPDATE devices SET owner_name = %s WHERE id = %s", (owner_name, device_id)) if car_model is not None: - cur.execute( - "UPDATE devices SET car_model = %s WHERE id = %s", - (car_model, device_id) - ) + cur.execute("UPDATE devices SET car_model = %s WHERE id = %s", (car_model, device_id)) if device_name is not None: - cur.execute( - "UPDATE devices SET device_name = %s WHERE id = %s", - (device_name, device_id) - ) + cur.execute("UPDATE devices SET device_name = %s WHERE id = %s", (device_name, device_id)) if avatar_path is not None: - cur.execute( - "UPDATE devices SET avatar_path = %s WHERE id = %s", - (avatar_path, device_id) - ) + cur.execute("UPDATE devices SET avatar_path = %s WHERE id = %s", (avatar_path, device_id)) + if odometer_baseline is not None: + cur.execute("UPDATE devices SET odometer_baseline = %s WHERE id = %s", (odometer_baseline, device_id)) return True, {} diff --git a/server/sql/migrate_phase27_odometer.sql b/server/sql/migrate_phase27_odometer.sql new file mode 100644 index 0000000..eac37dd --- /dev/null +++ b/server/sql/migrate_phase27_odometer.sql @@ -0,0 +1,2 @@ +-- Phase 27: Add odometer_baseline to devices table for server-side sync. +ALTER TABLE devices ADD COLUMN IF NOT EXISTS odometer_baseline DOUBLE PRECISION NOT NULL DEFAULT 0;