diff --git a/app/api.py b/app/api.py index 12a68e9..a5652bd 100644 --- a/app/api.py +++ b/app/api.py @@ -23,6 +23,8 @@ from app.auth_store import encrypt_secret from app.auth_store import decrypt_secret from app.db import get_db +from app.device_update import can_start_update +from app.device_update import update_available from app.resolution import connected_resolution_from_web_ui from app.resolution import resolution_from_automation_state from app.snapshot_service import write_latest_screenshot @@ -357,13 +359,15 @@ def get_device_snapshot(device_id: int): client = TinyPilotClient(row['base_url'], api_key=api_key) - # API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot. - # Skip Web UI session/CSRF-only routes (auth, requiresHttps, virtual media). + # API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot, + # latestRelease, update status/start. status, status_error = _safe_fetch(client.get_status) version, version_error = _safe_fetch(client.get_version) network, network_error = _safe_fetch(client.get_network_status) video, video_error = _safe_fetch(client.get_video_settings) automation_state, automation_state_error = _safe_fetch(client.get_automation_state) + latest_release, latest_release_error = _safe_fetch(client.get_latest_release) + update_job, update_job_error = _safe_fetch(client.get_update_status) last_error = ( status_error @@ -389,11 +393,15 @@ def get_device_snapshot(device_id: int): or connected_resolution_from_web_ui(video, status) ) + current_version = (version or {}).get('version') + latest_version = (latest_release or {}).get('version') + license_check_status = (latest_release or {}).get('licenseCheckStatus') + collapsed = { 'friendly_name': row['friendly_name'], 'device_url': row['base_url'], 'online': online, - 'software_version': (version or {}).get('version', 'unknown'), + 'software_version': current_version or 'unknown', 'last_checked': datetime.now(timezone.utc).isoformat(), 'api_key_status': 'configured', } @@ -406,6 +414,16 @@ def get_device_snapshot(device_id: int): 'automation_state': {'status': automation_state, 'error': automation_state_error}, 'connected_device_resolution': connected_resolution, 'last_management_error': last_error, + 'software_update': { + 'latest': latest_release, + 'latest_error': latest_release_error, + 'job': update_job, + 'job_error': update_job_error, + 'update_available': update_available(current_version, latest_version), + 'can_start': can_start_update( + current_version, latest_version, license_check_status + ), + }, } return jsonify( @@ -416,3 +434,31 @@ def get_device_snapshot(device_id: int): 'expanded': expanded, } ) + + +@api_blueprint.get('/devices//device/update') +def get_device_update_status(device_id: int): + client, err = _client_with_api_key(device_id) + if err is not None: + return err + try: + result = client.get_update_status() + except Exception as exc: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to fetch update status: {exc}'}), 502 + return jsonify(result) + + +@api_blueprint.put('/devices//device/update') +def start_device_update(device_id: int): + client, err = _client_with_api_key(device_id) + if err is not None: + return err + payload = request.get_json(silent=True) or {} + version = (payload.get('version') or '').strip() + if not version: + return jsonify({'error': 'version is required'}), 400 + try: + client.start_update(version) + except Exception as exc: # pylint: disable=broad-exception-caught + return jsonify({'error': f'failed to start update: {exc}'}), 502 + return jsonify({'device_id': device_id, 'update_started': True, 'version': version}) diff --git a/app/device_update.py b/app/device_update.py new file mode 100644 index 0000000..eb3beae --- /dev/null +++ b/app/device_update.py @@ -0,0 +1,24 @@ +"""Helpers for TinyPilot device software-update banners.""" + + +def license_blocks_update(license_check_status: str | None) -> bool: + """Return True when Gatekeeper/Web UI would refuse an update kickoff.""" + if not license_check_status: + return True + return license_check_status != 'VALID' + + +def update_available(current_version: str | None, latest_version: str | None) -> bool: + if not current_version or not latest_version: + return False + return current_version != latest_version + + +def can_start_update( + current_version: str | None, + latest_version: str | None, + license_check_status: str | None, +) -> bool: + return update_available(current_version, latest_version) and not license_blocks_update( + license_check_status + ) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index 64f1b6a..6eb09b0 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -711,3 +711,29 @@ button:active { padding: 0 1rem; } } + +.device-update-banner { + margin-top: var(--space-3); + padding: var(--space-3); + border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.device-update-banner__text { + margin: 0; + font-size: 0.9rem; + line-height: 1.4; +} + +.device-update-banner__actions { + display: flex; + justify-content: flex-end; + margin-top: var(--space-2); +} + +.device-update-banner__error { + display: block; + margin-top: var(--space-1); + color: var(--color-danger, #b42318); +} diff --git a/app/static/js/api.js b/app/static/js/api.js index 761c71b..7e2f3a8 100644 --- a/app/static/js/api.js +++ b/app/static/js/api.js @@ -35,6 +35,10 @@ async function getJson(path) { return fetchJson(path); } +async function putJson(path, body = {}) { + return fetchJson(path, { method: 'PUT', body }); +} + async function deleteJson(path) { return fetchJson(path, { method: 'DELETE' }); } @@ -43,4 +47,5 @@ window.dashboardApi = { deleteJson, getJson, postJson, + putJson, }; diff --git a/app/static/js/components/device-card.js b/app/static/js/components/device-card.js index 6430605..4e87d7e 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -1,7 +1,14 @@ import { escapeHtml, formatRelativeTime } from '../lib/strings.js'; import { formatExpandedSnapshot } from '../lib/snapshot-view.js'; +import { + latestVersionFrom, + licenseReason, + licenseStatusFrom, + updateBannerKind, +} from '../lib/device-update.js'; const AUTOMATION_LICENSE_INFO_URL = 'https://tinypilotkvm.com/pages/automation'; +const UPDATE_POLL_INTERVAL_MS = 3000; function formatCollapsedSnapshotSummary(c) { const checkedAt = c.last_checked || ''; @@ -30,6 +37,8 @@ class DeviceCard extends HTMLElement { super(); this._device = null; this._elements = {}; + this._updatePollTimer = null; + this._desiredUpdateVersion = null; } set device(value) { @@ -49,6 +58,10 @@ class DeviceCard extends HTMLElement { } } + disconnectedCallback() { + this._stopUpdatePoll(); + } + _cacheElements() { const id = this._device.id; this._elements = { @@ -58,6 +71,7 @@ class DeviceCard extends HTMLElement { summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`), metricsOutput: this.querySelector(`#device-metrics-output-${id}`), intervalInput: this.querySelector(`#screenshot-interval-${id}`), + updateBanner: this.querySelector(`#device-update-banner-${id}`), }; } @@ -129,6 +143,7 @@ class DeviceCard extends HTMLElement {
Retrieving device snapshot...
+
Expanded TinyPilot device info
Retrieving expanded device info...
@@ -218,14 +233,141 @@ class DeviceCard extends HTMLElement { summaryOutput.innerHTML = formatCollapsedSnapshotSummary(snapshot.collapsed); } } - if (!metricsOutput) { + if (metricsOutput) { + if (snapshot.error) { + metricsOutput.textContent = snapshot.error; + } else { + metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); + } + } + if (!snapshot.error) { + this._renderUpdateBanner(snapshot); + } + } + + _stopUpdatePoll() { + if (this._updatePollTimer) { + window.clearInterval(this._updatePollTimer); + this._updatePollTimer = null; + } + } + + _renderUpdateBanner(snapshot) { + const banner = this._elements.updateBanner; + if (!banner || !this._device) { + return; + } + const id = this._device.id; + const currentVersion = snapshot.collapsed?.software_version || ''; + const softwareUpdate = snapshot.expanded?.software_update || null; + const kind = updateBannerKind(softwareUpdate, currentVersion); + const latestVersion = latestVersionFrom(softwareUpdate); + const licenseStatus = licenseStatusFrom(softwareUpdate); + const jobError = softwareUpdate?.job?.updateError; + + if (!kind || kind === 'hidden') { + banner.hidden = true; + banner.innerHTML = ''; + this._stopUpdatePoll(); + return; + } + + banner.hidden = false; + if (kind === 'updating') { + banner.innerHTML = ` +

+ Updating TinyPilot${latestVersion ? ` to ${escapeHtml(latestVersion)}` : ''}… + ${jobError ? `${escapeHtml(jobError)}` : ''} +

+ `; + this._startUpdatePoll(); + return; + } + if (kind === 'error') { + banner.innerHTML = ` +

+ Could not check for updates: + ${escapeHtml(softwareUpdate.latest_error || 'unknown error')} +

+ `; + this._stopUpdatePoll(); + return; + } + if (kind === 'license-blocked') { + banner.innerHTML = ` +

+ Update available (${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)}), + but ${escapeHtml(licenseReason(licenseStatus))}. + Attach license in WebUI ↗ +

+ `; + this._stopUpdatePoll(); + return; + } + // available + this._desiredUpdateVersion = latestVersion; + banner.innerHTML = ` +

+ Update available: ${escapeHtml(currentVersion)} → ${escapeHtml(latestVersion)} +

+
+ +
+ `; + this._stopUpdatePoll(); + } + + _startUpdatePoll() { + if (this._updatePollTimer || !this._device) { + return; + } + const id = this._device.id; + this._updatePollTimer = window.setInterval(async () => { + const status = await window.dashboardApi.getJson(`/api/devices/${id}/device/update`); + if (status.error) { + return; + } + if (status.status === 'IN_PROGRESS') { + const banner = this._elements.updateBanner; + if (banner && !banner.hidden) { + const err = status.updateError + ? `${escapeHtml(status.updateError)}` + : ''; + banner.innerHTML = ` +

Updating TinyPilot… ${err}

+ `; + } + return; + } + this._stopUpdatePoll(); + await this.refreshSnapshot(); + }, UPDATE_POLL_INTERVAL_MS); + } + + async startUpdate() { + if (!this._device || !this._desiredUpdateVersion) { return; } - if (snapshot.error) { - metricsOutput.textContent = snapshot.error; + const id = this._device.id; + const banner = this._elements.updateBanner; + if (banner) { + banner.hidden = false; + banner.innerHTML = `

Starting update…

`; + } + const result = await window.dashboardApi.putJson(`/api/devices/${id}/device/update`, { + version: this._desiredUpdateVersion, + }); + if (result.error) { + if (banner) { + banner.innerHTML = ` +

+ ${escapeHtml(result.error)} +

+ `; + } return; } - metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); + this._startUpdatePoll(); } } diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index 19517bd..6ccb9f2 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -264,6 +264,10 @@ class DashboardApp extends HTMLElement { } if (action === 'fetch-device-snapshot') { await card.refreshSnapshot(); + return; + } + if (action === 'start-device-update') { + await card.startUpdate(); } } diff --git a/app/static/js/lib/device-update.js b/app/static/js/lib/device-update.js new file mode 100644 index 0000000..c40f840 --- /dev/null +++ b/app/static/js/lib/device-update.js @@ -0,0 +1,45 @@ +/** Pure helpers for device software-update banner copy/state. */ + +export const LICENSE_UPDATE_REASONS = { + EXPIRED: 'your license has expired', + UNLICENSED: + 'you need to attach a valid TinyPilot Pro license to this device to retrieve updates', + INVALID: 'there is an issue with your license', +}; + +export function licenseReason(licenseCheckStatus) { + return ( + LICENSE_UPDATE_REASONS[licenseCheckStatus] || LICENSE_UPDATE_REASONS.INVALID + ); +} + +/** + * @returns {'hidden'|'updating'|'available'|'license-blocked'|'error'|null} + */ +export function updateBannerKind(softwareUpdate, currentVersion) { + if (!softwareUpdate) { + return null; + } + const jobStatus = softwareUpdate.job?.status; + if (jobStatus === 'IN_PROGRESS') { + return 'updating'; + } + if (softwareUpdate.latest_error && !softwareUpdate.latest) { + return 'error'; + } + if (!softwareUpdate.update_available) { + return 'hidden'; + } + if (softwareUpdate.can_start) { + return 'available'; + } + return 'license-blocked'; +} + +export function latestVersionFrom(softwareUpdate) { + return softwareUpdate?.latest?.version || ''; +} + +export function licenseStatusFrom(softwareUpdate) { + return softwareUpdate?.latest?.licenseCheckStatus || ''; +} diff --git a/app/templates/index.html b/app/templates/index.html index a349e6e..57309b5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20260827-17' %} + {% set asset_v = '20260917-01' %} TinyPilot Dashboard diff --git a/app/tinypilot_client.py b/app/tinypilot_client.py index 9a45d8c..91543b5 100644 --- a/app/tinypilot_client.py +++ b/app/tinypilot_client.py @@ -1,7 +1,8 @@ """HTTP client for TinyPilot devices (Pro 3.2.0+ Automation API key). Uses a Bearer API key for allowlisted routes (screenshot, version, network, -video settings, ``/state``) plus a warmup GET for cookie affinity. +video settings, ``/state``, latestRelease, update) plus a warmup GET for +cookie affinity. TLS note: TinyPilot devices typically present a self-signed certificate, so this client sets ``session.verify = False`` and silences the corresponding @@ -69,6 +70,18 @@ def _get_json(self, path: str, *, bearer: bool = False) -> dict[str, Any]: self._raise_for_status(response) return response.json() + def _put_json(self, path: str, body: Optional[dict] = None) -> dict[str, Any]: + warmup = self.session.get(self.base_url, timeout=10) + warmup.raise_for_status() + response = self.session.put( + f'{self.base_url}{path}', + json=body, + headers=self._bearer_headers(), + timeout=30, + ) + self._raise_for_status(response) + return response.json() if response.content else {} + def get_network_status(self): return self._get_json('/api/network/status', bearer=True) @@ -81,6 +94,15 @@ def get_version(self) -> dict[str, Any]: def get_video_settings(self) -> dict[str, Any]: return self._get_json('/api/settings/video', bearer=True) + def get_latest_release(self) -> dict[str, Any]: + return self._get_json('/api/latestRelease', bearer=True) + + def get_update_status(self) -> dict[str, Any]: + return self._get_json('/api/update', bearer=True) + + def start_update(self, version: str) -> dict[str, Any]: + return self._put_json('/api/update', {'version': version}) + def get_screenshot(self) -> bytes: response = self.session.get( f'{self.base_url}/api/v1/screenshot', diff --git a/tests/test_api_connections.py b/tests/test_api_connections.py index 779cf9a..2ab366d 100644 --- a/tests/test_api_connections.py +++ b/tests/test_api_connections.py @@ -182,6 +182,16 @@ def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client } tp_client.get_video_settings.return_value = {'h264Bitrate': 8000, 'streamingMode': 'MJPEG'} tp_client.get_automation_state.return_value = {} + tp_client.get_latest_release.return_value = { + 'version': '2.6.5', + 'kind': 'automatic', + 'data': None, + 'licenseCheckStatus': 'VALID', + } + tp_client.get_update_status.return_value = { + 'status': 'NOT_RUNNING', + 'updateError': None, + } snapshot_response = client.get(f'/api/devices/{device_id}/device/snapshot') @@ -193,6 +203,8 @@ def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client assert 'connected_device_resolution' not in payload['collapsed'] assert payload['expanded']['network']['data']['interfaces'][0]['ipAddress'] == '192.168.1.44' assert payload['expanded']['connected_device_resolution'] == '1920x1080' + assert payload['expanded']['software_update']['update_available'] is False + assert payload['expanded']['software_update']['can_start'] is False client_cls.assert_called_once() _args, kwargs = client_cls.call_args assert kwargs.get('api_key') == 'test-api-key' @@ -216,12 +228,24 @@ def test_device_snapshot_prefers_automation_state_resolution(client): tp_client.get_automation_state.return_value = { 'result': {'source': {'resolution': {'width': 2560, 'height': 1440}}}, } + tp_client.get_latest_release.return_value = { + 'version': '3.1.0', + 'kind': 'automatic', + 'data': None, + 'licenseCheckStatus': 'VALID', + } + tp_client.get_update_status.return_value = { + 'status': 'NOT_RUNNING', + 'updateError': None, + } snapshot_response = client.get(f'/api/devices/{device_id}/device/snapshot') assert snapshot_response.status_code == 200 assert 'connected_device_resolution' not in snapshot_response.json['collapsed'] assert snapshot_response.json['expanded']['connected_device_resolution'] == '2560x1440' + assert snapshot_response.json['expanded']['software_update']['update_available'] is True + assert snapshot_response.json['expanded']['software_update']['can_start'] is True tp_client.get_automation_state.assert_called_once_with() @@ -273,3 +297,90 @@ def test_refresh_screenshot_surfaces_api_errors(client): assert response.status_code == 502 assert 'failed to refresh screenshot' in response.json['error'] + + +def test_device_snapshot_license_blocked_update_cannot_start(client): + create_payload = { + 'friendly_name': 'Voyager 2 Unlicensed', + 'base_url': 'https://192.168.1.90', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + tp_client = client_cls.return_value + tp_client.get_status.return_value = {'ok': True} + tp_client.get_version.return_value = {'version': '3.0.0'} + tp_client.get_network_status.return_value = {'interfaces': []} + tp_client.get_video_settings.return_value = {} + tp_client.get_automation_state.return_value = {} + tp_client.get_latest_release.return_value = { + 'version': '3.2.0', + 'kind': 'automatic', + 'data': None, + 'licenseCheckStatus': 'UNLICENSED', + } + tp_client.get_update_status.return_value = { + 'status': 'NOT_RUNNING', + 'updateError': None, + } + response = client.get(f'/api/devices/{device_id}/device/snapshot') + + assert response.status_code == 200 + update = response.json['expanded']['software_update'] + assert update['update_available'] is True + assert update['can_start'] is False + + +def test_start_device_update_endpoint(client): + create_payload = { + 'friendly_name': 'Update Target', + 'base_url': 'https://192.168.1.91', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + client_cls.return_value.start_update.return_value = {} + response = client.put( + f'/api/devices/{device_id}/device/update', + json={'version': '3.2.0'}, + ) + + assert response.status_code == 200 + assert response.json['update_started'] is True + client_cls.return_value.start_update.assert_called_once_with('3.2.0') + + +def test_start_device_update_requires_version(client): + create_payload = { + 'friendly_name': 'Update Target', + 'base_url': 'https://192.168.1.92', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + response = client.put(f'/api/devices/{device_id}/device/update', json={}) + assert response.status_code == 400 + + +def test_get_device_update_status_endpoint(client): + create_payload = { + 'friendly_name': 'Status Target', + 'base_url': 'https://192.168.1.93', + 'api_key': 'test-api-key', + } + create_response = client.post('/api/devices', json=create_payload) + device_id = create_response.json['device']['id'] + + with patch('app.api.TinyPilotClient') as client_cls: + client_cls.return_value.get_update_status.return_value = { + 'status': 'IN_PROGRESS', + 'updateError': None, + } + response = client.get(f'/api/devices/{device_id}/device/update') + + assert response.status_code == 200 + assert response.json['status'] == 'IN_PROGRESS' diff --git a/tests/test_device_update.py b/tests/test_device_update.py new file mode 100644 index 0000000..6ee910d --- /dev/null +++ b/tests/test_device_update.py @@ -0,0 +1,25 @@ +"""Tests for device software-update banner helpers.""" + +from app.device_update import can_start_update +from app.device_update import license_blocks_update +from app.device_update import update_available + + +def test_update_available_when_versions_differ(): + assert update_available('3.1.0', '3.2.0') is True + assert update_available('3.2.0', '3.2.0') is False + assert update_available(None, '3.2.0') is False + + +def test_license_blocks_when_not_valid(): + assert license_blocks_update('VALID') is False + assert license_blocks_update('UNLICENSED') is True + assert license_blocks_update('EXPIRED') is True + assert license_blocks_update('INVALID') is True + assert license_blocks_update(None) is True + + +def test_can_start_update_requires_newer_and_valid_license(): + assert can_start_update('3.1.0', '3.2.0', 'VALID') is True + assert can_start_update('3.1.0', '3.2.0', 'UNLICENSED') is False + assert can_start_update('3.2.0', '3.2.0', 'VALID') is False diff --git a/tests/test_tinypilot_client.py b/tests/test_tinypilot_client.py index b2988ee..9919d5b 100644 --- a/tests/test_tinypilot_client.py +++ b/tests/test_tinypilot_client.py @@ -50,3 +50,41 @@ def test_get_screenshot_uses_bearer_and_returns_bytes(client): _args, kwargs = mock_session.get.call_args assert kwargs['headers']['Authorization'] == 'Bearer test-key' assert _args[0].endswith('/api/v1/screenshot') + + +def test_get_latest_release_uses_bearer(client): + warmup = make_response() + api_response = make_response( + { + 'version': '3.2.0', + 'kind': 'automatic', + 'data': None, + 'licenseCheckStatus': 'VALID', + } + ) + mock_session = MagicMock() + mock_session.get.side_effect = [warmup, api_response] + client.session = mock_session + + result = client.get_latest_release() + + assert result['version'] == '3.2.0' + _args, kwargs = mock_session.get.call_args_list[1] + assert kwargs['headers']['Authorization'] == 'Bearer test-key' + assert _args[0].endswith('/api/latestRelease') + + +def test_start_update_puts_version_with_bearer(client): + warmup = make_response() + put_response = make_response(content=b'') + mock_session = MagicMock() + mock_session.get.return_value = warmup + mock_session.put.return_value = put_response + client.session = mock_session + + client.start_update('3.2.0') + + _args, kwargs = mock_session.put.call_args + assert _args[0].endswith('/api/update') + assert kwargs['json'] == {'version': '3.2.0'} + assert kwargs['headers']['Authorization'] == 'Bearer test-key'