161 lines
5.0 KiB
Python
161 lines
5.0 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 . 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())))
|
|
if config.get("odometer_baseline") is not None:
|
|
app_sql.set_setting("odometer_baseline", str(config["odometer_baseline"]))
|
|
|
|
|
|
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", "")
|