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:
+13
-3
@@ -68,7 +68,9 @@ so a permissive origin can't be used to steal a logged-in session.
|
||||
5. Sign in to the remote portal and open `/settings/device` to edit the synced
|
||||
owner/car/device/avatar values for the configured `DEVICE_NAME`. If the Pi
|
||||
has local **Sync with server** enabled, those values hot-reload into the
|
||||
running Pi UI after the next successful sync.
|
||||
running Pi UI after the next successful sync. Phase 25.5c also adds a
|
||||
multi-device list, one-time API-key reveal, key rotation, and a request-sync
|
||||
action on that page.
|
||||
|
||||
### Upgrading an existing unraid deployment
|
||||
|
||||
@@ -79,6 +81,7 @@ again automatically. After pulling new code, apply the new migrations manually:
|
||||
cd /mnt/user/app/git/yaris-obd/server
|
||||
docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase24.sql
|
||||
docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase25_5a.sql
|
||||
docker compose exec db psql -U yarisobd -d yarisobd -f /docker-entrypoint-initdb.d/migrate_phase25_5c.sql
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
@@ -99,8 +102,15 @@ docker compose exec api python3 scripts/create_device.py yaris-2013
|
||||
|
||||
This prints a one-time API key — copy the `YARIS_DEVICE_NAME` /
|
||||
`YARIS_API_KEY` values into the Pi's upload-client config. Only the
|
||||
key's sha256 hash is stored server-side; the raw key is never shown again
|
||||
(re-run the script with the same device name to rotate/replace the key).
|
||||
key's sha256 hash is stored server-side; the raw key is never shown again.
|
||||
|
||||
The browser portal at `/settings/device` can now do the same workflow without
|
||||
shell access:
|
||||
|
||||
- create a new device
|
||||
- reveal a one-time API key
|
||||
- rotate the API key later
|
||||
- request a sync for the selected device
|
||||
|
||||
## Exposing it outside the LAN
|
||||
|
||||
|
||||
+82
-1
@@ -17,7 +17,7 @@ from flask_login import login_user, logout_user, current_user
|
||||
from modules import db
|
||||
from modules.auth import require_device_auth
|
||||
from modules.user_auth import login_manager, User, check_password, require_login
|
||||
from modules import device_config
|
||||
from modules import device_config, device_registry
|
||||
|
||||
# ── Geocode cache ──────────────────────────────────────────────────────────
|
||||
try:
|
||||
@@ -630,6 +630,75 @@ def portal_trip_compat(trip_id):
|
||||
|
||||
# ── Phase 25.5a: Device configuration sync ──────────────────────────────────
|
||||
|
||||
@app.get("/api/v1/portal/devices")
|
||||
@require_login
|
||||
def portal_devices():
|
||||
devices = device_registry.list_devices()
|
||||
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
|
||||
for device in devices:
|
||||
device["portal_default"] = device["name"] == configured_name
|
||||
return jsonify(
|
||||
devices=devices,
|
||||
configured_device_name=configured_name,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/portal/devices")
|
||||
@require_login
|
||||
def portal_device_create():
|
||||
body = request.get_json(silent=True) or {}
|
||||
result, errors = device_registry.create_device(body.get("name"))
|
||||
if errors:
|
||||
return jsonify(error="Invalid device.", fields=errors), 400
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@app.get("/api/v1/portal/devices/<int:device_id>/config")
|
||||
@require_login
|
||||
def portal_device_config_detail(device_id):
|
||||
config = device_config.get_device_config(device_id)
|
||||
if not config:
|
||||
return jsonify(error="device not found"), 404
|
||||
device = db.get_device_by_id(device_id)
|
||||
return jsonify(
|
||||
device=device,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/portal/devices/<int:device_id>/config")
|
||||
@require_login
|
||||
def portal_device_config_update(device_id):
|
||||
success, errors = device_config.update_device_config(
|
||||
device_id,
|
||||
request.get_json(silent=True) or {},
|
||||
)
|
||||
if not success:
|
||||
return jsonify(error="Invalid device config.", fields=errors), 400
|
||||
return jsonify(
|
||||
device=db.get_device_by_id(device_id),
|
||||
config=device_config.get_device_config(device_id),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/portal/devices/<int:device_id>/rotate-key")
|
||||
@require_login
|
||||
def portal_device_rotate_key(device_id):
|
||||
result, errors = device_registry.rotate_device_key(device_id)
|
||||
if errors:
|
||||
return jsonify(error=errors.get("error", "Could not rotate key.")), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.post("/api/v1/portal/devices/<int:device_id>/request-sync")
|
||||
@require_login
|
||||
def portal_device_request_sync(device_id):
|
||||
result = device_registry.request_sync(device_id)
|
||||
if result is None:
|
||||
return jsonify(error="device not found"), 404
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/portal/device-config")
|
||||
@require_login
|
||||
def portal_device_config_get():
|
||||
@@ -660,6 +729,18 @@ def api_device_config_get():
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@app.post("/api/v1/device-config/ack")
|
||||
@require_device_auth
|
||||
def api_device_config_ack():
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
config_version = int(body.get("config_version"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(error="config_version is required"), 400
|
||||
device_registry.acknowledge_sync(g.device_id, config_version)
|
||||
return jsonify(status="ok")
|
||||
|
||||
|
||||
@app.post("/api/device-config")
|
||||
@require_login
|
||||
def api_device_config_update():
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
- ./sql/migrate_phase24.sql:/docker-entrypoint-initdb.d/migrate_phase24.sql:ro
|
||||
- ./sql/migrate_phase25_5a.sql:/docker-entrypoint-initdb.d/migrate_phase25_5a.sql:ro
|
||||
- ./sql/migrate_phase25_5c.sql:/docker-entrypoint-initdb.d/migrate_phase25_5c.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-yarisobd}"]
|
||||
interval: 10s
|
||||
|
||||
+112
-2
@@ -48,10 +48,17 @@ def get_device_by_name(name):
|
||||
|
||||
|
||||
def get_device_by_id(device_id):
|
||||
"""Get device by ID, including config fields for Phase 25.5a."""
|
||||
"""Get device by ID, including config + sync metadata."""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path, config_version FROM devices WHERE id = %s",
|
||||
"""
|
||||
SELECT id, name, api_key_hash, owner_name, car_model, device_name, avatar_path,
|
||||
config_version, config_updated_at, created_at, last_seen_at,
|
||||
last_config_sync_at, last_config_sync_version, force_sync_requested_at,
|
||||
api_key_rotated_at
|
||||
FROM devices
|
||||
WHERE id = %s
|
||||
""",
|
||||
(device_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -66,6 +73,13 @@ def get_device_by_id(device_id):
|
||||
"device_name": row[5],
|
||||
"avatar_path": row[6],
|
||||
"config_version": row[7],
|
||||
"config_updated_at": row[8],
|
||||
"created_at": row[9],
|
||||
"last_seen_at": row[10],
|
||||
"last_config_sync_at": row[11],
|
||||
"last_config_sync_version": row[12],
|
||||
"force_sync_requested_at": row[13],
|
||||
"api_key_rotated_at": row[14],
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +91,102 @@ def touch_device_last_seen(device_id):
|
||||
)
|
||||
|
||||
|
||||
def list_devices():
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, owner_name, car_model, device_name, avatar_path, config_version,
|
||||
config_updated_at, created_at, last_seen_at, last_config_sync_at,
|
||||
last_config_sync_version, force_sync_requested_at, api_key_rotated_at
|
||||
FROM devices
|
||||
ORDER BY created_at ASC, id ASC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"owner_name": row[2],
|
||||
"car_model": row[3],
|
||||
"device_name": row[4],
|
||||
"avatar_path": row[5],
|
||||
"config_version": row[6],
|
||||
"config_updated_at": row[7],
|
||||
"created_at": row[8],
|
||||
"last_seen_at": row[9],
|
||||
"last_config_sync_at": row[10],
|
||||
"last_config_sync_version": row[11],
|
||||
"force_sync_requested_at": row[12],
|
||||
"api_key_rotated_at": row[13],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_device(name, api_key_hash):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO devices (name, api_key_hash, api_key_rotated_at)
|
||||
VALUES (%s, %s, now())
|
||||
RETURNING id
|
||||
""",
|
||||
(name, api_key_hash),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
def rotate_device_api_key(device_id, api_key_hash):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET api_key_hash = %s, api_key_rotated_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(api_key_hash, device_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def request_device_sync(device_id):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET config_version = config_version + 1,
|
||||
config_updated_at = now(),
|
||||
force_sync_requested_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING config_version
|
||||
""",
|
||||
(device_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def acknowledge_device_sync(device_id, config_version):
|
||||
with get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET last_config_sync_at = now(),
|
||||
last_config_sync_version = %s,
|
||||
force_sync_requested_at = CASE
|
||||
WHEN config_version <= %s THEN NULL
|
||||
ELSE force_sync_requested_at
|
||||
END
|
||||
WHERE id = %s
|
||||
""",
|
||||
(config_version, config_version, device_id),
|
||||
)
|
||||
|
||||
|
||||
def insert_tracking_points(device_id, points):
|
||||
"""Bulk-insert tracking points, skipping any that already exist
|
||||
(device_id, recorded_at) so a client can safely re-send a batch after a
|
||||
|
||||
@@ -30,6 +30,8 @@ def update_device_config(device_id, data):
|
||||
Validates input and updates config_version on change.
|
||||
"""
|
||||
errors = {}
|
||||
if db.get_device_by_id(device_id) is None:
|
||||
return False, {"error": "Device not found."}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return False, {"error": "Invalid request body"}
|
||||
@@ -87,4 +89,3 @@ def update_device_config(device_id, data):
|
||||
)
|
||||
|
||||
return True, {}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Multi-device provisioning helpers for the remote portal."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
|
||||
from . import db
|
||||
|
||||
|
||||
_DEVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
|
||||
|
||||
|
||||
def list_devices():
|
||||
return db.list_devices()
|
||||
|
||||
|
||||
def validate_device_name(name):
|
||||
value = (name or "").strip()
|
||||
if not value:
|
||||
return None, "Device name is required."
|
||||
if not _DEVICE_NAME_RE.fullmatch(value):
|
||||
return None, "Device name must be 1-100 chars using letters, numbers, dot, underscore, or dash."
|
||||
return value, None
|
||||
|
||||
|
||||
def create_device(name):
|
||||
value, error = validate_device_name(name)
|
||||
if error:
|
||||
return None, {"name": error}
|
||||
if db.get_device_by_name(value) is not None:
|
||||
return None, {"name": "A device with that name already exists."}
|
||||
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
device_id = db.create_device(value, _hash_key(raw_key))
|
||||
return {
|
||||
"device": db.get_device_by_id(device_id),
|
||||
"api_key": raw_key,
|
||||
}, {}
|
||||
|
||||
|
||||
def rotate_device_key(device_id):
|
||||
device = db.get_device_by_id(device_id)
|
||||
if device is None:
|
||||
return None, {"error": "Device not found."}
|
||||
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
db.rotate_device_api_key(device_id, _hash_key(raw_key))
|
||||
return {
|
||||
"device": db.get_device_by_id(device_id),
|
||||
"api_key": raw_key,
|
||||
}, {}
|
||||
|
||||
|
||||
def request_sync(device_id):
|
||||
config_version = db.request_device_sync(device_id)
|
||||
if config_version is None:
|
||||
return None
|
||||
device = db.get_device_by_id(device_id)
|
||||
return {
|
||||
"device": device,
|
||||
"message": "Sync requested. The Pi will pick it up on its next online contact, or immediately if Sync now is used locally.",
|
||||
"config_version": config_version,
|
||||
}
|
||||
|
||||
|
||||
def acknowledge_sync(device_id, config_version):
|
||||
db.acknowledge_device_sync(device_id, config_version)
|
||||
|
||||
|
||||
def _hash_key(raw_key):
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
@@ -9,13 +9,11 @@ vars already configured by docker-compose, e.g.:
|
||||
The raw API key is only ever shown once here - copy it into the Pi's
|
||||
upload-client config immediately. Only its sha256 hash is stored server-side.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
from modules import db # noqa: E402
|
||||
from modules import device_registry # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
@@ -23,26 +21,16 @@ def main():
|
||||
print(f"usage: {sys.argv[0]} <device-name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
device_name = sys.argv[1]
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
result, errors = device_registry.create_device(sys.argv[1])
|
||||
if errors:
|
||||
print(f"error: {errors}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with db.get_cursor(commit=True) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO devices (name, api_key_hash) VALUES (%s, %s)
|
||||
ON CONFLICT (name) DO UPDATE SET api_key_hash = EXCLUDED.api_key_hash
|
||||
RETURNING id
|
||||
""",
|
||||
(device_name, key_hash),
|
||||
)
|
||||
device_id = cur.fetchone()[0]
|
||||
|
||||
print(f"Device '{device_name}' registered (id={device_id}).")
|
||||
print(f"Device '{result['device']['name']}' registered (id={result['device']['id']}).")
|
||||
print()
|
||||
print("Save these on the Pi (e.g. in db/upload_creds.env, gitignored):")
|
||||
print(f" YARIS_DEVICE_NAME={device_name}")
|
||||
print(f" YARIS_API_KEY={raw_key}")
|
||||
print(f" YARIS_DEVICE_NAME={result['device']['name']}")
|
||||
print(f" YARIS_API_KEY={result['api_key']}")
|
||||
print()
|
||||
print("This key will not be shown again - only its hash is stored server-side.")
|
||||
|
||||
|
||||
+11
-1
@@ -8,7 +8,17 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
name TEXT NOT NULL UNIQUE, -- e.g. "yaris-2013"
|
||||
api_key_hash TEXT NOT NULL, -- sha256 hex digest of the device's API key, never the raw key
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
owner_name TEXT DEFAULT '',
|
||||
car_model TEXT DEFAULT '',
|
||||
device_name TEXT DEFAULT '',
|
||||
avatar_path TEXT DEFAULT 'img/yaris.png',
|
||||
config_version INTEGER NOT NULL DEFAULT 0,
|
||||
config_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_config_sync_at TIMESTAMPTZ,
|
||||
last_config_sync_version INTEGER,
|
||||
force_sync_requested_at TIMESTAMPTZ,
|
||||
api_key_rotated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracking_points (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Phase 25.5c migration: multi-device sync/provisioning metadata.
|
||||
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_config_sync_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS last_config_sync_version INTEGER;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS force_sync_requested_at TIMESTAMPTZ;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS api_key_rotated_at TIMESTAMPTZ;
|
||||
|
||||
UPDATE devices
|
||||
SET api_key_rotated_at = COALESCE(api_key_rotated_at, created_at)
|
||||
WHERE api_key_rotated_at IS NULL;
|
||||
@@ -7,7 +7,7 @@
|
||||
<div>
|
||||
<h3 class="text-dark mb-1 fw-bold">Device settings</h3>
|
||||
<p class="text-muted mb-0">
|
||||
Edit the identity synced to the Pi for <code>{{ configured_device_name }}</code>.
|
||||
Manage registered Pi devices, one-time API keys, and synced identity values.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="device-settings-save" type="submit" form="device-settings-form" disabled>
|
||||
@@ -18,64 +18,133 @@
|
||||
<div id="device-settings-message" class="alert" role="status" hidden></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header py-2 d-flex align-items-center justify-content-between">
|
||||
<h6 class="text-primary fw-bold m-0">Registered devices</h6>
|
||||
<span class="badge bg-secondary" id="device-count">0</span>
|
||||
</div>
|
||||
<div class="list-group list-group-flush" id="device-list">
|
||||
<div class="list-group-item text-muted small">Loading devices…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="text-primary fw-bold m-0">Synced identity</h6>
|
||||
<h6 class="text-primary fw-bold m-0">Register new device</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="new-device-name">Device name</label>
|
||||
<input class="form-control" id="new-device-name" maxlength="100" placeholder="yaris-2013">
|
||||
<div class="form-text">This becomes <code>YARIS_DEVICE_NAME</code> on the Pi.</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-primary" id="create-device-button" type="button">
|
||||
<i class="fas fa-plus me-1"></i>Create device + API key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<h6 class="text-primary fw-bold m-0">Selected device</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="rotate-key-button" type="button" disabled>
|
||||
<i class="fas fa-key me-1"></i>Rotate API key
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" id="request-sync-button" type="button" disabled>
|
||||
<i class="fas fa-rotate me-1"></i>Request sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3 small">
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted">Device name</div>
|
||||
<div class="fw-semibold" id="selected-device-name">—</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted">Portal default</div>
|
||||
<div class="fw-semibold" id="selected-device-default">—</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted">Last seen</div>
|
||||
<div class="fw-semibold" id="selected-device-last-seen">—</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted">Last config sync</div>
|
||||
<div class="fw-semibold" id="selected-device-last-sync">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="device-settings-form" novalidate>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="owner_name">Owner name</label>
|
||||
<input class="form-control" id="owner_name" name="owner_name" maxlength="100" required>
|
||||
<input class="form-control" id="owner_name" name="owner_name" maxlength="100" required disabled>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="car_model">Car model</label>
|
||||
<input class="form-control" id="car_model" name="car_model" maxlength="100" required>
|
||||
<input class="form-control" id="car_model" name="car_model" maxlength="100" required disabled>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="device_name">Device name</label>
|
||||
<input class="form-control" id="device_name" name="device_name" maxlength="100">
|
||||
<div class="form-text">Used by the remote portal and future multi-device views.</div>
|
||||
<label class="form-label" for="device_name">Display device name</label>
|
||||
<input class="form-control" id="device_name" name="device_name" maxlength="100" disabled>
|
||||
<div class="form-text">Friendly label shown in the portal/device UI.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label" for="avatar_path">Avatar path</label>
|
||||
<input class="form-control" id="avatar_path" name="avatar_path" maxlength="255" placeholder="img/yaris.png">
|
||||
<input class="form-control" id="avatar_path" name="avatar_path" maxlength="255" placeholder="img/yaris.png" disabled>
|
||||
<div class="form-text">Relative to the shared <code>static/</code> folder.</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="text-primary fw-bold m-0">Sync behavior</h6>
|
||||
<h6 class="text-primary fw-bold m-0">Pi credential handoff</h6>
|
||||
</div>
|
||||
<div class="card-body small text-muted">
|
||||
<p class="mb-2">
|
||||
The Pi picks these values up on its next successful internet-backed sync.
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">
|
||||
Create or rotate a key, then paste these into the Pi Settings page under Remote server credentials.
|
||||
</p>
|
||||
<ul class="mb-0 ps-3">
|
||||
<li>Expected propagation delay: up to ~5 minutes while online.</li>
|
||||
<li>The Pi must have <strong>Sync with server</strong> enabled locally.</li>
|
||||
<li>Offline cars keep the last synced config until connectivity returns.</li>
|
||||
</ul>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="credential-server-url">Server URL</label>
|
||||
<input class="form-control" id="credential-server-url" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="credential-device-name">Device name</label>
|
||||
<input class="form-control" id="credential-device-name" readonly>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="credential-api-key">API key</label>
|
||||
<input class="form-control" id="credential-api-key" readonly placeholder="Shown only after create / rotate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="copy-credentials-button" type="button" disabled>
|
||||
<i class="fas fa-copy me-1"></i>Copy credentials
|
||||
</button>
|
||||
<span class="small text-muted align-self-center">Raw key is only shown immediately after create/rotate.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="text-primary fw-bold m-0">Preview</h6>
|
||||
<h6 class="text-primary fw-bold m-0">Sync behavior</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img id="avatar-preview" alt="Avatar preview" class="rounded-circle border mb-3" src="{{ url_for('static', filename='img/yaris.png') }}" style="width:96px;height:96px;object-fit:cover;">
|
||||
<div class="small text-muted mb-1">Config version</div>
|
||||
<div class="fw-bold fs-5" id="config-version">—</div>
|
||||
<div class="small text-muted mt-3" id="preview-caption">Preview updates as you type.</div>
|
||||
<div class="card-body small text-muted">
|
||||
<ul class="mb-0 ps-3">
|
||||
<li><strong>Request sync</strong> means “fetch this config on the Pi's next online contact”.</li>
|
||||
<li>The car can still be offline; the remote server cannot push directly into it.</li>
|
||||
<li>Use the Pi's local <strong>Sync now</strong> button for immediate pull attempts.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user