feat: add multi-device provisioning and local remote-config UI

- add multi-device portal device management, key rotation, and sync request APIs
- add Pi settings UI for remote server URL, device name, and API key
- add local test-connection and sync-now actions
- make upload and config sync reuse dynamic shared credentials
- add Phase 25.5c migration and docs updates

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:02:09 -04:00
co-authored by Copilot
parent fbc41a5db5
commit ba682fbf3a
23 changed files with 1357 additions and 167 deletions
+7 -11
View File
@@ -1,5 +1,4 @@
import unittest
import os
from unittest.mock import Mock, patch
from modules import app_account, app_car, app_device_sync
@@ -56,16 +55,13 @@ class DeviceSyncReloadCallbackTest(unittest.TestCase):
finally:
app_device_sync._RELOAD_CALLBACK = original
@patch.dict(
os.environ,
{
"YARIS_SERVER_URL": "https://example.test",
"YARIS_DEVICE_NAME": "yaris-2013",
"YARIS_API_KEY": "upload-key",
},
clear=True,
)
def test_sync_credentials_fall_back_to_upload_api_key(self):
@patch("modules.app_remote_config.server_credentials")
def test_sync_credentials_fall_back_to_upload_api_key(self, server_credentials):
server_credentials.return_value = {
"server_url": "https://example.test",
"device_name": "yaris-2013",
"api_key": "upload-key",
}
server_url, device_name, device_key = app_device_sync._server_credentials()
self.assertEqual(server_url, "https://example.test")
+159
View File
@@ -0,0 +1,159 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from modules import app_device_config, app_device_sync, app_remote_config
class RemoteConfigTest(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.creds_path = os.path.join(self.tmpdir.name, "upload_creds.env")
def tearDown(self):
self.tmpdir.cleanup()
def test_update_and_read_remote_config(self):
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
errors = {}
app_remote_config.update_remote_config(
{
"server_url": "https://example.test/",
"device_name": "yaris-2013",
"api_key": "secret-key",
},
errors,
)
self.assertEqual(errors, {})
config = app_remote_config.get_remote_config()
self.assertEqual(config["server_url"], "https://example.test")
self.assertEqual(config["device_name"], "yaris-2013")
self.assertTrue(config["api_key_configured"])
def test_blank_api_key_preserves_existing_until_cleared(self):
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
app_remote_config.update_remote_config(
{
"server_url": "https://example.test",
"device_name": "yaris-2013",
"api_key": "secret-key",
},
{},
)
errors = {}
app_remote_config.update_remote_config(
{
"server_url": "https://example.test",
"device_name": "yaris-2013",
"api_key": "",
"clear_api_key": False,
},
errors,
)
self.assertEqual(errors, {})
self.assertTrue(app_remote_config.get_remote_config()["api_key_configured"])
app_remote_config.update_remote_config(
{"clear_api_key": True},
{},
)
self.assertFalse(app_remote_config.get_remote_config()["api_key_configured"])
@patch("modules.app_remote_config.requests.get")
def test_test_connection_checks_health_and_device_auth(self, requests_get):
class Response:
def __init__(self, status_code):
self.status_code = status_code
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError("bad status")
requests_get.side_effect = [Response(200), Response(200)]
with patch.object(app_remote_config, "_CREDS_PATH", self.creds_path):
app_remote_config.update_remote_config(
{
"server_url": "https://example.test",
"device_name": "yaris-2013",
"api_key": "secret-key",
},
{},
)
result = app_remote_config.test_connection()
self.assertTrue(result["ok"])
self.assertTrue(result["authenticated"])
self.assertEqual(requests_get.call_count, 2)
class DeviceConfigSyncPrereqTest(unittest.TestCase):
@patch("modules.app_remote_config.get_remote_config")
def test_sync_enable_requires_remote_credentials(self, get_remote_config):
get_remote_config.return_value = {
"server_url": "",
"device_name": "",
"api_key_configured": False,
}
errors = {}
with patch("modules.app_device_config._get_sync_enabled", return_value=False):
app_device_config.update_device_config({"sync_enabled": True}, errors)
self.assertIn("sync_enabled", errors)
class DeviceSyncNowTest(unittest.TestCase):
@patch("modules.app_device_sync._has_internet", return_value=True)
@patch("modules.app_device_sync._acknowledge_sync")
@patch("modules.app_device_sync._on_config_changed")
@patch("modules.app_device_sync._store_synced_config")
@patch("modules.app_device_sync.requests.get")
@patch("modules.app_remote_config.server_credentials")
def test_sync_now_fetches_and_applies_config(
self,
server_credentials,
requests_get,
store_synced_config,
on_config_changed,
acknowledge_sync,
_has_internet,
):
class Response:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return {"owner_name": "alice", "config_version": 4}
server_credentials.return_value = {
"server_url": "https://example.test",
"device_name": "yaris-2013",
"api_key": "secret-key",
}
requests_get.return_value = Response()
original_version = app_device_sync._SYNC_CONFIG_VERSION
app_device_sync._SYNC_CONFIG_VERSION = 0
try:
result = app_device_sync.sync_now()
finally:
app_device_sync._SYNC_CONFIG_VERSION = original_version
self.assertEqual(result["status"], "synced")
store_synced_config.assert_called_once()
on_config_changed.assert_called_once()
acknowledge_sync.assert_called_once_with(
"https://example.test",
"yaris-2013",
"secret-key",
4,
)
if __name__ == "__main__":
unittest.main()