Files
yaris-obd/modules/app_device_sync.py
T
Mathieu MoranandCopilot 5a5058adca fix: reuse upload credentials for device config sync
- load server sync creds from db/upload_creds.env like uploads do
- fall back from YARIS_DEVICE_KEY to existing YARIS_API_KEY
- document shared credential flow for server-synced identity
- add test coverage for sync credential fallback

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:37:52 -04:00

149 lines
4.7 KiB
Python

"""Device configuration sync from remote server (Phase 25.5a).
Pi fetches device config from server when internet is available (connection-driven).
Stores in SQLite and hot-reloads app globals if config changes.
"""
import os
import subprocess
import threading
import time
import requests
from dotenv import load_dotenv
from . import app_network, app_sql
_SYNC_THREAD = None
_SYNC_STOP = False
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
_RELOAD_CALLBACK = None
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, "db", "upload_creds.env"))
def _has_internet():
"""Use the existing NetworkManager-derived status instead of probing 8.8.8.8 over HTTP."""
try:
return app_network.get_status().get("internet_reachable", False)
except (
OSError,
subprocess.SubprocessError,
ValueError,
KeyError,
app_network.NetworkConfigurationError,
):
return False
def _server_credentials():
server_url = os.getenv("YARIS_SERVER_URL", "").rstrip("/")
device_key = os.getenv("YARIS_DEVICE_KEY") or os.getenv("YARIS_API_KEY", "")
return server_url, device_key
def start_sync():
"""Start background device config sync thread."""
global _SYNC_THREAD, _SYNC_STOP
if _SYNC_THREAD is not None:
return
_SYNC_STOP = False
_SYNC_THREAD = threading.Thread(target=_sync_loop, daemon=True)
_SYNC_THREAD.start()
def register_reload_callback(callback):
"""Register a callback invoked after synced config is written locally."""
global _RELOAD_CALLBACK
_RELOAD_CALLBACK = callback
def stop_sync():
"""Stop background device config sync thread."""
global _SYNC_STOP
_SYNC_STOP = True
def _sync_loop():
"""Periodically sync device config from server when internet available."""
global _SYNC_CONFIG_VERSION
server_url, device_key = _server_credentials()
if not server_url or not device_key:
return # Sync disabled if server URL or device key not configured
# Sync every 5 minutes (300 sec) when connected, backoff to 60 sec when offline
sync_interval = 300
offline_interval = 60
while not _SYNC_STOP:
try:
is_connected = _has_internet()
if is_connected:
# Fetch device config from server
headers = {"Authorization": f"Bearer {device_key}"}
response = requests.get(
f"{server_url.rstrip('/')}/api/device-config",
headers=headers,
timeout=5
)
if response.status_code == 200:
config = response.json()
# Check if config changed
new_version = config.get("config_version", 0)
if new_version != _SYNC_CONFIG_VERSION:
_SYNC_CONFIG_VERSION = new_version
# Store in database
_store_synced_config(config)
# Signal that config changed (for hot-reload)
_on_config_changed()
time.sleep(sync_interval)
else:
# Offline: backoff to less frequent checks
time.sleep(offline_interval)
except Exception as e:
# Log error but don't crash sync thread
print(f"Device config sync error: {e}")
time.sleep(60)
def _store_synced_config(config):
"""Store synced config in SQLite database."""
app_sql.set_setting("device_owner_name", config.get("owner_name", ""))
app_sql.set_setting("device_car_model", config.get("car_model", ""))
app_sql.set_setting("device_device_name", config.get("device_name", ""))
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())))
def _on_config_changed():
"""Called when config changes and needs to be reloaded by app."""
if _RELOAD_CALLBACK is not None:
_RELOAD_CALLBACK()
def is_sync_enabled():
"""Check if device config sync from server is enabled."""
server_url, device_key = _server_credentials()
return bool(server_url) and bool(device_key)
def get_synced_config_version():
"""Get the last-synced config version number."""
return int(app_sql.get_setting("device_config_version", "0"))
def get_synced_at_timestamp():
"""Get timestamp of last successful sync."""
return app_sql.get_setting("device_config_synced_at", "")