From c0c4d3ec42727a37e47807f9526ea0f56ee5c84a Mon Sep 17 00:00:00 2001 From: Scott Halver <76669202+scott-tp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:23:28 -0400 Subject: [PATCH] Require Automation API keys for Pro 3.2.0 (v0.2.0) Switch the dashboard to persistent Bearer API keys and drop Web UI CSRF/virtual-media paths that the Automation key cannot call. Remove internal planning docs from the public tree. --- README.md | 20 +- app/__init__.py | 8 +- app/api.py | 348 ++---- app/static/css/dashboard.css | 249 +--- app/static/js/api.js | 5 - app/static/js/components/device-card.js | 143 +-- app/static/js/dashboard-app.js | 110 +- app/static/js/lib/snapshot-view.js | 88 +- app/templates/index.html | 2 +- app/tinypilot_client.py | 164 +-- .../2026-05-19-digitalocean-deployment.md | 481 -------- ...6-15-v012-virtual-media-and-flicker-fix.md | 1053 ----------------- ...26-05-19-digitalocean-deployment-design.md | 139 --- .../specs/2026-06-15-v012-design.md | 133 --- tests/test_api_connections.py | 286 +---- tests/test_auth_refresh.py | 20 - tests/test_csrf_refresh.py | 41 - tests/test_tinypilot_client.py | 103 +- 18 files changed, 326 insertions(+), 3067 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-19-digitalocean-deployment.md delete mode 100644 docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md delete mode 100644 docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-v012-design.md delete mode 100644 tests/test_auth_refresh.py delete mode 100644 tests/test_csrf_refresh.py diff --git a/README.md b/README.md index 3575f34..c8f4e7e 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,15 @@ A self-hosted dashboard for monitoring and controlling multiple TinyPilot device ## What you get - One web UI that lists your TinyPilot devices, up to four per page, with a live screenshot of the connected system on each card. -- One-click device addition (friendly name + TinyPilot URL). +- One-click device addition (friendly name + TinyPilot URL + Automation API key). - Per-device snapshot of TinyPilot status: connected display resolution, streaming mode (H.264 with bitrate, or MJPEG with framerate and quality), firmware version, network info, and more. -- Encrypted at-rest storage for device credentials and tokens. +- Encrypted at-rest storage for Automation API keys. ## Requirements - A host that can already reach your TinyPilot devices over LAN, VPN, or a private overlay. The dashboard does **not** create that connectivity for you. - [Docker](https://docs.docker.com/get-docker/) and Docker Compose. -- A [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation) on each device — required for the live and on-demand screenshots the dashboard displays. +- TinyPilot Pro **3.2.0 or newer** with an Automation API key (System → Automation). A [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation) is required for screenshots. - A modern browser (Chrome, Firefox, Safari, or Edge). ## Deploy on DigitalOcean @@ -105,11 +105,9 @@ on your trusted network, see [Deployment & security](#deployment--security). ## Adding a device 1. Click **Add a device**. -2. Enter a friendly name (e.g. "Rack A KVM") and the TinyPilot URL (e.g. `https://tinypilot-rack-a.local` or `https://192.168.1.50`). +2. Enter a friendly name (e.g. "Rack A KVM"), the TinyPilot URL (e.g. `https://tinypilot-rack-a.local` or `https://192.168.1.50`), and an Automation API key from the device (System → Automation). 3. Save. The dashboard will pull a live screenshot and TinyPilot status. -**Alpha limitation:** this release supports TinyPilot devices whose Web UI is **not** protected by a username/password. Username/password support is planned for a future release. - ## Deployment & security The dashboard is designed to live **inside a network perimeter you already trust**. It does not authenticate inbound users — there are no login sessions or cookies — so standard CSRF attacks (which require a session to hijack) do not apply to this release. State-changing API requests require `Content-Type: application/json`, which triggers a browser CORS preflight that blocks cross-origin requests. If HTTP authentication is added in a future release, CSRF token protection will be required alongside it. @@ -126,7 +124,7 @@ If none of those apply to your environment, please **do not run this alpha**. - Anyone who can reach `http://:8080` can add, delete, and snapshot devices. Treat reachability to the dashboard as equivalent to administrative access. - TinyPilot devices ship with self-signed TLS certificates. The dashboard intentionally skips certificate verification when talking to them so it works out of the box; the perimeter requirement above bounds the resulting risk. -- Device credentials and tokens are encrypted on disk. Host filesystem permissions and full-disk encryption are your last line of defense — protect the host accordingly. +- Device Automation API keys are encrypted on disk. Host filesystem permissions and full-disk encryption are your last line of defense — protect the host accordingly. ## Data and backup @@ -160,7 +158,7 @@ Source layout and conventions for anyone reading the code or auditing what the d **Frontend** (`app/static/`, `app/templates/`) - `templates/index.html` — Jinja shell that loads the `` web component and static assets. -- `static/js/dashboard-app.js` — root custom element with shadow DOM: top bar, theme toggle, Add-a-device modal, paging (four devices per page), and the device grid (`device-grid--n1` … `device-grid--n4`, one column per visible device). +- `static/js/dashboard-app.js` — root custom element with shadow DOM: top bar, theme toggle, Add-a-device modal, paging (four devices per page), and a responsive device grid (1 → 2 → 4 columns). - `static/js/components/device-card.js` — one custom element per TinyPilot target. Owns the screenshot controls and the collapsed/expanded TinyPilot snapshot panel. - `static/js/lib/` — shared helpers (`strings.js`, `snapshot-view.js`). - `static/js/api.js` — thin `fetch` wrappers attached to `window.dashboardApi`. @@ -170,13 +168,13 @@ Source layout and conventions for anyone reading the code or auditing what the d - `__init__.py` — Flask app factory. - `api.py` — `/api/*` blueprint and route handlers. - `db.py` — SQLite connection helper and schema bootstrap. -- `tinypilot_client.py` — HTTP client wrapping TinyPilot's Automation and Web UI JSON endpoints. +- `tinypilot_client.py` — HTTP client for TinyPilot Automation API-key routes. - `resolution.py` — normalizers for the various shapes TinyPilot uses to report "connected device resolution". - `snapshot_service.py` — atomic latest-screenshot writer. - `auth_store.py`, `crypto.py` — Fernet-encrypted at-rest secrets. ### TinyPilot APIs the dashboard uses -- **[TinyPilot REST API](https://tinypilotkvm.com/pages/tinypilot-rest-api)** (`/api/v1/*`, Bearer auth) — used to refresh automation tokens and fetch screenshots. Requires a [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation) on the device. -- **Web UI JSON** (`/api/*`) — used to read device snapshot fields (version, network, video settings, etc.) via the same session the in-browser TinyPilot app uses. +- **[TinyPilot REST API](https://tinypilotkvm.com/pages/tinypilot-rest-api)** (`/api/v1/*`, Bearer API key) — screenshots and related Automation routes. Requires Pro **3.2.0+** and a [TinyPilot Automation License](https://tinypilotkvm.com/pages/automation). +- **Allowlisted device JSON** (`/api/version`, `/api/network/status`, `/api/settings/video`, `/state`) — snapshot fields, same Bearer key. diff --git a/app/__init__.py b/app/__init__.py index 2f56620..f888bb2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -10,11 +10,11 @@ from app.db import close_db, init_db # Public product version. Version bands signal lifecycle stage to customers: -# 0.1.x -> alpha -# 0.5.x -> beta -# 1.0.0 -> first stable release +# 0.1.x / 0.2.x -> alpha +# 0.5.x -> beta +# 1.0.0 -> first stable release # Bump this on every customer-visible release. -__version__ = '0.1.1' +__version__ = '0.2.0' def create_app(test_config=None): diff --git a/app/api.py b/app/api.py index 5bd3764..12a68e9 100644 --- a/app/api.py +++ b/app/api.py @@ -36,79 +36,48 @@ def get_version(): """Report the running dashboard version (useful for bug reports).""" return jsonify({'version': current_app.config['DASHBOARD_VERSION']}) -_SQL_DEVICE_INNER_AUTH = """ +_SQL_DEVICE_AUTH = """ SELECT devices.base_url, - device_auth.encrypted_web_username, - device_auth.encrypted_web_password + device_auth.encrypted_automation_token FROM devices JOIN device_auth ON device_auth.device_id = devices.id WHERE devices.id = ? """ -def _device_row_with_web_auth(device_id: int): - """Return device URL and encrypted Web UI auth fields, or None if the row is missing.""" - return get_db().execute(_SQL_DEVICE_INNER_AUTH, (device_id,)).fetchone() +def _device_row(device_id: int): + """Return device URL and encrypted API key, or None if the row is missing.""" + return get_db().execute(_SQL_DEVICE_AUTH, (device_id,)).fetchone() -def _persist_automation_token(db, key_path: Path, device_id: int, token: str) -> None: - db.execute( - """ - UPDATE device_auth - SET encrypted_automation_token = ?, automation_token_refreshed_at = ? - WHERE device_id = ? - """, - ( - encrypt_secret(key_path, token), - datetime.now(timezone.utc).isoformat(), - device_id, - ), - ) +def _api_key_from_auth_row(key_path: Path, auth_row) -> Optional[str]: + """Decrypt the stored Automation API key, or None if missing.""" + if auth_row is None or auth_row['encrypted_automation_token'] is None: + return None + return decrypt_secret(key_path, auth_row['encrypted_automation_token']) -def _http_basic_from_auth_row(key_path: Path, auth_row) -> Optional[tuple[str, str]]: - """Decode stored Web UI HTTP Basic credentials, or None if none are configured.""" - if auth_row is None: - return None - if ( - auth_row['encrypted_web_username'] is None - and auth_row['encrypted_web_password'] is None - ): - return None - username = '' - password = '' - if auth_row['encrypted_web_username'] is not None: - username = decrypt_secret(key_path, auth_row['encrypted_web_username']) - if auth_row['encrypted_web_password'] is not None: - password = decrypt_secret(key_path, auth_row['encrypted_web_password']) - return (username, password) - - -def _attempt_automation_token_refresh(db, device_id: int, base_url: str): - """ - Fetch and store an Automation API token. TinyPilot expects the Automation - license to be activated on the device; POST /api/v1/auth typically needs no - body. We always try once on create so devices with Automation already enabled - get a token even when the dashboard does not store a license key. - """ +def _client_with_api_key(device_id: int): + """Return (TinyPilotClient, None) or (None, (jsonify_response, status)).""" + row = _device_row(device_id) + if row is None: + return None, (jsonify({'error': 'device not found'}), 404) key_path = Path(current_app.config['SECRET_KEY_PATH']) - auth_row = db.execute( - """ - SELECT encrypted_web_username, encrypted_web_password - FROM device_auth - WHERE device_id = ? - """, - (device_id,), - ).fetchone() - http_basic = _http_basic_from_auth_row(key_path, auth_row) - client = TinyPilotClient(base_url, http_basic=http_basic) - try: - automation_token = client.refresh_automation_token() - except Exception as err: # pylint: disable=broad-exception-caught - return False, str(err) - _persist_automation_token(db, key_path, device_id, automation_token) - return True, None + api_key = _api_key_from_auth_row(key_path, row) + if not api_key: + return None, ( + jsonify( + { + 'error': ( + 'api key not configured; add the device again with an ' + 'API key from System → Automation' + ) + } + ), + 400, + ) + return TinyPilotClient(row['base_url'], api_key=api_key), None @api_blueprint.get('/devices') @@ -124,7 +93,7 @@ def list_devices(): CASE WHEN device_auth.encrypted_automation_token IS NOT NULL THEN 1 ELSE 0 - END AS automation_token_configured + END AS api_key_configured FROM devices LEFT JOIN device_runtime_state ON device_runtime_state.device_id = devices.id LEFT JOIN device_auth ON device_auth.device_id = devices.id @@ -138,7 +107,7 @@ def list_devices(): 'base_url': row['base_url'], 'latest_screenshot_captured_at': row['latest_screenshot_captured_at'], 'screenshot_refresh_interval_minutes': row['screenshot_refresh_interval_minutes'] or 0, - 'automation_token_configured': bool(row['automation_token_configured']), + 'api_key_configured': bool(row['api_key_configured']), } for row in rows ] @@ -150,9 +119,12 @@ def create_device(): payload = request.get_json(silent=True) or {} friendly_name = (payload.get('friendly_name') or '').strip() base_url = (payload.get('base_url') or '').strip() + api_key = (payload.get('api_key') or '').strip() - if not friendly_name or not base_url: - return jsonify({'error': 'friendly_name and base_url are required'}), 400 + if not friendly_name or not base_url or not api_key: + return jsonify( + {'error': 'friendly_name, base_url, and api_key are required'} + ), 400 key_path = Path(current_app.config['SECRET_KEY_PATH']) db = get_db() @@ -165,35 +137,20 @@ def create_device(): ) device_id = cursor.lastrowid - encrypted_automation_license_key = None - if payload.get('automation_license_key'): - encrypted_automation_license_key = encrypt_secret( - key_path, - payload['automation_license_key'], - ) - - encrypted_web_username = None - if payload.get('web_username'): - encrypted_web_username = encrypt_secret(key_path, payload['web_username']) - - encrypted_web_password = None - if payload.get('web_password'): - encrypted_web_password = encrypt_secret(key_path, payload['web_password']) - + # Store the persistent Automation API key in encrypted_automation_token + # (same Bearer secret shape as the former ephemeral token). db.execute( """ INSERT INTO device_auth ( device_id, - encrypted_automation_license_key, - encrypted_web_username, - encrypted_web_password - ) VALUES (?, ?, ?, ?) + encrypted_automation_token, + automation_token_refreshed_at + ) VALUES (?, ?, ?) """, ( device_id, - encrypted_automation_license_key, - encrypted_web_username, - encrypted_web_password, + encrypt_secret(key_path, api_key), + datetime.now(timezone.utc).isoformat(), ), ) db.execute( @@ -203,12 +160,6 @@ def create_device(): """, (device_id,), ) - - automation_token_refreshed, automation_error = _attempt_automation_token_refresh( - db=db, - device_id=device_id, - base_url=base_url, - ) db.commit() return jsonify( @@ -217,8 +168,7 @@ def create_device(): 'id': device_id, 'friendly_name': friendly_name, 'base_url': base_url, - 'automation_token_refreshed': automation_token_refreshed, - 'automation_error': automation_error, + 'api_key_configured': True, } } ), 201 @@ -263,60 +213,18 @@ def delete_device(device_id: int): return jsonify({'deleted': True, 'device_id': device_id}) -@api_blueprint.post('/devices//automation/refresh-token') -def refresh_automation_token(device_id: int): - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - automation_token = client.refresh_automation_token() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to refresh automation token: {err}'}), 502 - - db = get_db() - _persist_automation_token(db, key_path, device_id, automation_token) - db.commit() - return jsonify({'device_id': device_id, 'automation_token_refreshed': True}) - - @api_blueprint.post('/devices//refresh-screenshot') def refresh_screenshot(device_id: int): - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - db = get_db() - - try: - fresh_token = client.refresh_automation_token() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to refresh automation token: {err}'}), 502 - - _persist_automation_token(db, key_path, device_id, fresh_token) - db.commit() + client, err = _client_with_api_key(device_id) + if err is not None: + return err - screenshot = None try: - screenshot = client.get_screenshot(fresh_token) + screenshot = client.get_screenshot() except Exception as err: # pylint: disable=broad-exception-caught - if '401' not in str(err): - return jsonify({'error': f'failed to refresh screenshot: {err}'}), 502 - try: - fresh_token = client.refresh_automation_token() - _persist_automation_token(db, key_path, device_id, fresh_token) - db.commit() - screenshot = client.get_screenshot(fresh_token) - except Exception as retry_err: # pylint: disable=broad-exception-caught - return jsonify( - {'error': f'failed to refresh screenshot after token retry: {retry_err}'} - ), 502 + return jsonify({'error': f'failed to refresh screenshot: {err}'}), 502 + db = get_db() data_root = Path(current_app.config['DATABASE_PATH']).resolve().parent screenshot_path = write_latest_screenshot(data_root / 'screenshots', device_id, screenshot) captured_at = datetime.now(timezone.utc).isoformat() @@ -410,35 +318,6 @@ def get_latest_screenshot(device_id: int): return send_file(screenshot_path, mimetype='image/jpeg') -@api_blueprint.post('/devices//device/refresh-csrf') -def refresh_csrf_token(device_id: int): - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - csrf_token = client.refresh_csrf_token() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to refresh csrf token: {err}'}), 502 - - encrypted_csrf_token = encrypt_secret(key_path, csrf_token) - db = get_db() - db.execute( - """ - UPDATE device_auth - SET encrypted_csrf_token = ? - WHERE device_id = ? - """, - (encrypted_csrf_token, device_id), - ) - db.commit() - return jsonify({'device_id': device_id, 'csrf_refreshed': True}) - - - def _safe_fetch(fetcher): try: return fetcher(), None @@ -454,9 +333,7 @@ def get_device_snapshot(device_id: int): devices.id, devices.friendly_name, devices.base_url, - device_auth.encrypted_automation_token, - device_auth.encrypted_web_username, - device_auth.encrypted_web_password + device_auth.encrypted_automation_token FROM devices LEFT JOIN device_auth ON device_auth.device_id = devices.id WHERE devices.id = ? @@ -467,41 +344,31 @@ def get_device_snapshot(device_id: int): return jsonify({'error': 'device not found'}), 404 key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) + api_key = _api_key_from_auth_row(key_path, row) + if not api_key: + return jsonify( + { + 'error': ( + 'api key not configured; add the device again with an ' + 'API key from System → Automation' + ) + } + ), 400 - automation_token_plain = None - automation_token_error = None - db = get_db() - try: - automation_token_plain = client.refresh_automation_token() - _persist_automation_token(db, key_path, row['id'], automation_token_plain) - db.commit() - except Exception as err: # pylint: disable=broad-exception-caught - automation_token_error = str(err) + 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). status, status_error = _safe_fetch(client.get_status) - auth_status, auth_error = _safe_fetch(client.get_auth_status) version, version_error = _safe_fetch(client.get_version) network, network_error = _safe_fetch(client.get_network_status) - requires_https, https_error = _safe_fetch(client.get_requires_https) video, video_error = _safe_fetch(client.get_video_settings) - - # Unofficial `GET /state` uses the same Automation bearer; refresh token above - # so resolution tracks the latest session. - automation_state = None - automation_state_error = automation_token_error - if automation_token_plain: - automation_state, automation_state_error = _safe_fetch( - lambda token=automation_token_plain: client.get_automation_state(token) - ) + automation_state, automation_state_error = _safe_fetch(client.get_automation_state) last_error = ( status_error - or auth_error or version_error or network_error - or https_error or video_error or automation_state_error ) @@ -510,11 +377,10 @@ def get_device_snapshot(device_id: int): error is None for error in ( status_error, - auth_error, version_error, network_error, - https_error, video_error, + automation_state_error, ) ) @@ -528,20 +394,16 @@ def get_device_snapshot(device_id: int): 'device_url': row['base_url'], 'online': online, 'software_version': (version or {}).get('version', 'unknown'), - 'web_session_status': 'connected' if auth_status and not auth_error else 'unknown', - 'management_role': (auth_status or {}).get('role') or 'unknown', 'last_checked': datetime.now(timezone.utc).isoformat(), - 'csrf_status': 'unknown', - 'automation_api_status': 'configured' if automation_token_plain else 'not_configured', + 'api_key_status': 'configured', } expanded = { 'reachability': {'status': status, 'error': status_error}, - 'web_session': {'status': auth_status, 'error': auth_error}, 'version': {'status': version, 'error': version_error}, 'network': {'data': network or {}, 'error': network_error}, - 'https_requirement': {'status': requires_https, 'error': https_error}, 'video_settings': {'status': video, 'error': video_error}, + 'automation_state': {'status': automation_state, 'error': automation_state_error}, 'connected_device_resolution': connected_resolution, 'last_management_error': last_error, } @@ -554,77 +416,3 @@ def get_device_snapshot(device_id: int): 'expanded': expanded, } ) - - -@api_blueprint.get('/devices//media') -def get_device_media(device_id: int): - """Return current virtual media state (backing files + mount mode) for a device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - result = client.get_mass_storage() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch media state: {err}'}), 502 - return jsonify(result) - - -@api_blueprint.post('/devices//media/fetch') -def fetch_device_media_from_url(device_id: int): - """Tell the device to download an image from a URL.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - payload = request.get_json(silent=True) or {} - url = (payload.get('url') or '').strip() - if not url: - return jsonify({'error': 'url is required'}), 400 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - file_name = client.get_mass_storage_filename_from_url(url) - client.fetch_mass_storage_from_url(file_name, url) - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch image from URL: {err}'}), 502 - return jsonify({'device_id': device_id, 'fileName': file_name}) - - -@api_blueprint.put('/devices//media/mount') -def mount_device_media(device_id: int): - """Mount a backing file on the device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - payload = request.get_json(silent=True) or {} - file_name = (payload.get('fileName') or '').strip() - mode = (payload.get('mode') or '').strip() - if not file_name or not mode: - return jsonify({'error': 'fileName and mode are required'}), 400 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - client.mount_mass_storage(file_name, mode) - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to mount image: {err}'}), 502 - return jsonify({'device_id': device_id, 'mounted': True, 'fileName': file_name, 'mode': mode}) - - -@api_blueprint.put('/devices//media/eject') -def eject_device_media(device_id: int): - """Eject the currently mounted image on the device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - client.eject_mass_storage() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to eject media: {err}'}), 502 - return jsonify({'device_id': device_id, 'ejected': True}) diff --git a/app/static/css/dashboard.css b/app/static/css/dashboard.css index 8a32e45..e0555a3 100644 --- a/app/static/css/dashboard.css +++ b/app/static/css/dashboard.css @@ -65,8 +65,8 @@ } .dashboard { - /* Full width on large displays so a row of four targets fits comfortably */ - max-width: min(1920px, 100%); + /* Cap above QHD so a four-across row can use the width; ≤1920 unchanged (still 100%). */ + max-width: min(2560px, 100%); margin: 0 auto; padding: calc(var(--space-6) + 45px) var(--space-4) var(--space-4); box-sizing: border-box; @@ -148,21 +148,8 @@ summary { text-decoration: none; } -/* Connected system: expanded by default; summary shows title, license link below, +/− on the right */ -.connected-system-summary { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; - gap: var(--space-2) var(--space-3); - list-style: none; - cursor: pointer; -} - -.connected-system-summary::-webkit-details-marker { - display: none; -} - -.connected-system-summary__lead { +/* Target system: always expanded (core of the card); no collapse control. */ +.connected-system-header { display: flex; flex-direction: column; align-items: flex-start; @@ -178,38 +165,10 @@ summary { color: var(--color-text); } -.connected-system-details .section-note-automation { +.connected-system .section-note-automation { margin: 0; } -.connected-system-summary__toggle { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: 2rem; - min-height: 2.25rem; - flex-shrink: 0; - font-size: 1.35rem; - font-weight: 600; - line-height: 1; - color: var(--color-accent); - user-select: none; -} - -.connected-system-details[open] .connected-system-summary__toggle::after { - content: '\2212'; -} - -.connected-system-details:not([open]) .connected-system-summary__toggle::after { - content: '+'; -} - -.connected-system-summary:hover .connected-system-summary__toggle, -.connected-system-summary:focus-visible .connected-system-summary__toggle { - color: var(--color-accent-hover); -} - .connected-system-body { margin-top: var(--space-3); } @@ -395,8 +354,8 @@ button:active { } /* - * Device layout: visible count on this page = column count (1–4). Fifth+ devices - * use pagination ("Next page"). + * Device layout: up to four devices per page (pagination). Multi-card columns + * are 1 → 2 → 4 only — never three, so four cards never sit as 3+1. */ .device-grid { display: grid; @@ -404,6 +363,7 @@ button:active { width: 100%; box-sizing: border-box; align-items: stretch; + grid-template-columns: 1fr; } .device-grid .device-wrapper { @@ -415,26 +375,33 @@ button:active { margin-bottom: 0; } -.device-grid--n1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); -} - +/* Single card: keep a readable max width instead of stretching full bleed. */ .device-grid--n1 .device-wrapper { max-width: min(52rem, 100%); width: 100%; justify-self: center; } -.device-grid--n2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); +/* Two or four visible cards: pair into two columns when there is room. */ +@media (min-width: 720px) { + .device-grid:not(.device-grid--n1):has(> .device-wrapper:nth-child(2):last-child), + .device-grid:not(.device-grid--n1):has(> .device-wrapper:nth-child(4)) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } -.device-grid--n3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); +/* Exactly three cards: one column until three-across fits (avoid 2+1 orphan). */ +@media (min-width: 1000px) { + .device-grid:not(.device-grid--n1):has(> .device-wrapper:nth-child(3):last-child) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } } -.device-grid--n4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); +/* Four cards: jump to four-across when wide enough (skip a 3-column band). */ +@media (min-width: 1400px) { + .device-grid:not(.device-grid--n1):has(> .device-wrapper:nth-child(4)) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } } .device-grid:not(.device-grid--n1) .connected-screenshot-link { @@ -580,6 +547,28 @@ button:active { word-break: break-word; } +.snapshot-iface-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.snapshot-iface { + padding: var(--space-2) 0; +} + +.snapshot-iface + .snapshot-iface { + border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent); +} + +.snapshot-iface:first-child { + padding-top: 0; +} + +.snapshot-iface:last-child { + padding-bottom: 0; +} + @media (max-width: 420px) { .snapshot-section .snapshot-row { flex-direction: column; @@ -689,6 +678,10 @@ button:active { border-top-color: rgba(255, 255, 255, 0.12); } +:root[data-theme="dark"] .snapshot-iface + .snapshot-iface { + border-top-color: rgba(255, 255, 255, 0.1); +} + :root[data-theme="dark"] .snapshot-row > span:first-child, :root[data-theme="dark"] .status-meta, :root[data-theme="dark"] .status-url { @@ -701,143 +694,3 @@ button:active { padding: 0 1rem; } } - -/* Virtual media section */ - -.virtual-media-details { - border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent); - padding-top: var(--space-3); - margin-top: var(--space-2); -} - -.virtual-media-summary { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; - list-style: none; - cursor: pointer; - user-select: none; - padding: 0; -} - -.virtual-media-summary::-webkit-details-marker { - display: none; -} - -.virtual-media-summary__lead { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; -} - -.virtual-media-summary__heading { - margin: 0; - font-size: 1.0625rem; - line-height: 1.3; - font-weight: 600; - color: var(--color-text); -} - -.virtual-media-summary-text { - margin: 0; - font-size: 0.9rem; - color: var(--color-text-muted); -} - -.virtual-media-summary__toggle { - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - width: 2rem; - min-height: 2.25rem; - flex-shrink: 0; - font-size: 1.35rem; - font-weight: 600; - line-height: 1; - color: var(--color-accent); - user-select: none; -} - -.virtual-media-details[open] .virtual-media-summary__toggle::after { - content: '\2212'; -} - -.virtual-media-details:not([open]) .virtual-media-summary__toggle::after { - content: '+'; -} - -.virtual-media-summary:hover .virtual-media-summary__toggle, -.virtual-media-summary:focus-visible .virtual-media-summary__toggle { - color: var(--color-accent-hover); -} - -.virtual-media-body { - margin-top: var(--space-3); -} - -.virtual-media-select, -.virtual-media-url-input { - display: block; - width: 100%; - margin-bottom: var(--space-2); - font-family: inherit; - font-size: 0.9rem; - box-sizing: border-box; -} - -.virtual-media-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); - align-items: center; - flex-wrap: wrap; - margin-top: var(--space-1); -} - -.virtual-media-confirm-text { - font-size: 0.88rem; - margin-right: auto; -} - -.virtual-media-info { - display: grid; - grid-template-columns: 5rem 1fr; - gap: var(--space-1) var(--space-3); - margin: 0 0 var(--space-3) 0; - font-size: 0.88rem; -} - -.virtual-media-info dt { - color: var(--color-text-muted, #5a6770); -} - -.virtual-media-info dd { - margin: 0; - word-break: break-all; -} - -.virtual-media-hint { - margin: var(--space-1) 0 0 0; - font-size: 0.9rem; -} - -.virtual-media-hint a { - color: var(--color-accent); - text-decoration: none; - font-weight: 600; -} - -.virtual-media-hint a:hover, -.virtual-media-hint a:focus-visible { - color: var(--color-accent-hover); - text-decoration: none; -} - -.virtual-media-status { - font-size: 0.82rem; - color: var(--color-text-muted, #5a6770); - min-height: 1.2em; - margin: var(--space-2) 0 0 0; -} diff --git a/app/static/js/api.js b/app/static/js/api.js index 7e2f3a8..761c71b 100644 --- a/app/static/js/api.js +++ b/app/static/js/api.js @@ -35,10 +35,6 @@ 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' }); } @@ -47,5 +43,4 @@ 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 6aace3d..6430605 100644 --- a/app/static/js/components/device-card.js +++ b/app/static/js/components/device-card.js @@ -3,23 +3,14 @@ import { formatExpandedSnapshot } from '../lib/snapshot-view.js'; const AUTOMATION_LICENSE_INFO_URL = 'https://tinypilotkvm.com/pages/automation'; -const _MOUNT_MODE_LABELS = { - CDROM: 'CD-ROM', - FLASH_READ_ONLY: 'USB — Read only', - FLASH_READ_WRITE: 'USB — Read/write', -}; - -function _formatMountMode(mode) { - return _MOUNT_MODE_LABELS[mode] || mode || 'Unknown'; -} - function formatCollapsedSnapshotSummary(c) { - const checkedText = formatRelativeTime(c.last_checked); + const checkedAt = c.last_checked || ''; + const checkedText = formatRelativeTime(checkedAt); return `
${c.online ? 'Connected' : 'Disconnected'} - v${escapeHtml(c.software_version || 'unknown')} · Last checked ${escapeHtml(checkedText)} + v${escapeHtml(c.software_version || 'unknown')} · Last checked ${escapeHtml(checkedText)}
${escapeHtml(c.device_url || '')}
`; @@ -67,7 +58,6 @@ class DeviceCard extends HTMLElement { summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`), metricsOutput: this.querySelector(`#device-metrics-output-${id}`), intervalInput: this.querySelector(`#screenshot-interval-${id}`), - virtualMediaSection: this.querySelector(`#virtual-media-${id}`), }; } @@ -83,16 +73,13 @@ class DeviceCard extends HTMLElement {

${escapeHtml(device.friendly_name)}

-
- -
-

Connected system

-

- Requires Automation License -

-
- -
+
+
+

Target system

+

+ Requires Automation License +

+
- +

-
-
- -
-

Virtual media

-

Loading…

-
- -
-
-
+

TinyPilot device

@@ -251,102 +228,6 @@ class DeviceCard extends HTMLElement { metricsOutput.innerHTML = formatExpandedSnapshot(snapshot); } - _renderVirtualMedia(mediaState) { - const id = this._device.id; - const section = this._elements.virtualMediaSection; - if (!section) { - return; - } - const backingFiles = mediaState.backingFiles || []; - const mountMode = mediaState.mountMode || ''; - const mountedFile = backingFiles.find(f => f.mounted) || null; - - const summaryText = section.querySelector('.virtual-media-summary-text'); - if (summaryText) { - summaryText.textContent = mountedFile - ? `${mountedFile.name} · ${_formatMountMode(mountMode)}` - : 'Not mounted'; - } - - const body = section.querySelector('.virtual-media-body'); - if (!body) { - return; - } - body.innerHTML = ''; - - if (backingFiles.length === 0) { - body.innerHTML = ` -

- -
- -
-
-

- Or upload via the TinyPilot WebUI ↗ -

-

- `; - } else if (!mountedFile) { - const options = backingFiles - .map(f => ``) - .join(''); - body.innerHTML = ` - - -
- -
-

- `; - } else { - body.innerHTML = ` -
-
Mounted
-
${escapeHtml(mountedFile.name)}
-
Mode
-
${escapeHtml(_formatMountMode(mountMode))}
-
-
- -
-

- `; - } - } - - async refreshMedia() { - if (!this._device) { - return; - } - const id = this._device.id; - const result = await window.dashboardApi.getJson(`/api/devices/${id}/media`); - const section = this._elements.virtualMediaSection; - if (result.error) { - if (section) { - const body = section.querySelector('.virtual-media-body'); - if (body) { - body.textContent = `Could not reach device: ${result.error}`; - } - } - return; - } - this._renderVirtualMedia(result); - } - } customElements.define('device-card', DeviceCard); diff --git a/app/static/js/dashboard-app.js b/app/static/js/dashboard-app.js index 824eb06..5b24123 100644 --- a/app/static/js/dashboard-app.js +++ b/app/static/js/dashboard-app.js @@ -1,5 +1,5 @@ import './components/device-card.js'; -import { escapeHtml, formatRelativeTime } from './lib/strings.js'; +import { formatRelativeTime } from './lib/strings.js'; const DEVICES_PER_PAGE = 4; const CONNECTED_STATUS_REFRESH_INTERVAL_MS = 30_000; @@ -44,11 +44,15 @@ class DashboardApp extends HTMLElement {
+
@@ -177,6 +181,7 @@ class DashboardApp extends HTMLElement { const payload = { friendly_name: formData.get('friendly_name'), base_url: formData.get('base_url'), + api_key: formData.get('api_key'), }; const result = await window.dashboardApi.postJson('/api/devices', payload); if (result.error) { @@ -256,89 +261,6 @@ class DashboardApp extends HTMLElement { } if (action === 'fetch-device-snapshot') { await card.refreshSnapshot(); - return; - } - if (action === 'fetch-media') { - const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); - const urlInput = card.querySelector(`#virtual-media-url-${deviceId}`); - const url = urlInput ? urlInput.value.trim() : ''; - if (!url) { - if (statusEl) { - statusEl.textContent = 'Please enter a URL.'; - } - return; - } - if (statusEl) { - statusEl.textContent = 'Downloading…'; - } - const fetchResult = await window.dashboardApi.postJson( - `/api/devices/${deviceId}/media/fetch`, - { url }, - ); - if (fetchResult.error) { - if (statusEl) { - statusEl.textContent = `Failed: ${fetchResult.error}`; - } - } else { - await card.refreshMedia(); - } - return; - } - if (action === 'mount-media') { - const fileSelect = card.querySelector(`#virtual-media-file-${deviceId}`); - const modeSelect = card.querySelector(`#virtual-media-mode-${deviceId}`); - const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); - const fileName = fileSelect ? fileSelect.value : ''; - const mode = modeSelect ? modeSelect.value : 'CDROM'; - if (!fileName) { - if (statusEl) { - statusEl.textContent = 'Please select an image.'; - } - return; - } - const mountResult = await window.dashboardApi.putJson( - `/api/devices/${deviceId}/media/mount`, - { fileName, mode }, - ); - if (mountResult.error) { - if (statusEl) { - statusEl.textContent = `Mount failed: ${mountResult.error}`; - } - } else { - await card.refreshMedia(); - } - return; - } - if (action === 'eject-media') { - const ejectArea = card.querySelector(`#virtual-media-eject-area-${deviceId}`); - const mountedName = ejectArea - ? ejectArea.closest('.virtual-media-body').querySelector('dd')?.textContent || 'this image' - : 'this image'; - if (ejectArea) { - ejectArea.innerHTML = ` - Eject ${escapeHtml(mountedName)}? - - - `; - } - return; - } - if (action === 'eject-media-confirm') { - const statusEl = card.querySelector(`#virtual-media-status-${deviceId}`); - const ejectResult = await window.dashboardApi.putJson( - `/api/devices/${deviceId}/media/eject`, - ); - if (ejectResult.error) { - if (statusEl) { - statusEl.textContent = `Eject failed: ${ejectResult.error}`; - } - } else { - await card.refreshMedia(); - } - return; - } - if (action === 'eject-media-cancel') { - await card.refreshMedia(); } } @@ -368,9 +290,10 @@ class DashboardApp extends HTMLElement { const container = this._elements.deviceSections; container.innerHTML = ''; container.className = 'device-grid'; - const count = Math.min(devices.length, DEVICES_PER_PAGE); - if (count > 0) { - container.classList.add(`device-grid--n${count}`); + // Only special-case a solitary card (centered max-width). Multi-card + // columns come from CSS auto-fit, not from device-grid--n2/3/4. + if (devices.length === 1) { + container.classList.add('device-grid--n1'); } for (const device of devices) { const card = document.createElement('device-card'); @@ -443,6 +366,14 @@ class DashboardApp extends HTMLElement { } connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(capturedAt)}.`; } + // "Last checked" is baked in at snapshot time; refresh relative labels so + // they age past "just now" like screenshot status does. + for (const el of this._elements.deviceSections.querySelectorAll('.status-checked-at')) { + const checkedAt = el.getAttribute('data-checked-at'); + if (checkedAt) { + el.textContent = formatRelativeTime(checkedAt); + } + } } async _refreshVisibleDevicePanels() { @@ -454,7 +385,6 @@ class DashboardApp extends HTMLElement { } await card.refreshScreenshot(this._screenshotCapturedAtByDevice); await card.refreshSnapshot(); - await card.refreshMedia(); } } diff --git a/app/static/js/lib/snapshot-view.js b/app/static/js/lib/snapshot-view.js index 878f68b..6f659c2 100644 --- a/app/static/js/lib/snapshot-view.js +++ b/app/static/js/lib/snapshot-view.js @@ -1,63 +1,95 @@ -import { escapeHtml, stringValue, yesNoUnknown } from './strings.js'; +import { escapeHtml, stringValue } from './strings.js'; function snapshotRow(label, valueText) { return `
${escapeHtml(label)}${escapeHtml(valueText)}
`; } -function interfaceLinkLabel(connected) { - if (connected === true) { - return 'connected'; +function friendlyInterfaceName(name) { + const raw = stringValue(name); + if (raw === 'eth0') { + return 'LAN1'; } - if (connected === false) { - return 'disconnected'; + if (raw === 'eth1') { + return 'LAN2'; } - return 'unknown'; + if (raw === 'wlan0') { + return 'Wi-Fi'; + } + return raw || 'Interface'; } -function formatNetworkInterface(label, iface) { +function interfaceConnected(iface) { + let connected = iface.isConnected; + if (connected === undefined) { + connected = iface.is_connected; + } + if (connected === undefined) { + connected = iface.connected; + } + return connected; +} + +function formatNetworkInterface(iface) { if (!iface || typeof iface !== 'object') { return ''; } + const label = friendlyInterfaceName(iface.name); const ip = stringValue(iface.ipAddress) || stringValue(iface.ip_address) - || stringValue(iface.ip) - || 'no IP'; + || stringValue(iface.ip); const mac = stringValue(iface.macAddress) || stringValue(iface.mac_address) || stringValue(iface.mac); - let connected = iface.isConnected; - if (connected === undefined) { - connected = iface.is_connected; - } - if (connected === undefined) { - connected = iface.connected; + const connected = interfaceConnected(iface); + + // One primary line: IP when present, otherwise link state (IP + status are + // usually redundant for a glanceable dashboard). + let primary = ip; + if (!primary) { + if (connected === true) { + primary = 'No IP'; + } else if (connected === false) { + primary = 'No link'; + } else { + primary = 'unknown'; + } } - const rows = [snapshotRow(`${label} IP`, ip)]; + + const rows = [snapshotRow(label, primary)]; if (mac) { - rows.push(snapshotRow(`${label} MAC`, mac)); + rows.push(snapshotRow('MAC', mac)); } - rows.push(snapshotRow(`${label} status`, interfaceLinkLabel(connected))); - return rows.join(''); + return `
${rows.join('')}
`; } function formatNetworkData(data) { if (!data || typeof data !== 'object') { return snapshotRow('Network', 'None reported'); } - const { ethernet, wifi } = data; + // Pro 3.2.0+: { interfaces: [{ name, ipAddress, macAddress, isConnected }, ...] } + if (Array.isArray(data.interfaces)) { + const parts = data.interfaces + .map((iface) => formatNetworkInterface(iface)) + .filter(Boolean); + if (parts.length === 0) { + return snapshotRow('Network', 'None reported'); + } + return `
${parts.join('')}
`; + } + // Legacy ethernet/wifi shape (pre-3.2.0). const parts = []; - if (ethernet) { - parts.push(formatNetworkInterface('Ethernet', ethernet)); + if (data.ethernet) { + parts.push(formatNetworkInterface({ ...data.ethernet, name: 'eth0' })); } - if (wifi) { - parts.push(formatNetworkInterface('Wi-Fi', wifi)); + if (data.wifi) { + parts.push(formatNetworkInterface({ ...data.wifi, name: 'wlan0' })); } if (parts.length === 0) { return snapshotRow('Network', 'None reported'); } - return parts.join(''); + return `
${parts.join('')}
`; } // TinyPilot ships two streaming modes today: H.264 and MJPEG. Normalize the @@ -160,7 +192,6 @@ export function formatExpandedSnapshot(snapshot) { const expanded = snapshot.expanded || {}; const status = expanded.reachability?.status || {}; const version = expanded.version?.status || {}; - const https = expanded.https_requirement?.status || {}; const video = expanded.video_settings?.status || {}; const networkData = expanded.network?.data || {}; @@ -178,10 +209,9 @@ export function formatExpandedSnapshot(snapshot) { ${snapshotRow('Target URL', snapshot.source_base_url || 'unknown')} ${snapshotRow('Software version', version.version || 'unknown')} ${snapshotRow('Reachability', expanded.reachability?.error ? 'Error' : 'OK')} - ${snapshotRow('Requires HTTPS', yesNoUnknown(https.requiresHttps))}
-

Network interfaces

+

Network status

${formatNetworkData(networkData)}
diff --git a/app/templates/index.html b/app/templates/index.html index 21ca0c4..a349e6e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,7 +1,7 @@ - {% set asset_v = '20260615-02' %} + {% set asset_v = '20260827-17' %} TinyPilot Dashboard diff --git a/app/tinypilot_client.py b/app/tinypilot_client.py index 4231f44..9a45d8c 100644 --- a/app/tinypilot_client.py +++ b/app/tinypilot_client.py @@ -1,17 +1,7 @@ -"""HTTP client for TinyPilot devices. +"""HTTP client for TinyPilot devices (Pro 3.2.0+ Automation API key). -This client wraps three groups of TinyPilot endpoints: - -* **Documented Automation REST API** (``/api/v1/*``, Bearer token) — limited to - what the official docs cover (auth, screenshot, keystroke, mouse, paste). -* **Device management / metrics** — TinyPilot Web UI JSON routes (``/api/*``) - reached through the same ``requests.Session`` (after an optional warmup GET). -* **Unofficial** — ``GET /state`` (Bearer same as Automation) is used only as - an optional fallback to discover the connected display resolution. - -If TinyPilot (or its reverse proxy) requires HTTP Basic authentication, pass -``http_basic=(username, password)`` so every request on the session carries -those credentials. +Uses a Bearer API key for allowlisted routes (screenshot, version, network, +video settings, ``/state``) 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 @@ -19,10 +9,8 @@ security tradeoff. Do not reuse this client for non-TinyPilot hosts. """ -import re from typing import Any from typing import Optional -from typing import Tuple import requests import urllib3 @@ -37,48 +25,18 @@ class TinyPilotClient: """Thin HTTP client for a single TinyPilot device.""" - def __init__( - self, - base_url: str, - *, - http_basic: Optional[Tuple[str, str]] = None, - ): + def __init__(self, base_url: str, *, api_key: Optional[str] = None): self.base_url = base_url.rstrip('/') + self.api_key = api_key self.session = requests.Session() self.session.headers.pop('Origin', None) # TinyPilot self-signed certs: see module docstring. self.session.verify = False - if http_basic is not None: - self.session.auth = http_basic - def refresh_automation_token(self) -> str: - response = self.session.post( - f'{self.base_url}/api/v1/auth', - timeout=10, - ) - response.raise_for_status() - return response.json()['token'] - - def refresh_csrf_token(self) -> str: - response = self.session.get(self.base_url, timeout=10) - response.raise_for_status() - csrf_match = re.search( - r'', - response.text, - ) - if csrf_match: - return csrf_match.group(1) - if self.session.cookies.get('session'): - # Some TinyPilot versions expose session + CSRF server-side without a meta tag. - return 'session-cookie' - raise ValueError('csrf-token meta tag not found') - - def get_network_status(self): - warmup = self.session.get(self.base_url, timeout=10) - warmup.raise_for_status() - response = self.session.get(f'{self.base_url}/api/network/status', timeout=10) - response.raise_for_status() - return response.json() + def _bearer_headers(self) -> dict[str, str]: + if not self.api_key: + return {} + return {'Authorization': f'Bearer {self.api_key}'} def _raise_for_status(self, response: requests.Response) -> None: """Raise an HTTPError, including TinyPilot's error message when present. @@ -99,111 +57,34 @@ def _raise_for_status(self, response: requests.Response) -> None: detail = f'{detail}: {device_message}' raise requests.HTTPError(detail, response=response) - def _get_json(self, path: str) -> dict[str, Any]: - warmup = self.session.get(self.base_url, timeout=10) - warmup.raise_for_status() - response = self.session.get(f'{self.base_url}{path}', timeout=10) - if response.status_code in (401, 403): - # Automatically recover from stale session/csrf by reloading the WebUI token once. - self.refresh_csrf_token() - response = self.session.get(f'{self.base_url}{path}', timeout=10) - self._raise_for_status(response) - return response.json() - - def _put_json( - self, - path: str, - body: Optional[dict] = None, - params: Optional[dict] = None, - ) -> dict[str, Any]: - """Send a PUT request with CSRF header. Retries once on 401/403.""" + def _get_json(self, path: str, *, bearer: bool = False) -> dict[str, Any]: warmup = self.session.get(self.base_url, timeout=10) warmup.raise_for_status() - csrf_token = None - csrf_match = re.search( - r'', - warmup.text, - ) - if csrf_match: - csrf_token = csrf_match.group(1) - headers = {'X-CSRFToken': csrf_token} if csrf_token else {} - response = self.session.put( + headers = self._bearer_headers() if bearer else {} + response = self.session.get( f'{self.base_url}{path}', - json=body, - params=params, headers=headers, - timeout=30, - ) - if response.status_code in (401, 403): - new_csrf = self.refresh_csrf_token() - retry_headers = ( - {'X-CSRFToken': new_csrf} - if new_csrf and new_csrf != 'session-cookie' - else {} - ) - response = self.session.put( - f'{self.base_url}{path}', - json=body, - params=params, - headers=retry_headers, - timeout=30, - ) - self._raise_for_status(response) - return response.json() if response.content else {} - - def get_mass_storage(self) -> dict[str, Any]: - """Return backing files, intermediate files, and current mount mode.""" - return self._get_json('/api/massStorage/backingFiles') - - def get_mass_storage_filename_from_url(self, url: str) -> str: - """Resolve or generate a backing file name from a download URL.""" - warmup = self.session.get(self.base_url, timeout=10) - warmup.raise_for_status() - response = self.session.get( - f'{self.base_url}/api/massStorage/retrieveFileNameFromUrl', - params={'url': url}, timeout=10, ) - response.raise_for_status() - return response.json()['fileName'] - - def fetch_mass_storage_from_url(self, filename: str, url: str) -> None: - """Tell the device to download an image from a URL and store it as `filename`.""" - self._put_json( - f'/api/massStorage/backingFiles/{filename}/fetchFromUrl', - body={'url': url}, - ) - - def mount_mass_storage(self, filename: str, mode: str) -> None: - """Mount `filename` in the given mode (CDROM, FLASH_READ_ONLY, FLASH_READ_WRITE).""" - self._put_json( - f'/api/massStorage/mount/{filename}', - params={'mode': mode}, - ) + self._raise_for_status(response) + return response.json() - def eject_mass_storage(self) -> None: - """Eject the currently mounted image.""" - self._put_json('/api/massStorage/eject') + def get_network_status(self): + return self._get_json('/api/network/status', bearer=True) def get_status(self) -> dict[str, Any]: return self._get_json('/api/status') - def get_auth_status(self) -> dict[str, Any]: - return self._get_json('/api/auth') - def get_version(self) -> dict[str, Any]: - return self._get_json('/api/version') - - def get_requires_https(self) -> dict[str, Any]: - return self._get_json('/api/settings/requiresHttps') + return self._get_json('/api/version', bearer=True) def get_video_settings(self) -> dict[str, Any]: - return self._get_json('/api/settings/video') + return self._get_json('/api/settings/video', bearer=True) - def get_screenshot(self, automation_token: str) -> bytes: + def get_screenshot(self) -> bytes: response = self.session.get( f'{self.base_url}/api/v1/screenshot', - headers={'Authorization': f'Bearer {automation_token}'}, + headers=self._bearer_headers(), timeout=15, ) response.raise_for_status() @@ -214,13 +95,12 @@ def get_screenshot(self, automation_token: str) -> bytes: ) return response.content - def get_automation_state(self, automation_token: str) -> dict[str, Any]: + def get_automation_state(self) -> dict[str, Any]: """Unofficial Automation API: connected display resolution via result.source.resolution.""" response = self.session.get( f'{self.base_url}/state', - headers={'Authorization': f'Bearer {automation_token}'}, + headers=self._bearer_headers(), timeout=10, ) response.raise_for_status() return response.json() - diff --git a/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md b/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md deleted file mode 100644 index 6f1a4a4..0000000 --- a/docs/superpowers/plans/2026-05-19-digitalocean-deployment.md +++ /dev/null @@ -1,481 +0,0 @@ -# DigitalOcean Deployment Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a single cloud-init bash script customers paste into DigitalOcean droplet creation, which installs Docker, clones the repo, starts the dashboard, configures the firewall, optionally joins Tailscale, and enables a daily auto-update timer. - -**Architecture:** A standalone bash script (`deploy/cloud-init.sh`) is the only new file. It is pasted by the customer into DigitalOcean's User Data field at droplet creation. The script runs once on first boot as root. All infrastructure knowledge lives in this file; the Flask app and Docker Compose config are unchanged. - -**Tech Stack:** Bash (Google Shell style + TinyPilot conventions), Docker Compose, `ufw`, `systemd`, Tailscale CLI, GitHub Actions (ShellCheck). - ---- - -## Style constraints (TinyPilot shell conventions) - -Apply these throughout `deploy/cloud-init.sh` and any embedded scripts: - -- `set -e`, `set -u`, `set -x` at the top of every bash script. -- Long flag names: `--yes` not `-y`, `--detach` not `-d`, `--quiet` not `-q`. -- `UPPERCASE` constants declared with `readonly` just before first use. -- Comments: capital first letter, trailing period. -- `(( ... ))` for numerical comparisons. -- Here-documents use `EOF` as the delimiter. -- Error messages to `>&2 echo`; no "ERROR:" prefix; sentence casing; no trailing punctuation. -- ShellCheck must pass with no warnings. - ---- - -## File map - -| Action | Path | Purpose | -|---|---|---| -| Create | `deploy/cloud-init.sh` | User-data script for DigitalOcean droplet creation | -| Create | `.github/workflows/shellcheck.yml` | CI: lint all shell scripts on every push/PR | -| Modify | `README.md` | Replace flat `## Install` with `## Deploy on DigitalOcean` + `## Run locally` | - ---- - -## Task 1: Create `deploy/cloud-init.sh` - -**Files:** -- Create: `deploy/cloud-init.sh` - -- [ ] **Step 1: Create the deploy/ directory and write the script** - -```bash -mkdir deploy -``` - -Write `deploy/cloud-init.sh` with the following exact content: - -```bash -#!/bin/bash - -# TinyPilot Dashboard setup script for DigitalOcean. -# Paste the contents of this file into the "User Data" field when creating -# a DigitalOcean Ubuntu 24.04 droplet (Advanced Options → User Data). -# -# To enable Tailscale (strongly recommended), set TAILSCALE_AUTH_KEY to a -# reusable auth key. Generate one at: -# https://login.tailscale.com/admin/settings/keys -# -# With Tailscale: dashboard accessible at https://..ts.net -# Without Tailscale: dashboard accessible via SSH tunnel only -# (ssh -L 8080:localhost:8080 root@) - -set -e -set -u -set -x - -# Set your Tailscale auth key here. Leave blank to skip Tailscale setup. -TAILSCALE_AUTH_KEY="" -readonly TAILSCALE_AUTH_KEY - -readonly INSTALL_DIR="/opt/tinypilot-dashboard" -readonly UPDATE_SCRIPT="/usr/local/bin/tinypilot-dashboard-update" -readonly UPDATE_SERVICE="/etc/systemd/system/tinypilot-dashboard-update.service" -readonly UPDATE_TIMER="/etc/systemd/system/tinypilot-dashboard-update.timer" - -# Install Docker Engine from the official Docker apt repository. -apt-get update --quiet -apt-get install \ - --yes \ - --quiet \ - ca-certificates \ - curl \ - git -install --mode=0755 --directory /etc/apt/keyrings -curl \ - --fail \ - --silent \ - --show-error \ - --location \ - --output /etc/apt/keyrings/docker.asc \ - https://download.docker.com/linux/ubuntu/gpg -chmod a+r /etc/apt/keyrings/docker.asc -# shellcheck disable=SC1091 -echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ - https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \ - | tee /etc/apt/sources.list.d/docker.list > /dev/null -apt-get update --quiet -apt-get install \ - --yes \ - --quiet \ - docker-ce \ - docker-ce-cli \ - containerd.io \ - docker-buildx-plugin \ - docker-compose-plugin - -# Clone the dashboard repository and build the Docker image. -git clone https://github.com/tiny-pilot/tinypilot-dashboard.git "${INSTALL_DIR}" -cd "${INSTALL_DIR}" -docker compose up --detach --build - -# Write the update script that customers can run manually or that the -# systemd timer invokes each night. -cat > "${UPDATE_SCRIPT}" << 'EOF' -#!/bin/bash - -# Updates TinyPilot Dashboard to the latest version from GitHub. -# The dashboard is unavailable for approximately 30 seconds while the new -# image builds. - -set -e -set -u - -readonly INSTALL_DIR="/opt/tinypilot-dashboard" - -cd "${INSTALL_DIR}" -git pull -docker compose up --detach --build -EOF -chmod 0755 "${UPDATE_SCRIPT}" - -# Install a systemd service and daily timer that auto-update the dashboard. -cat > "${UPDATE_SERVICE}" << 'EOF' -[Unit] -Description=TinyPilot Dashboard auto-update - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/tinypilot-dashboard-update -EOF - -cat > "${UPDATE_TIMER}" << 'EOF' -[Unit] -Description=TinyPilot Dashboard daily auto-update timer - -[Timer] -OnCalendar=*-*-* 03:00:00 -Persistent=true - -[Install] -WantedBy=timers.target -EOF - -systemctl daemon-reload -systemctl enable --now tinypilot-dashboard-update.timer - -# Configure ufw: allow SSH, block everything else. Port 8080 is never -# opened on the public internet regardless of Tailscale choice. -ufw allow OpenSSH -ufw --force enable - -# Set up Tailscale if an auth key was provided. -if [[ -n "${TAILSCALE_AUTH_KEY}" ]]; then - curl \ - --fail \ - --silent \ - --show-error \ - --location \ - https://tailscale.com/install.sh \ - | sh - tailscale up \ - --authkey="${TAILSCALE_AUTH_KEY}" \ - --ssh - tailscale serve http://localhost:8080 -fi -``` - -- [ ] **Step 2: Make the script executable** - -```bash -chmod 0755 deploy/cloud-init.sh -``` - -- [ ] **Step 3: Run ShellCheck locally to verify no warnings** - -```bash -shellcheck deploy/cloud-init.sh -``` - -Expected: no output (zero warnings, zero errors). - -If ShellCheck is not installed: -```bash -brew install shellcheck # macOS -# or -apt-get install shellcheck # Ubuntu -``` - -Common fixes if ShellCheck warns: -- SC2086 (word splitting): quote variables → `"${VAR}"` not `${VAR}` -- SC2046 (word splitting in command substitution): already handled by quoting -- SC1091 (not following sourced file): add `# shellcheck disable=SC1091` above the `/etc/os-release` source line - -- [ ] **Step 4: Commit** - -```bash -git add deploy/cloud-init.sh -git commit -m "Add DigitalOcean cloud-init setup script" -``` - ---- - -## Task 2: Add ShellCheck GitHub Actions workflow - -Per TinyPilot style guide: all repositories with shell scripts must run -ShellCheck in CI. - -**Files:** -- Create: `.github/workflows/shellcheck.yml` - -- [ ] **Step 1: Write the workflow** - -Write `.github/workflows/shellcheck.yml`: - -```yaml -name: ShellCheck - -on: - push: - pull_request: - -jobs: - shellcheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master - with: - scandir: './deploy' -``` - -- [ ] **Step 2: Commit** - -```bash -git add .github/workflows/shellcheck.yml -git commit -m "Add ShellCheck CI for deploy/ shell scripts" -``` - ---- - -## Task 3: Update README.md - -Replace the flat `## Install` section with two parallel sections: -`## Deploy on DigitalOcean` and `## Run locally`. Both are first-class -options — no hierarchy, no "recommended" framing. - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Replace the `## Install` section** - -Find this block in `README.md`: - -```markdown -## Install - -1. Clone or download this repository. -2. From the repository directory, start the dashboard: - - ```bash - docker compose up -d - ``` - -3. Open **[http://localhost:8080](http://localhost:8080)** in your browser. - -To stop the dashboard: - -```bash -docker compose down -``` - -Compose binds to `127.0.0.1:8080` on the host by default, so the UI is reachable only from the machine running it. To expose it to other hosts on your trusted network, see [Deployment & security](#deployment--security). -``` - -Replace it with: - -```markdown -## Deploy on DigitalOcean - -For always-on access from anywhere on your Tailscale network, run the -dashboard on a DigitalOcean droplet. All droplet sizes run identical -software — choose based on your workload: - -| Use case | Size | Cost | -|---|---|---| -| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo | -| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo | -| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo | -| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo | - -Droplets can be resized in the DigitalOcean control panel at any time -without data loss. - -### Steps - -1. [Create a DigitalOcean droplet](https://cloud.digitalocean.com/droplets/new) - running **Ubuntu 24.04 LTS**. -2. In **Advanced Options → User Data**, paste the contents of - [`deploy/cloud-init.sh`](deploy/cloud-init.sh) from this repository. -3. At the top of the pasted script, set your Tailscale auth key - (see below). Leave it blank to skip Tailscale. -4. Click **Create Droplet**. The dashboard starts automatically during - first boot (~5 minutes). - -### Tailscale (strongly recommended) - -[Generate a reusable auth key](https://login.tailscale.com/admin/settings/keys) -in your Tailscale admin console, then set it at the top of the -cloud-init script: - -``` -TAILSCALE_AUTH_KEY="tskey-auth-xxxxxxxx" -``` - -Once the droplet boots, the dashboard is available at -`https://..ts.net` — accessible only to devices -on your Tailscale network, with HTTPS provided automatically. - -**Without Tailscale:** the dashboard is localhost-only. Access it via -SSH tunnel (`ssh -L 8080:localhost:8080 root@`) and open -`http://localhost:8080`. For additional server hardening, see -DigitalOcean's [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu). - -### Updates - -The droplet auto-updates the dashboard daily at 3am. No action required. - -## Run locally - -For running on a machine that is already on the same network as your -TinyPilot devices: - -1. Clone or download this repository. -2. From the repository directory, start the dashboard: - - ```bash - docker compose up -d - ``` - -3. Open **[http://localhost:8080](http://localhost:8080)** in your browser. - -To stop the dashboard: - -```bash -docker compose down -``` - -Compose binds to `127.0.0.1:8080` on the host by default, so the UI is -reachable only from the machine running it. To expose it to other hosts -on your trusted network, see [Deployment & security](#deployment--security). -``` - -- [ ] **Step 2: Verify the README renders correctly** - -Open `README.md` in a Markdown previewer (GitHub preview or your IDE's -preview) and confirm: - -- The sizing table renders correctly. -- The two code blocks (Tailscale auth key, `docker compose up -d`) both - render as code blocks and are not broken by the surrounding fenced - code blocks. -- All links resolve (hover to confirm). -- `## Deploy on DigitalOcean` and `## Run locally` appear as sibling - headings with no visual hierarchy between them. - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "Add DigitalOcean deployment instructions to README" -``` - ---- - -## Task 4: Copy changes to staging repo and verify - -The public-facing code lives in the staging repo at -`/Users/shalver/Downloads/tinypilot-dashboard-public/`. Sync the new -files there and verify the tests still pass. - -- [ ] **Step 1: Copy new/changed files to the staging repo** - -```bash -cp -r \ - /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/deploy \ - /Users/shalver/Downloads/tinypilot-dashboard-public/ - -cp \ - /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/.github/workflows/shellcheck.yml \ - /Users/shalver/Downloads/tinypilot-dashboard-public/.github/workflows/ - -cp \ - /Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard/README.md \ - /Users/shalver/Downloads/tinypilot-dashboard-public/ -``` - -- [ ] **Step 2: Run tests in the staging repo** - -```bash -cd /Users/shalver/Downloads/tinypilot-dashboard-public -pytest -q -``` - -Expected: all 33 tests pass, 1 warning. - -- [ ] **Step 3: Run ShellCheck in the staging repo** - -```bash -shellcheck /Users/shalver/Downloads/tinypilot-dashboard-public/deploy/cloud-init.sh -``` - -Expected: no output. - -- [ ] **Step 4: Commit and push to GitHub** - -```bash -cd /Users/shalver/Downloads/tinypilot-dashboard-public -git add deploy/ .github/workflows/shellcheck.yml README.md -git status # confirm only expected files are staged -git commit -m "Add DigitalOcean one-droplet deployment" -git push origin master -``` - ---- - -## Post-implementation smoke test (manual, on a real droplet) - -After the code is pushed, verify the full customer journey: - -1. Create a fresh DigitalOcean Ubuntu 24.04 droplet (any size). -2. Paste `deploy/cloud-init.sh` into User Data with a valid Tailscale - auth key. -3. Wait ~5 minutes after the droplet boots. -4. Confirm the droplet appears in your Tailscale admin console. -5. Open `https://..ts.net` in a browser — the - TinyPilot Dashboard should load and show the `v0.1.0` version badge. -6. Add a TinyPilot device and confirm a screenshot loads. -7. Check the auto-update timer is active: - ```bash - systemctl status tinypilot-dashboard-update.timer - ``` - Expected: `active (waiting)`. -8. Verify port 8080 is NOT reachable on the public IP: - ```bash - curl --max-time 5 http://:8080 - ``` - Expected: connection timeout or refused. - ---- - -## Self-review checklist - -- [x] `deploy/cloud-init.sh` covers all spec steps: Docker install, git - clone, docker compose up, update script, systemd timer, ufw, Tailscale. -- [x] Port 8080 is never opened in ufw in either Tailscale or non-Tailscale - paths. -- [x] Data volume (`/opt/tinypilot-dashboard/data/`) persists across - auto-updates (bind mount in existing docker-compose.yml is unchanged). -- [x] ShellCheck CI added per TinyPilot style guide requirement. -- [x] README presents both deployment options as equals. -- [x] Sizing table in README matches spec. -- [x] No Docker socket mounted (removed from earlier design iteration). -- [x] All bash follows TinyPilot conventions: `set -e/u/x`, long flags, - uppercase constants, comments with trailing punctuation. -- [x] No `INTERNAL.md` or `poc` references introduced. diff --git a/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md b/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md deleted file mode 100644 index 24c0d20..0000000 --- a/docs/superpowers/plans/2026-06-15-v012-virtual-media-and-flicker-fix.md +++ /dev/null @@ -1,1053 +0,0 @@ -# TinyPilot Dashboard v0.1.2 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Virtual Media section to each device card (mount/eject/fetch-from-URL) and fix screenshot flicker by holding the last frame until the new one is pixel-ready. - -**Architecture:** Virtual media is a new collapsible `
` section in `device-card.js`, peer to "Connected System". Backend adds four new proxy routes in `api.py` that delegate to five new methods on `TinyPilotClient`. The flicker fix is a pure JS change: pre-load new screenshots into a temporary `Image` object before swapping ``. A cleanup task fixes a network-mapping bug and removes dead code. - -**Tech Stack:** Python/Flask (backend routes), `requests` (TinyPilot HTTP client), vanilla ES modules + web components (frontend), SQLite (unchanged — no schema changes needed). - -**Working directory:** `/Users/shalver/Downloads/tinypilot-dashboard/.worktrees/poc-dashboard` - -**Run tests with:** `pytest -q` (from working directory) - ---- - -## Task 1: Fix screenshot flicker - -**Files:** -- Modify: `app/static/js/components/device-card.js` (lines 144–207) - -The flicker is caused by `screenshot.removeAttribute('src')` at line 154, which blanks the image before the new one loads. The fix pre-loads the new image into an off-screen `Image` object and only swaps `screenshot.src` once the new image is ready. - -- [ ] **Step 1: Replace `refreshScreenshot` with the flicker-free version** - -Open `app/static/js/components/device-card.js` and replace the entire `refreshScreenshot` method (lines 144–207) with: - -```js -async refreshScreenshot(capturedAtByDevice) { - if (!this._device) { - return; - } - const id = this._device.id; - const { screenshot, link, connectedStatus } = this._elements; - - // Do NOT clear screenshot.src here — keep showing the current frame - // until the replacement is ready to avoid a visible blank state. - deactivateScreenshotLink(link); - - const result = await window.dashboardApi.postJson(`/api/devices/${id}/refresh-screenshot`); - - if (connectedStatus) { - if (result.error) { - connectedStatus.textContent = `Screenshot failed: ${result.error}`; - } else { - capturedAtByDevice.set(String(id), result.captured_at || new Date().toISOString()); - connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; - } - } - - if (result.error || !screenshot) { - return; - } - - const busted = `/api/devices/${id}/latest-screenshot?t=${Date.now()}`; - - const activateScreenshotLink = () => { - if (!link) { - return; - } - link.classList.add('connected-screenshot-link--available'); - link.setAttribute('tabindex', '0'); - link.removeAttribute('aria-disabled'); - link.href = busted; - }; - - // Pre-load into an off-screen Image. Only swap the displayed src - // after the browser has fully decoded the new frame so it never goes blank. - const preload = new Image(); - preload.onload = () => { - screenshot.src = busted; - activateScreenshotLink(); - if (connectedStatus) { - connectedStatus.textContent = `Screenshot refreshed ${formatRelativeTime(result.captured_at)}.`; - } - }; - preload.onerror = () => { - // Keep the current screenshot; show a status note. - if (connectedStatus) { - connectedStatus.textContent = 'Screenshot failed to load (image error).'; - } - deactivateScreenshotLink(link); - }; - preload.src = busted; - - // If already cached, onload may have fired synchronously — handle that case. - if (preload.complete && preload.naturalWidth > 0 && screenshot.src !== busted) { - screenshot.src = busted; - activateScreenshotLink(); - } -} -``` - -- [ ] **Step 2: Verify tests still pass** - -```bash -pytest -q -``` - -Expected: all tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add app/static/js/components/device-card.js -git commit -m "Fix screenshot flicker by pre-loading before swapping src" -``` - ---- - -## Task 2: TinyPilotClient — virtual media methods - -**Files:** -- Modify: `app/tinypilot_client.py` -- Create: `tests/test_tinypilot_client.py` - -Add five new methods to `TinyPilotClient` and a `_put_json` helper. All mass storage endpoints are Web UI session-based (ADMIN role). For the alpha (no Web UI passwords), CSRF tokens may not be strictly enforced, but we include a CSRF header as best practice. - -- [ ] **Step 1: Write failing tests** - -Create `tests/test_tinypilot_client.py`: - -```python -"""Unit tests for TinyPilotClient virtual media methods.""" - -from unittest.mock import MagicMock, patch - -import pytest - -from app.tinypilot_client import TinyPilotClient - - -@pytest.fixture -def client(): - return TinyPilotClient('https://device.local') - - -def _mock_session(client, responses): - """Attach a mock session that returns `responses` in order.""" - mock_session = MagicMock() - mock_session.get.return_value = responses[0] if len(responses) == 1 else MagicMock( - raise_for_status=MagicMock(), - text='', - status_code=200, - ) - mock_session.put.return_value = responses[-1] - client.session = mock_session - return mock_session - - -def make_response(json_data=None, status_code=200, content=b''): - r = MagicMock() - r.status_code = status_code - r.json.return_value = json_data or {} - r.content = content - r.raise_for_status = MagicMock() - return r - - -def test_get_mass_storage_returns_backing_files_and_mount_mode(client): - warmup = make_response() - warmup.text = '' - api_response = make_response({ - 'backingFiles': [{'name': 'ubuntu.iso', 'isMounted': True, 'loadedBytes': 1000, 'totalBytes': 1000}], - 'intermediateFiles': [], - 'mountMode': 'CDROM', - }) - mock_session = MagicMock() - mock_session.get.side_effect = [warmup, api_response] - client.session = mock_session - - result = client.get_mass_storage() - - assert result['mountMode'] == 'CDROM' - assert result['backingFiles'][0]['name'] == 'ubuntu.iso' - - -def test_get_mass_storage_filename_from_url_returns_filename(client): - warmup = make_response() - warmup.text = '' - api_response = make_response({'fileName': 'ubuntu-24.04.iso'}) - mock_session = MagicMock() - mock_session.get.side_effect = [warmup, api_response] - client.session = mock_session - - result = client.get_mass_storage_filename_from_url('https://example.com/ubuntu.iso') - - assert result == 'ubuntu-24.04.iso' - - -def test_mount_mass_storage_sends_correct_path_and_mode(client): - warmup = make_response() - warmup.text = '' - put_response = make_response({'success': True}) - mock_session = MagicMock() - mock_session.get.return_value = warmup - mock_session.put.return_value = put_response - client.session = mock_session - - client.mount_mass_storage('ubuntu.iso', 'CDROM') - - call_args = mock_session.put.call_args - assert 'massStorage/mount/ubuntu.iso' in call_args[0][0] - assert call_args[1]['params']['mode'] == 'CDROM' - - -def test_eject_mass_storage_calls_eject_endpoint(client): - warmup = make_response() - warmup.text = '' - put_response = make_response() - put_response.content = b'' - mock_session = MagicMock() - mock_session.get.return_value = warmup - mock_session.put.return_value = put_response - client.session = mock_session - - client.eject_mass_storage() - - call_args = mock_session.put.call_args - assert 'massStorage/eject' in call_args[0][0] -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -pytest tests/test_tinypilot_client.py -v -``` - -Expected: `AttributeError: 'TinyPilotClient' object has no attribute 'get_mass_storage'` (or similar). - -- [ ] **Step 3: Add `_put_json` helper and five new methods to `TinyPilotClient`** - -In `app/tinypilot_client.py`, add after `_get_json` (after line 92): - -```python -def _put_json( - self, - path: str, - body: Optional[dict] = None, - params: Optional[dict] = None, -) -> dict[str, Any]: - """Send a PUT request with CSRF header. Retries once on 401/403.""" - warmup = self.session.get(self.base_url, timeout=10) - warmup.raise_for_status() - csrf_token = None - csrf_match = re.search( - r'', - warmup.text, - ) - if csrf_match: - csrf_token = csrf_match.group(1) - headers = {'X-CSRFToken': csrf_token} if csrf_token else {} - response = self.session.put( - f'{self.base_url}{path}', - json=body, - params=params, - headers=headers, - timeout=30, - ) - if response.status_code in (401, 403): - self.refresh_csrf_token() - response = self.session.put( - f'{self.base_url}{path}', - json=body, - params=params, - headers=headers, - timeout=30, - ) - response.raise_for_status() - return response.json() if response.content else {} - -def get_mass_storage(self) -> dict[str, Any]: - """Return backing files, intermediate files, and current mount mode.""" - return self._get_json('/api/massStorage/backingFiles') - -def get_mass_storage_filename_from_url(self, url: str) -> str: - """Resolve or generate a backing file name from a download URL.""" - warmup = self.session.get(self.base_url, timeout=10) - warmup.raise_for_status() - response = self.session.get( - f'{self.base_url}/api/massStorage/retrieveFileNameFromUrl', - params={'url': url}, - timeout=10, - ) - response.raise_for_status() - return response.json()['fileName'] - -def fetch_mass_storage_from_url(self, filename: str, url: str) -> None: - """Tell the device to download an image from a URL and store it as `filename`.""" - self._put_json( - f'/api/massStorage/backingFiles/{filename}/fetchFromUrl', - body={'url': url}, - ) - -def mount_mass_storage(self, filename: str, mode: str) -> None: - """Mount `filename` in the given mode (CDROM, FLASH_READ_ONLY, FLASH_READ_WRITE).""" - self._put_json( - f'/api/massStorage/mount/{filename}', - params={'mode': mode}, - ) - -def eject_mass_storage(self) -> None: - """Eject the currently mounted image.""" - self._put_json('/api/massStorage/eject') -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -pytest tests/test_tinypilot_client.py -v -``` - -Expected: all 5 tests pass. - -- [ ] **Step 5: Run full test suite to confirm nothing regressed** - -```bash -pytest -q -``` - -Expected: all tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add app/tinypilot_client.py tests/test_tinypilot_client.py -git commit -m "Add virtual media methods to TinyPilotClient" -``` - ---- - -## Task 3: Dashboard backend — virtual media routes - -**Files:** -- Modify: `app/api.py` -- Modify: `tests/test_api_connections.py` - -Add four new routes: `GET /media`, `POST /media/fetch`, `PUT /media/mount`, `PUT /media/eject`. All use the same device-lookup + client pattern already established in `api.py`. - -- [ ] **Step 1: Write failing tests** - -Append to `tests/test_api_connections.py`: - -```python -def _create_device(client, base_url='https://192.168.1.200'): - """Helper: create a device with no automation token.""" - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.refresh_automation_token.side_effect = RuntimeError('no device') - resp = client.post('/api/devices', json={ - 'friendly_name': 'Media Test Device', - 'base_url': base_url, - }) - return resp.json['device']['id'] - - -def test_get_media_returns_backing_files(client): - device_id = _create_device(client) - backing_files_response = { - 'backingFiles': [{'name': 'ubuntu.iso', 'isMounted': True, 'loadedBytes': 100, 'totalBytes': 100}], - 'intermediateFiles': [], - 'mountMode': 'CDROM', - } - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.get_mass_storage.return_value = backing_files_response - response = client.get(f'/api/devices/{device_id}/media') - - assert response.status_code == 200 - assert response.json['backingFiles'][0]['name'] == 'ubuntu.iso' - assert response.json['mountMode'] == 'CDROM' - - -def test_get_media_returns_404_for_unknown_device(client): - response = client.get('/api/devices/9999/media') - assert response.status_code == 404 - - -def test_media_fetch_triggers_device_download(client): - device_id = _create_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.get_mass_storage_filename_from_url.return_value = 'ubuntu.iso' - cls.return_value.fetch_mass_storage_from_url.return_value = None - response = client.post( - f'/api/devices/{device_id}/media/fetch', - json={'url': 'https://example.com/ubuntu.iso'}, - ) - - assert response.status_code == 200 - assert response.json['fileName'] == 'ubuntu.iso' - - -def test_media_fetch_returns_400_without_url(client): - device_id = _create_device(client) - response = client.post(f'/api/devices/{device_id}/media/fetch', json={}) - assert response.status_code == 400 - - -def test_media_mount_calls_device(client): - device_id = _create_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.mount_mass_storage.return_value = None - response = client.put( - f'/api/devices/{device_id}/media/mount', - json={'fileName': 'ubuntu.iso', 'mode': 'CDROM'}, - ) - - assert response.status_code == 200 - cls.return_value.mount_mass_storage.assert_called_once_with('ubuntu.iso', 'CDROM') - - -def test_media_mount_returns_400_without_required_fields(client): - device_id = _create_device(client) - response = client.put(f'/api/devices/{device_id}/media/mount', json={'fileName': 'ubuntu.iso'}) - assert response.status_code == 400 - - -def test_media_eject_calls_device(client): - device_id = _create_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.eject_mass_storage.return_value = None - response = client.put(f'/api/devices/{device_id}/media/eject') - - assert response.status_code == 200 - cls.return_value.eject_mass_storage.assert_called_once() -``` - -- [ ] **Step 2: Run new tests to confirm they fail** - -```bash -pytest tests/test_api_connections.py -k "media" -v -``` - -Expected: `404 NOT FOUND` on all (routes don't exist yet). - -- [ ] **Step 3: Add four new routes to `app/api.py`** - -Append to `app/api.py` (before the final newline): - -```python -@api_blueprint.get('/devices//media') -def get_device_media(device_id: int): - """Return current virtual media state (backing files + mount mode) for a device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - result = client.get_mass_storage() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch media state: {err}'}), 502 - return jsonify(result) - - -@api_blueprint.post('/devices//media/fetch') -def fetch_device_media_from_url(device_id: int): - """Tell the device to download an image from a URL.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - payload = request.get_json(silent=True) or {} - url = (payload.get('url') or '').strip() - if not url: - return jsonify({'error': 'url is required'}), 400 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - file_name = client.get_mass_storage_filename_from_url(url) - client.fetch_mass_storage_from_url(file_name, url) - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to fetch image from URL: {err}'}), 502 - return jsonify({'device_id': device_id, 'fileName': file_name}) - - -@api_blueprint.put('/devices//media/mount') -def mount_device_media(device_id: int): - """Mount a backing file on the device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - payload = request.get_json(silent=True) or {} - file_name = (payload.get('fileName') or '').strip() - mode = (payload.get('mode') or '').strip() - if not file_name or not mode: - return jsonify({'error': 'fileName and mode are required'}), 400 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - client.mount_mass_storage(file_name, mode) - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to mount image: {err}'}), 502 - return jsonify({'device_id': device_id, 'mounted': True, 'fileName': file_name, 'mode': mode}) - - -@api_blueprint.put('/devices//media/eject') -def eject_device_media(device_id: int): - """Eject the currently mounted image on the device.""" - row = _device_row_with_web_auth(device_id) - if row is None: - return jsonify({'error': 'device not found'}), 404 - key_path = Path(current_app.config['SECRET_KEY_PATH']) - http_basic = _http_basic_from_auth_row(key_path, row) - client = TinyPilotClient(row['base_url'], http_basic=http_basic) - try: - client.eject_mass_storage() - except Exception as err: # pylint: disable=broad-exception-caught - return jsonify({'error': f'failed to eject media: {err}'}), 502 - return jsonify({'device_id': device_id, 'ejected': True}) -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -pytest tests/test_api_connections.py -k "media" -v -``` - -Expected: all 8 new tests pass. - -- [ ] **Step 5: Run full suite** - -```bash -pytest -q -``` - -Expected: all tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add app/api.py tests/test_api_connections.py -git commit -m "Add virtual media proxy routes to dashboard API" -``` - ---- - -## Task 4: Virtual Media frontend section - -**Files:** -- Modify: `app/static/js/components/device-card.js` -- Modify: `app/static/css/dashboard.css` -- Modify: `app/templates/index.html` (bump `asset_v`) - -Add a collapsible "Virtual media" section below "Connected system" in the device card. Uses the same `
` open/close pattern as Connected System. - -The section has three UI states: -- **No images:** URL input + "Add image" button + hint to TinyPilot WebUI -- **Images available, nothing mounted:** file `` + "Mount" button -- **Mounted:** filename + mode label + "Eject" button (with inline confirmation) - -- [ ] **Step 1: Add `_renderVirtualMedia` helper and extend `_cacheElements` in `device-card.js`** - -In `_cacheElements`, add `virtualMediaSection` to `this._elements`: - -```js -_cacheElements() { - const id = this._device.id; - this._elements = { - screenshot: this.querySelector(`#connected-screenshot-${id}`), - link: this.querySelector(`#connected-screenshot-link-${id}`), - connectedStatus: this.querySelector(`#connected-status-${id}`), - summaryOutput: this.querySelector(`#device-collapsed-summary-${id}`), - metricsOutput: this.querySelector(`#device-metrics-output-${id}`), - intervalInput: this.querySelector(`#screenshot-interval-${id}`), - virtualMediaSection: this.querySelector(`#virtual-media-${id}`), - }; -} -``` - -Add the `_renderVirtualMedia(mediaState)` method after `refreshSnapshot`: - -```js -_renderVirtualMedia(mediaState) { - const id = this._device.id; - const section = this._elements.virtualMediaSection; - if (!section) { - return; - } - const { backingFiles, mountMode } = mediaState; - const mountedFile = (backingFiles || []).find(f => f.isMounted); - const allFiles = (backingFiles || []); - - // Update collapsed summary. - const summary = section.querySelector('.virtual-media-summary-text'); - if (summary) { - summary.textContent = mountedFile - ? `${mountedFile.name} · ${_formatMountMode(mountMode)}` - : 'Not mounted'; - } - - // Render body. - const body = section.querySelector('.virtual-media-body'); - if (!body) { - return; - } - body.innerHTML = ''; - - if (allFiles.length === 0) { - body.innerHTML = ` -

- No images on this device. - - Upload via the TinyPilot WebUI - . -

-
- - -
-

- `; - } else if (!mountedFile) { - const options = allFiles - .map(f => ``) - .join(''); - body.innerHTML = ` - - -
- -
-

- `; - } else { - body.innerHTML = ` -
-
Mounted
-
${escapeHtml(mountedFile.name)}
-
Mode
-
${escapeHtml(_formatMountMode(mountMode))}
-
-
- -
-

- `; - } -} -``` - -Add the module-level helper `_formatMountMode` at the top of `device-card.js` (after the imports): - -```js -const _MOUNT_MODE_LABELS = { - CDROM: 'CD-ROM', - FLASH_READ_ONLY: 'USB — Read only', - FLASH_READ_WRITE: 'USB — Read/write', -}; - -function _formatMountMode(mode) { - return _MOUNT_MODE_LABELS[mode] || mode || 'Unknown'; -} -``` - -- [ ] **Step 2: Add the Virtual Media HTML to `_render()`** - -In `_render()`, after the closing `
` of the Connected System section (after line 121), add the new section: - -```js -
- -
- -

Virtual media

-

Loading…

-
- -
-
-
-``` - -- [ ] **Step 3: Add event handlers for virtual media actions** - -The `dashboard-app.js` dispatches button click events with `data-action`. Open `app/static/js/dashboard-app.js` and find where `data-action` values are handled. Add cases for the new actions: - -In the click handler (look for `refresh-screenshot`, `fetch-device-snapshot`, etc.), add: - -```js -case 'fetch-media': { - const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); - const urlInput = document.getElementById(`virtual-media-url-${deviceId}`); - const url = urlInput ? urlInput.value.trim() : ''; - if (!url) { - if (statusEl) statusEl.textContent = 'Please enter a URL.'; - break; - } - if (statusEl) statusEl.textContent = 'Downloading…'; - const result = await window.dashboardApi.postJson(`/api/devices/${deviceId}/media/fetch`, { url }); - if (result.error) { - if (statusEl) statusEl.textContent = `Failed: ${result.error}`; - } else { - const card = this._deviceCards.get(deviceId); - if (card) await card.refreshMedia(); - } - break; -} -case 'mount-media': { - const fileSelect = document.getElementById(`virtual-media-file-${deviceId}`); - const modeSelect = document.getElementById(`virtual-media-mode-${deviceId}`); - const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); - const fileName = fileSelect ? fileSelect.value : ''; - const mode = modeSelect ? modeSelect.value : 'CDROM'; - if (!fileName) { - if (statusEl) statusEl.textContent = 'Please select an image.'; - break; - } - const result = await window.dashboardApi.putJson(`/api/devices/${deviceId}/media/mount`, { fileName, mode }); - if (result.error) { - if (statusEl) statusEl.textContent = `Mount failed: ${result.error}`; - } else { - const card = this._deviceCards.get(deviceId); - if (card) await card.refreshMedia(); - } - break; -} -case 'eject-media': { - const ejectArea = document.getElementById(`virtual-media-eject-area-${deviceId}`); - if (ejectArea) { - ejectArea.innerHTML = ` - Eject this image? - - - `; - } - break; -} -case 'eject-media-confirm': { - const statusEl = document.getElementById(`virtual-media-status-${deviceId}`); - const result = await window.dashboardApi.putJson(`/api/devices/${deviceId}/media/eject`); - if (result.error) { - if (statusEl) statusEl.textContent = `Eject failed: ${result.error}`; - } else { - const card = this._deviceCards.get(deviceId); - if (card) await card.refreshMedia(); - } - break; -} -case 'eject-media-cancel': { - const card = this._deviceCards.get(deviceId); - if (card) await card.refreshMedia(); - break; -} -``` - -- [ ] **Step 4: Add `refreshMedia()` method and `putJson` to the API helper** - -In `device-card.js`, add after `refreshSnapshot`: - -```js -async refreshMedia() { - if (!this._device) { - return; - } - const id = this._device.id; - const result = await window.dashboardApi.getJson(`/api/devices/${id}/media`); - if (result.error) { - const section = this._elements.virtualMediaSection; - if (section) { - const body = section.querySelector('.virtual-media-body'); - if (body) body.textContent = `Could not reach device: ${result.error}`; - } - return; - } - this._renderVirtualMedia(result); -} -``` - -In `app/static/js/api.js`, add a `putJson` method alongside the existing `postJson`: - -```js -async putJson(path, body = {}) { - try { - const response = await fetch(path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return await response.json(); - } catch (err) { - return { error: String(err) }; - } -} -``` - -- [ ] **Step 5: Call `refreshMedia()` from the snapshot refresh loop** - -In `dashboard-app.js`, wherever `card.refreshSnapshot()` is called, add a follow-up call: - -```js -await card.refreshSnapshot(); -await card.refreshMedia(); -``` - -- [ ] **Step 6: Add CSS for the new section** - -In `app/static/css/dashboard.css`, append: - -```css -/* Virtual media section — mirrors .connected-system-* structure */ - -.virtual-media-details { - border-top: 1px solid color-mix(in srgb, var(--color-text) 10%, transparent); - padding-top: var(--space-3); - margin-top: var(--space-2); -} - -.virtual-media-summary { - display: grid; - grid-template-columns: 1fr auto; - align-items: start; - list-style: none; - cursor: pointer; - user-select: none; - padding: 0; -} - -.virtual-media-summary::-webkit-details-marker { - display: none; -} - -.virtual-media-summary__lead { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; -} - -.virtual-media-icon { - color: var(--color-text-muted, #5a6770); - margin-bottom: 2px; -} - -.virtual-media-summary__heading { - margin: 0; - font-size: 1.0625rem; - line-height: 1.3; - font-weight: 400; -} - -.virtual-media-summary-text { - margin: 0; - font-size: 0.82rem; - color: var(--color-text-muted, #5a6770); -} - -.virtual-media-summary__toggle { - display: flex; - align-items: center; - justify-content: center; - min-width: 1.5rem; - font-size: 1.1rem; - color: var(--color-text-muted, #5a6770); - padding-top: 0.15em; -} - -.virtual-media-details[open] .virtual-media-summary__toggle::after { - content: '\2212'; -} - -.virtual-media-details:not([open]) .virtual-media-summary__toggle::after { - content: '+'; -} - -.virtual-media-summary:hover .virtual-media-summary__toggle, -.virtual-media-summary:focus-visible .virtual-media-summary__toggle { - color: var(--color-accent-hover); -} - -.virtual-media-body { - margin-top: var(--space-3); -} - -.virtual-media-select { - display: block; - width: 100%; - margin-bottom: var(--space-2); - font-family: inherit; - font-size: 0.9rem; -} - -.virtual-media-url-input { - display: block; - width: 100%; - margin-bottom: var(--space-2); - font-family: inherit; - font-size: 0.9rem; -} - -.virtual-media-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); - align-items: center; - flex-wrap: wrap; -} - -.virtual-media-confirm-text { - font-size: 0.88rem; - margin-right: auto; -} - -.virtual-media-info { - display: grid; - grid-template-columns: 5rem 1fr; - gap: var(--space-1) var(--space-3); - margin: 0 0 var(--space-3) 0; - font-size: 0.88rem; -} - -.virtual-media-info dt { - color: var(--color-text-muted, #5a6770); -} - -.virtual-media-info dd { - margin: 0; - word-break: break-all; -} - -.virtual-media-empty { - font-size: 0.85rem; - color: var(--color-text-muted, #5a6770); - margin: 0 0 var(--space-3) 0; -} - -.virtual-media-status { - font-size: 0.82rem; - color: var(--color-text-muted, #5a6770); - min-height: 1.2em; - margin: var(--space-2) 0 0 0; -} -``` - -- [ ] **Step 7: Bump `asset_v` in the HTML template** - -In `app/templates/index.html`, increment `asset_v` (e.g. `20260511-15` → `20260615-01`): - -```html -{% set asset_v = '20260615-01' %} -``` - -- [ ] **Step 8: Run full test suite** - -```bash -pytest -q -``` - -Expected: all tests pass. - -- [ ] **Step 9: Smoke-test locally with Docker** - -```bash -docker compose up --build --detach -``` - -Open `http://localhost:8080` and verify: -- The Virtual Media section appears below "Connected system" on each card. -- The `+` / `−` toggle collapses/expands the section. -- Collapsed state shows "Loading…" (before `refreshMedia` runs) then the mount status. - -- [ ] **Step 10: Commit** - -```bash -git add app/static/js/components/device-card.js \ - app/static/js/dashboard-app.js \ - app/static/js/api.js \ - app/static/css/dashboard.css \ - app/templates/index.html -git commit -m "Add Virtual Media section to device card" -``` - ---- - -## Task 5: Cleanup - -**Files:** -- Modify: `app/api.py` (remove dead endpoint, fix network mapping) - -Two targeted cleanup items touched during this work: - -1. **Fix network mapping bug** — `get_device_snapshot` maps `network` as `{'interfaces': network.get('interfaces', [])}` but TinyPilot returns `{ethernet: {...}, wifi: {...}}` — `interfaces` never exists. Fix to pass through the full network object. - -2. **Remove dead `get_device_metrics` endpoint** — superseded by the snapshot endpoint; not called by any frontend code. - -- [ ] **Step 1: Fix network mapping in `get_device_snapshot`** - -In `app/api.py`, find the `expanded` dict construction in `get_device_snapshot` (around line 560). Change: - -```python -'network': {'interfaces': (network or {}).get('interfaces', []), 'error': network_error}, -``` - -to: - -```python -'network': {'data': network or {}, 'error': network_error}, -``` - -- [ ] **Step 2: Update `snapshot-view.js` to use the new key** - -In `app/static/js/lib/snapshot-view.js`, find any reference to `snapshot.expanded.network.interfaces` and update it to read from `snapshot.expanded.network.data` (check for both `ethernet` and `wifi` keys): - -```js -// In the network section formatter, replace any .interfaces array walk with: -const networkData = (expanded.network || {}).data || {}; -const ethernet = networkData.ethernet; -const wifi = networkData.wifi; -// Render ethernet and wifi directly rather than iterating an interfaces array. -``` - -- [ ] **Step 3: Remove `get_device_metrics` from `app/api.py`** - -Delete the entire `get_device_metrics` function and its route decorator (lines 441–461): - -```python -@api_blueprint.get('/devices//device/metrics') -def get_device_metrics(device_id: int): - ... -``` - -- [ ] **Step 4: Run full test suite** - -```bash -pytest -q -``` - -Expected: all tests pass (no test references `get_device_metrics`). - -- [ ] **Step 5: Commit** - -```bash -git add app/api.py app/static/js/lib/snapshot-view.js -git commit -m "Fix network mapping in snapshot and remove dead metrics endpoint" -``` diff --git a/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md b/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md deleted file mode 100644 index 559a647..0000000 --- a/docs/superpowers/specs/2026-05-19-digitalocean-deployment-design.md +++ /dev/null @@ -1,139 +0,0 @@ -# Design: TinyPilot Dashboard — DigitalOcean Deployment - -**Date:** 2026-05-19 -**Status:** Approved for implementation - ---- - -## Overview - -Enable existing TinyPilot customers to deploy the TinyPilot Dashboard on a -DigitalOcean droplet with minimal effort. The droplet runs the dashboard as a -Docker service, secures it with Tailscale, and auto-updates daily from the -public GitHub repository. - -Self-hosted local deployment remains a fully supported, first-class option. -DigitalOcean is an additional path for customers who want always-on access -from anywhere on their Tailscale network. - ---- - -## Deliverables - -1. `deploy/cloud-init.yaml` — user-data script pasted into DigitalOcean droplet creation -2. Systemd timer unit (embedded in cloud-init) — auto-updates the dashboard daily -3. README updates — new "Deploy on DigitalOcean" and "Run locally" sections replacing the existing flat "Install" section - ---- - -## Droplet sizing - -One cloud-init script works on any Ubuntu droplet. Customers choose their own -size; there are no locked tiers. Recommended sizes: - -| Use case | Size | Cost | -|---|---|---| -| Dashboard only, light use | `s-1vcpu-1gb` | $6/mo | -| Dashboard only, comfortable | `s-1vcpu-2gb` | $12/mo (recommended) | -| Dashboard + heavier polling | `s-2vcpu-4gb` | $24/mo | -| Dashboard + AI agent (Cursor/Claude Code) | `s-4vcpu-8gb` | $48/mo | - -Note: The $6/mo tier works but the first-boot Docker image build may be slow -due to limited RAM. All tiers run identical software. Droplets can be resized -in the DigitalOcean control panel at any time without data loss. - ---- - -## Cloud-init script (`deploy/cloud-init.yaml`) - -Runs once on first boot. Steps in order: - -1. Update Ubuntu packages -2. Install Docker Engine + Docker Compose plugin (official Docker apt repo) -3. Clone `https://github.com/tiny-pilot/tinypilot-dashboard.git` to `/opt/tinypilot-dashboard/` -4. Build and start the dashboard: `docker compose up -d --build` -6. Configure `ufw`: allow OpenSSH, enable ufw (default deny incoming). Port 8080 is never opened. -7. **If `TAILSCALE_AUTH_KEY` is set:** - - Install Tailscale via official install script - - Authenticate: `tailscale up --authkey=$TAILSCALE_AUTH_KEY --ssh` - - Enable Tailscale Serve: `tailscale serve --bg http://localhost:8080` - - Dashboard is now accessible at `https://..ts.net` — Tailscale peers only, HTTPS with valid certificate -8. **If `TAILSCALE_AUTH_KEY` is empty:** skip Tailscale entirely. Dashboard is localhost-only; access via SSH tunnel. -9. Install systemd auto-update timer (see below) - -The `TAILSCALE_AUTH_KEY` variable appears at the top of the script as a -clearly labeled blank. Customers paste in their key or leave it empty. - -### Security posture - -- `ufw` allows SSH only. Port 8080 is never exposed on the public internet in - either path. -- **With Tailscale:** `tailscale serve` proxies localhost:8080 onto the - Tailscale interface with automatic HTTPS. No firewall rules needed beyond - the default deny. -- **Without Tailscale:** dashboard is reachable only via SSH tunnel - (`ssh -L 8080:localhost:8080 user@droplet-ip`). Customers who choose this - path are directed to DigitalOcean's - [Initial Server Setup guide](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu) - for additional hardening. No further security guidance is maintained in this - repo. -- Docker socket is NOT mounted. No container-to-host privilege escalation. - ---- - -## Auto-update - -A systemd timer unit (`tinypilot-dashboard-update.timer`) runs daily at 3am: - -```bash -cd /opt/tinypilot-dashboard -git pull -docker compose up -d --build -``` - -`git pull` is a no-op if no new commits exist — no unnecessary restarts. -When new code is pulled, the container rebuilds and restarts (~30s downtime). - -**Data persistence:** `data/` is bind-mounted from the host at -`/opt/tinypilot-dashboard/data/`. Device records, credentials, and -`secret.key` survive every update. Customers never need to re-add their -devices after an update. - -**No UI surface for updates.** The existing version badge (`v0.1.x`) in the -topbar is sufficient for bug report reference. Auto-update is silent and -invisible to the customer. This will be revisited for beta when manual update -control may be reintroduced. - ---- - -## README structure changes - -The existing flat `## Install` section is replaced with two parallel sections: - -### `## Deploy on DigitalOcean` -- When to choose this path (always-on, Tailscale network access) -- Sizing recommendations table -- Step-by-step: create Ubuntu 24.04 droplet, paste cloud-init script into - User Data field, click Create -- The cloud-init script as a copyable code block with `TAILSCALE_AUTH_KEY=""` - at the top -- Tailscale note: strongly recommended; link to Tailscale's [auth key docs](https://tailscale.com/kb/1085/auth-keys) for generating a key; link to DO hardening guide for those who skip it - -### `## Run locally` -- When to choose this path (LAN use, machine already on the network) -- Existing `docker compose up -d` steps, unchanged - -Both sections are presented without hierarchy — different use cases, same product. - ---- - -## Out of scope - -- Published Docker image / registry (Path B distribution): future work; will - eliminate the first-boot build step and make the $6/mo tier more reliable -- DigitalOcean Marketplace 1-click app: post-alpha; requires vendor application - (~4–8 weeks). The cloud-init script doubles as the basis for a Marketplace - submission when ready. -- Manual update button in the UI: deferred to beta. Auto-update is sufficient - for alpha. -- Multi-user auth on the dashboard: separate workstream, not in scope here. diff --git a/docs/superpowers/specs/2026-06-15-v012-design.md b/docs/superpowers/specs/2026-06-15-v012-design.md deleted file mode 100644 index d92346d..0000000 --- a/docs/superpowers/specs/2026-06-15-v012-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# TinyPilot Dashboard v0.1.2 Design - -## Overview - -Two features for v0.1.2: - -1. **Virtual Media** — A new collapsible card section that shows the mount status of a TinyPilot device's mass storage and allows the user to mount or eject an image without leaving the dashboard. -2. **Screenshot flicker fix** — The current screenshot must never be removed until a replacement is fully loaded, eliminating visible blank/loading gaps during polling refreshes. - ---- - -## Feature 1: Virtual Media - -### Where it lives - -A new collapsible section in the device card, rendered as a peer to "Connected System". Both sections appear in the card body below the card header. The Virtual Media section appears below Connected System. - -The section follows the same `
` / `+` / `−` pattern already used by Connected System. - -### Collapsed state - -Header shows: -- A disk icon (SVG inline) -- "Virtual media" heading -- A one-line summary: filename + mode (e.g. `ubuntu-24.04-server.iso · CD-ROM`) or "Not mounted" if nothing is mounted -- `+` or `−` toggle on the right - -### Expanded states - -**State A — No images on device** (device has zero backing files): - -``` -Virtual media − -[ Paste image URL… ] - [ Add image ] -``` - -- User pastes a direct URL to an ISO (e.g. `https://releases.ubuntu.com/.../ubuntu.iso`) -- Dashboard resolves the filename via `GET /api/massStorage/retrieveFileNameFromUrl?url=`, then triggers the device to download it via `PUT /api/massStorage/backingFiles//fetchFromUrl` -- While downloading, the section shows a progress/spinner state with "Downloading…" -- On success, the file list refreshes and moves to State B -- On failure, shows an inline error - -A small "Upload via the TinyPilot WebUI" hint link sits below the input for users who want to upload from their computer (deferred to v0.1.3). - -**State B — Images available, nothing mounted**: - -``` -Virtual media − -[ Select image… ▾ ] -[ CD-ROM ▾ ] - [ Mount ] -``` - -- File dropdown lists all `backingFiles` from `GET /api/massStorage/backingFiles` -- Mode dropdown: CD-ROM (default), USB — Read only, USB — Read/write -- Mount button disabled until a file is selected -- Mount calls `PUT /api/massStorage/mount/?mode=` - -**State C — Something mounted**: - -``` -Virtual media CD-ROM − -Mounted ubuntu-24.04-server.iso -Mode CD-ROM - [ Eject ] -``` - -- Eject button opens an inline confirmation: - ``` - Eject ubuntu-24.04-server.iso? - [ Cancel ] [ Eject ] - ``` -- Confirmed eject calls `PUT /api/massStorage/eject` - -### API calls (all proxied via the dashboard backend) - -| Action | TinyPilot endpoint | Auth | -|---|---|---| -| Get file list + current mount | `GET /api/massStorage/backingFiles` | Web UI session (ADMIN) | -| Resolve filename from URL | `GET /api/massStorage/retrieveFileNameFromUrl?url=` | Web UI session (ADMIN) | -| Fetch image from URL | `PUT /api/massStorage/backingFiles//fetchFromUrl` body: `{url}` | Web UI session (ADMIN) | -| Mount | `PUT /api/massStorage/mount/?mode=` | Web UI session (ADMIN) | -| Eject | `PUT /api/massStorage/eject` | Web UI session (ADMIN) | - -The dashboard backend must add four new proxy routes under `/api/devices//`: -- `GET /media` — fetches and returns `backingFiles` + `mountMode` from the device -- `POST /media/fetch` — accepts `{url}` body, resolves filename then triggers device download -- `PUT /media/mount` — accepts `{fileName, mode}` body, calls device mount endpoint -- `PUT /media/eject` — calls device eject endpoint - -Mount mode values passed to TinyPilot: `CDROM`, `FLASH_READ_ONLY`, `FLASH_READ_WRITE`. - -### Error handling - -- If the device is unreachable, show a subtle error in the section ("Could not reach device"). -- If mount/eject fails, show the error inline below the action area. -- No global error toast — errors stay within the section. - -### Data freshness - -Virtual media state is fetched as part of the device snapshot poll. The existing snapshot service is extended to include `massStorage` data. No separate polling loop. - ---- - -## Feature 2: Screenshot flicker fix - -### Problem - -When the dashboard polls for a new screenshot (`POST /api/devices//refresh-screenshot` or the periodic refresh), the UI currently clears the `` src before the new image is available, causing a visible blank/grey state for the duration of the network round-trip. - -### Fix - -In `device-card.js`, when a new screenshot URL becomes available: - -1. Create a temporary `Image` object in JavaScript (`new Image()`). -2. Set its `src` to the new screenshot URL. -3. Only update the displayed `` element's `src` after the temporary image fires `onload`. -4. If `onerror` fires instead, keep the current screenshot and show a subtle error indicator. - -This is a pure frontend change — no backend or API changes required. - -The displayed image never goes blank during normal operation. The old screenshot stays until the new one is pixel-ready. - ---- - -## Out of scope for v0.1.2 - -- Uploading ISO images from the user's computer (planned for v0.1.3) -- Tracking fetch-from-URL download progress (TinyPilot returns no progress events; show spinner only) -- Deleting backing files from the dashboard -- Wake-on-LAN, disk usage, hostname display, or other API additions from the v0.1.2 exploration -- Per-device screenshot polling interval UI diff --git a/tests/test_api_connections.py b/tests/test_api_connections.py index 92f74f5..779cf9a 100644 --- a/tests/test_api_connections.py +++ b/tests/test_api_connections.py @@ -1,8 +1,6 @@ -from pathlib import Path from unittest.mock import patch from app import create_app -from app.auth_store import encrypt_secret from app.resolution import resolution_from_automation_state @@ -27,36 +25,28 @@ def test_create_device_persists_and_lists_device(client): payload = { 'friendly_name': 'Office Rack KVM', 'base_url': 'https://192.168.1.44', - 'automation_license_key': 'license-123', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.return_value = 'token-from-license' - create_response = client.post('/api/devices', json=payload) + create_response = client.post('/api/devices', json=payload) assert create_response.status_code == 201 - client_cls.assert_called() - _args, kwargs = client_cls.call_args - assert kwargs.get('http_basic') is None created = create_response.json['device'] assert created['friendly_name'] == payload['friendly_name'] assert created['base_url'] == payload['base_url'] + assert created['api_key_configured'] is True list_response = client.get('/api/devices') assert list_response.status_code == 200 assert len(list_response.json['devices']) == 1 listed = list_response.json['devices'][0] assert listed['friendly_name'] == payload['friendly_name'] - assert listed['automation_token_configured'] is True + assert listed['api_key_configured'] is True -def test_create_device_fetches_token_without_license_payload_when_tinypilot_succeeds(client): +def test_create_device_requires_api_key(client): payload = {'friendly_name': 'Bare URL', 'base_url': 'https://192.168.1.71'} - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.return_value = 'from-device' - response = client.post('/api/devices', json=payload) - assert response.status_code == 201 - assert response.json['device']['automation_token_refreshed'] is True - listed = client.get('/api/devices').json['devices'][0] - assert listed['automation_token_configured'] is True + response = client.post('/api/devices', json=payload) + assert response.status_code == 400 + assert 'api_key' in response.json['error'] def test_resolution_from_automation_state_accepts_string_dimensions(): @@ -74,31 +64,26 @@ def test_resolution_from_automation_state_accepts_dimension_string(): assert resolution_from_automation_state(state) == '1280x720' -def test_create_device_attempts_automation_token_refresh_when_license_present(client): +def test_create_device_rejects_blank_api_key(client): payload = { 'friendly_name': 'Licensed Device', 'base_url': 'https://192.168.1.88', - 'automation_license_key': 'license-xyz', + 'api_key': ' ', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.return_value = 'token-xyz' - response = client.post('/api/devices', json=payload) - - assert response.status_code == 201 - assert response.json['device']['automation_token_refreshed'] is True + response = client.post('/api/devices', json=payload) + assert response.status_code == 400 def test_delete_device_removes_device_and_related_rows(client): payload = { 'friendly_name': 'Delete Me', 'base_url': 'https://192.168.1.99', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=payload) + create_response = client.post('/api/devices', json=payload) device_id = create_response.json['device']['id'] list_before = client.get('/api/devices') - assert list_before.json['devices'][0]['automation_token_configured'] is False + assert list_before.json['devices'][0]['api_key_configured'] is True delete_response = client.delete(f'/api/devices/{device_id}') assert delete_response.status_code == 200 @@ -109,47 +94,25 @@ def test_delete_device_removes_device_and_related_rows(client): assert all(device['id'] != device_id for device in list_response.json['devices']) -def test_refresh_automation_endpoint(client): - create_payload = { - 'friendly_name': 'Token Device', - 'base_url': 'https://192.168.1.50', - } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = [ - RuntimeError('no TinyPilot on create'), - 'token-xyz', - ] - create_response = client.post('/api/devices', json=create_payload) - device_id = create_response.json['device']['id'] - response = client.post(f'/api/devices/{device_id}/automation/refresh-token') - - assert response.status_code == 200 - assert response.json['automation_token_refreshed'] is True - - list_response = client.get('/api/devices') - listed = next(d for d in list_response.json['devices'] if d['id'] == device_id) - assert listed['automation_token_configured'] is True - - def test_refresh_screenshot_endpoint(client): create_payload = { 'friendly_name': 'Screenshot Device', 'base_url': 'https://192.168.1.51', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=create_payload) + create_response = client.post('/api/devices', json=create_payload) device_id = create_response.json['device']['id'] with patch('app.api.TinyPilotClient') as client_cls: inst = client_cls.return_value - inst.refresh_automation_token.return_value = 'token-xyz' inst.get_screenshot.return_value = b'jpeg-bytes' response = client.post(f'/api/devices/{device_id}/refresh-screenshot') assert response.status_code == 200 assert response.json['screenshot_refreshed'] is True assert response.json['screenshot_path'].endswith(f'device-{device_id}-latest.jpg') + _args, kwargs = client_cls.call_args + assert kwargs.get('api_key') == 'test-api-key' screenshot_response = client.get(f'/api/devices/{device_id}/latest-screenshot') assert screenshot_response.status_code == 200 @@ -160,10 +123,9 @@ def test_set_screenshot_refresh_interval_endpoint(client): create_payload = { 'friendly_name': 'Interval Device', 'base_url': 'https://192.168.1.77', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=create_payload) + create_response = client.post('/api/devices', json=create_payload) device_id = create_response.json['device']['id'] response = client.post( @@ -186,54 +148,40 @@ def test_index_page_loads(client): assert b'dashboard-app.js' in response.data -def test_refresh_csrf_endpoint(client): - create_payload = { - 'friendly_name': 'Office Rack KVM', - 'base_url': 'https://192.168.1.44', - } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - 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.refresh_csrf_token.return_value = 'csrf-abc' - - csrf_response = client.post(f'/api/devices/{device_id}/device/refresh-csrf') - assert csrf_response.status_code == 200 - assert csrf_response.json['csrf_refreshed'] is True - - metrics_response = client.get(f'/api/devices/{device_id}/device/metrics') - assert metrics_response.status_code == 404 - - def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client): create_payload = { 'friendly_name': 'Office Rack KVM', 'base_url': 'https://192.168.1.44', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=create_payload) + 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.refresh_automation_token.return_value = 'tok' - tp_client.get_status.return_value = {'ok': True} - tp_client.get_auth_status.return_value = {'isAuthenticated': True, 'username': 'admin'} - tp_client.get_version.return_value = {'version': '2.6.5'} - tp_client.get_network_status.return_value = { - 'ethernet': {'isConnected': True, 'ipAddress': '192.168.1.44', 'macAddress': 'aa:bb:cc:dd:ee:ff'}, - 'wifi': {'isConnected': False, 'ipAddress': ''}, - } - tp_client.get_requires_https.return_value = {'requiresHttps': True} - tp_client.get_video_settings.return_value = {'h264Bitrate': 8000, 'streamingMode': 'MJPEG'} tp_client.get_status.return_value = { 'ok': True, 'video': {'connectedDeviceResolution': '1920x1080'}, } + tp_client.get_version.return_value = {'version': '2.6.5'} + tp_client.get_network_status.return_value = { + 'interfaces': [ + { + 'name': 'eth0', + 'isConnected': True, + 'ipAddress': '192.168.1.44', + 'macAddress': 'aa:bb:cc:dd:ee:ff', + }, + { + 'name': 'wlan0', + 'isConnected': False, + 'ipAddress': None, + 'macAddress': None, + }, + ], + } + tp_client.get_video_settings.return_value = {'h264Bitrate': 8000, 'streamingMode': 'MJPEG'} + tp_client.get_automation_state.return_value = {} snapshot_response = client.get(f'/api/devices/{device_id}/device/snapshot') @@ -241,45 +189,29 @@ def test_device_snapshot_endpoint_returns_collapsed_and_expanded_sections(client payload = snapshot_response.json assert payload['source_base_url'] == create_payload['base_url'] assert payload['collapsed']['software_version'] == '2.6.5' - assert payload['collapsed']['web_session_status'] == 'connected' + assert payload['collapsed']['api_key_status'] == 'configured' assert 'connected_device_resolution' not in payload['collapsed'] - assert payload['expanded']['network']['data']['ethernet']['ipAddress'] == '192.168.1.44' + assert payload['expanded']['network']['data']['interfaces'][0]['ipAddress'] == '192.168.1.44' assert payload['expanded']['connected_device_resolution'] == '1920x1080' + client_cls.assert_called_once() + _args, kwargs = client_cls.call_args + assert kwargs.get('api_key') == 'test-api-key' def test_device_snapshot_prefers_automation_state_resolution(client): create_payload = { 'friendly_name': 'State Resolution', 'base_url': 'https://192.168.1.55', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=create_payload) + create_response = client.post('/api/devices', json=create_payload) device_id = create_response.json['device']['id'] - with client.application.app_context(): - key_path = Path(client.application.config['SECRET_KEY_PATH']) - encrypted_token = encrypt_secret(key_path, 'token-abc') - from app.db import get_db - db = get_db() - db.execute( - """ - UPDATE device_auth - SET encrypted_automation_token = ? - WHERE device_id = ? - """, - (encrypted_token, device_id), - ) - db.commit() - with patch('app.api.TinyPilotClient') as client_cls: tp_client = client_cls.return_value - tp_client.refresh_automation_token.return_value = 'tok' tp_client.get_status.return_value = {'ok': True} - tp_client.get_auth_status.return_value = {} tp_client.get_version.return_value = {'version': '3.0.2'} - tp_client.get_network_status.return_value = {'ethernet': None, 'wifi': None} - tp_client.get_requires_https.return_value = {'requiresHttps': False} + tp_client.get_network_status.return_value = {'interfaces': []} tp_client.get_video_settings.return_value = {'h264Bitrate': 900} tp_client.get_automation_state.return_value = { 'result': {'source': {'resolution': {'width': 2560, 'height': 1440}}}, @@ -290,19 +222,16 @@ def test_device_snapshot_prefers_automation_state_resolution(client): 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' - tp_client.get_automation_state.assert_called_once() + tp_client.get_automation_state.assert_called_once_with() def test_latest_screenshot_rejects_paths_outside_screenshots_dir(client, tmp_path): create_payload = { 'friendly_name': 'Path Test', 'base_url': 'https://192.168.1.80', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError( - 'no TinyPilot in tests' - ) - create_response = client.post('/api/devices', json=create_payload) + create_response = client.post('/api/devices', json=create_payload) device_id = create_response.json['device']['id'] rogue_path = tmp_path / 'rogue.jpg' @@ -327,113 +256,20 @@ def test_latest_screenshot_rejects_paths_outside_screenshots_dir(client, tmp_pat assert response.data != b'should-not-be-served' -def test_refresh_screenshot_retries_after_401_by_refreshing_token(client): +def test_refresh_screenshot_surfaces_api_errors(client): create_payload = { 'friendly_name': 'Retry Device', 'base_url': 'https://192.168.1.66', + 'api_key': 'test-api-key', } - with patch('app.api.TinyPilotClient') as client_cls: - client_cls.return_value.refresh_automation_token.side_effect = RuntimeError('no TinyPilot in tests') - create_response = client.post('/api/devices', json=create_payload) + 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.refresh_automation_token.side_effect = ['first-token', 'second-token'] - tp_client.get_screenshot.side_effect = [ - Exception('401 Client Error: Unauthorized for url'), - b'jpeg-bytes', - ] - response = client.post(f'/api/devices/{device_id}/refresh-screenshot') - - assert response.status_code == 200 - assert response.json['screenshot_refreshed'] is True - - -def _create_media_device(client): - """Helper: create a device with no automation token for media tests.""" - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.refresh_automation_token.side_effect = RuntimeError('no device') - resp = client.post('/api/devices', json={ - 'friendly_name': 'Media Test Device', - 'base_url': 'https://192.168.1.200', - }) - return resp.json['device']['id'] - - -def test_get_media_returns_backing_files(client): - device_id = _create_media_device(client) - backing_files_response = { - 'backingFiles': [{'name': 'ubuntu.iso', 'mounted': True, 'loadedBytes': 100, 'totalBytes': 100}], - 'intermediateFiles': [], - 'mountMode': 'CDROM', - } - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.get_mass_storage.return_value = backing_files_response - response = client.get(f'/api/devices/{device_id}/media') - - assert response.status_code == 200 - assert response.json['backingFiles'][0]['name'] == 'ubuntu.iso' - assert response.json['mountMode'] == 'CDROM' - - -def test_get_media_returns_404_for_unknown_device(client): - response = client.get('/api/devices/9999/media') - assert response.status_code == 404 - - -def test_media_fetch_triggers_device_download(client): - device_id = _create_media_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.get_mass_storage_filename_from_url.return_value = 'ubuntu.iso' - cls.return_value.fetch_mass_storage_from_url.return_value = None - response = client.post( - f'/api/devices/{device_id}/media/fetch', - json={'url': 'https://example.com/ubuntu.iso'}, - ) - - assert response.status_code == 200 - assert response.json['fileName'] == 'ubuntu.iso' - - -def test_media_fetch_returns_400_without_url(client): - device_id = _create_media_device(client) - response = client.post(f'/api/devices/{device_id}/media/fetch', json={}) - assert response.status_code == 400 - - -def test_media_mount_calls_device(client): - device_id = _create_media_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.mount_mass_storage.return_value = None - response = client.put( - f'/api/devices/{device_id}/media/mount', - json={'fileName': 'ubuntu.iso', 'mode': 'CDROM'}, + client_cls.return_value.get_screenshot.side_effect = Exception( + '403 Client Error: Forbidden' ) + response = client.post(f'/api/devices/{device_id}/refresh-screenshot') - assert response.status_code == 200 - cls.return_value.mount_mass_storage.assert_called_once_with('ubuntu.iso', 'CDROM') - - -def test_media_mount_returns_400_without_required_fields(client): - device_id = _create_media_device(client) - response = client.put( - f'/api/devices/{device_id}/media/mount', - json={'fileName': 'ubuntu.iso'}, - ) - assert response.status_code == 400 - - -def test_media_eject_calls_device(client): - device_id = _create_media_device(client) - with patch('app.api.TinyPilotClient') as cls: - cls.return_value.eject_mass_storage.return_value = None - response = client.put(f'/api/devices/{device_id}/media/eject') - - assert response.status_code == 200 - cls.return_value.eject_mass_storage.assert_called_once() - - -def test_media_eject_returns_404_for_unknown_device(client): - response = client.put('/api/devices/9999/media/eject') - assert response.status_code == 404 + assert response.status_code == 502 + assert 'failed to refresh screenshot' in response.json['error'] diff --git a/tests/test_auth_refresh.py b/tests/test_auth_refresh.py deleted file mode 100644 index f8c6356..0000000 --- a/tests/test_auth_refresh.py +++ /dev/null @@ -1,20 +0,0 @@ -from unittest.mock import Mock -from unittest.mock import patch - -from app.tinypilot_client import TinyPilotClient - - -def test_refresh_automation_token_calls_auth_endpoint_without_body(): - response = Mock() - response.json.return_value = {'token': 'new-token'} - response.raise_for_status.return_value = None - - with patch('app.tinypilot_client.requests.Session.post', return_value=response) as post_mock: - client = TinyPilotClient('https://tp.local') - token = client.refresh_automation_token() - - assert token == 'new-token' - post_mock.assert_called_once_with( - 'https://tp.local/api/v1/auth', - timeout=10, - ) diff --git a/tests/test_csrf_refresh.py b/tests/test_csrf_refresh.py deleted file mode 100644 index 54f3ee3..0000000 --- a/tests/test_csrf_refresh.py +++ /dev/null @@ -1,41 +0,0 @@ -from unittest.mock import Mock -from unittest.mock import patch - -from app.tinypilot_client import TinyPilotClient - - -def test_refresh_csrf_parses_meta_tag(): - response = Mock() - response.text = '' - response.raise_for_status.return_value = None - - with patch('app.tinypilot_client.requests.Session.get', return_value=response): - client = TinyPilotClient('https://tp.local') - csrf = client.refresh_csrf_token() - - assert csrf == 'csrf-xyz' - - -def test_get_json_retries_once_after_forbidden(): - warmup_ok = Mock(status_code=200) - warmup_ok.raise_for_status.return_value = None - - api_forbidden = Mock(status_code=403) - api_forbidden.raise_for_status.return_value = None - - csrf_html = Mock(status_code=200) - csrf_html.text = '' - csrf_html.raise_for_status.return_value = None - - api_success = Mock(status_code=200) - api_success.raise_for_status.return_value = None - api_success.json.return_value = {'version': '3.0.2'} - - with patch( - 'app.tinypilot_client.requests.Session.get', - side_effect=[warmup_ok, api_forbidden, csrf_html, api_success], - ): - client = TinyPilotClient('https://tp.local') - result = client.get_version() - - assert result == {'version': '3.0.2'} diff --git a/tests/test_tinypilot_client.py b/tests/test_tinypilot_client.py index 7a6e8ce..b2988ee 100644 --- a/tests/test_tinypilot_client.py +++ b/tests/test_tinypilot_client.py @@ -1,4 +1,4 @@ -"""Unit tests for TinyPilotClient virtual media methods.""" +"""Unit tests for TinyPilotClient Bearer API helpers.""" from unittest.mock import MagicMock @@ -9,12 +9,14 @@ @pytest.fixture def client(): - return TinyPilotClient('https://device.local') + return TinyPilotClient('https://device.local', api_key='test-key') -def make_response(json_data=None, status_code=200, content=b'{}'): +def make_response(json_data=None, status_code=200, content=b'{}', ok=True): r = MagicMock() r.status_code = status_code + r.ok = ok and status_code < 400 + r.reason = 'OK' if r.ok else 'Error' r.json.return_value = json_data or {} r.content = content r.raise_for_status = MagicMock() @@ -22,96 +24,29 @@ def make_response(json_data=None, status_code=200, content=b'{}'): return r -def test_get_mass_storage_returns_backing_files_and_mount_mode(client): +def test_get_version_sends_bearer_header(client): warmup = make_response() - api_response = make_response({ - 'backingFiles': [{'name': 'ubuntu.iso', 'mounted': True, 'loadedBytes': 1000, 'totalBytes': 1000}], - 'intermediateFiles': [], - 'mountMode': 'CDROM', - }) + api_response = make_response({'version': '3.2.0'}) mock_session = MagicMock() mock_session.get.side_effect = [warmup, api_response] client.session = mock_session - result = client.get_mass_storage() + result = client.get_version() - assert result['mountMode'] == 'CDROM' - assert result['backingFiles'][0]['name'] == 'ubuntu.iso' + assert result['version'] == '3.2.0' + _args, kwargs = mock_session.get.call_args_list[1] + assert kwargs['headers']['Authorization'] == 'Bearer test-key' -def test_get_mass_storage_filename_from_url_returns_filename(client): - warmup = make_response() - api_response = make_response({'fileName': 'ubuntu-24.04.iso'}) - mock_session = MagicMock() - mock_session.get.side_effect = [warmup, api_response] - client.session = mock_session - - result = client.get_mass_storage_filename_from_url('https://example.com/ubuntu.iso') - - assert result == 'ubuntu-24.04.iso' - - -def test_fetch_mass_storage_from_url_calls_correct_endpoint(client): - warmup = make_response() - put_response = make_response() - mock_session = MagicMock() - mock_session.get.return_value = warmup - mock_session.put.return_value = put_response - client.session = mock_session - - client.fetch_mass_storage_from_url('ubuntu.iso', 'https://example.com/ubuntu.iso') - - call_args = mock_session.put.call_args - assert 'backingFiles/ubuntu.iso/fetchFromUrl' in call_args[0][0] - assert call_args[1]['json'] == {'url': 'https://example.com/ubuntu.iso'} - - -def test_mount_mass_storage_sends_correct_path_and_mode(client): - warmup = make_response() - put_response = make_response({'success': True}) - mock_session = MagicMock() - mock_session.get.return_value = warmup - mock_session.put.return_value = put_response - client.session = mock_session - - client.mount_mass_storage('ubuntu.iso', 'CDROM') - - call_args = mock_session.put.call_args - assert 'massStorage/mount/ubuntu.iso' in call_args[0][0] - assert call_args[1]['params']['mode'] == 'CDROM' - - -def test_eject_mass_storage_calls_eject_endpoint(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.eject_mass_storage() - - call_args = mock_session.put.call_args - assert 'massStorage/eject' in call_args[0][0] - - -def test_put_json_retries_with_fresh_csrf_on_401(client): - warmup = make_response() - warmup.text = '' - first_put = make_response(status_code=401, content=b'') - first_put.raise_for_status = MagicMock() # Don't raise on 401 — retry handles it. - retry_warmup = make_response() - retry_warmup.text = '' - retry_put = make_response({'ok': True}) - +def test_get_screenshot_uses_bearer_and_returns_bytes(client): + api_response = make_response(content=b'jpeg-bytes') mock_session = MagicMock() - mock_session.get.side_effect = [warmup, retry_warmup] - mock_session.put.side_effect = [first_put, retry_put] + mock_session.get.return_value = api_response client.session = mock_session - result = client._put_json('/api/massStorage/eject') + result = client.get_screenshot() - assert mock_session.put.call_count == 2 - _args, second_kwargs = mock_session.put.call_args_list[1] - assert second_kwargs['headers'].get('X-CSRFToken') == 'tok2' - assert result == {'ok': True} + assert result == b'jpeg-bytes' + _args, kwargs = mock_session.get.call_args + assert kwargs['headers']['Authorization'] == 'Bearer test-key' + assert _args[0].endswith('/api/v1/screenshot')