"""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 . import app_network, app_remote_config, app_sql _SYNC_THREAD = None _SYNC_STOP = False _SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes _RELOAD_CALLBACK = None 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(): credentials = app_remote_config.server_credentials() device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"] return credentials["server_url"], credentials["device_name"], device_key def start_sync(): """Start background device config sync thread.""" global _SYNC_THREAD, _SYNC_STOP if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive(): 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.""" sync_interval = 300 offline_interval = 60 while not _SYNC_STOP: try: result = sync_now() if result["status"] in {"synced", "no_change"}: time.sleep(sync_interval) else: time.sleep(offline_interval) except Exception as e: print(f"Device config sync error: {e}") time.sleep(60) def sync_now(): """Perform one immediate sync attempt.""" global _SYNC_CONFIG_VERSION server_url, device_name, device_key = _server_credentials() if not server_url or not device_name or not device_key: return {"status": "not_configured", "message": "Remote server credentials are incomplete."} if not _has_internet(): return {"status": "offline", "message": "Internet is not currently reachable."} response = requests.get( f"{server_url}/api/device-config", headers={ "X-Device-Name": device_name, "X-Api-Key": device_key, }, timeout=5, ) response.raise_for_status() config = response.json() new_version = config.get("config_version", 0) if new_version != _SYNC_CONFIG_VERSION: _SYNC_CONFIG_VERSION = new_version _store_synced_config(config) _acknowledge_sync(server_url, device_name, device_key, new_version) _on_config_changed() return {"status": "synced", "message": f"Synced config version {new_version}."} _acknowledge_sync(server_url, device_name, device_key, new_version) return {"status": "no_change", "message": "Already up to date."} 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_name, device_key = _server_credentials() return bool(server_url) and bool(device_name) and bool(device_key) def _acknowledge_sync(server_url, device_name, device_key, config_version): requests.post( f"{server_url}/api/v1/device-config/ack", json={"config_version": config_version}, headers={ "X-Device-Name": device_name, "X-Api-Key": device_key, }, timeout=5, ).raise_for_status() 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", "")