857 lines
31 KiB
Python
Executable File
857 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Copyright (c) 2026, Rick Lan
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
in the Software without restriction, including without limitation the rights
|
|
to use, copy, modify, merge, publish, distribute, and/or sublicense,
|
|
for non-commercial purposes only, subject to the following conditions:
|
|
|
|
- The above copyright notice and this permission notice shall be included in
|
|
all copies or substantial portions of the Software.
|
|
- Commercial use (e.g. use in a product, service, or activity intended to
|
|
generate revenue) is prohibited without explicit written permission from
|
|
the copyright holder.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
Dashy HTTP Server
|
|
|
|
Provides REST API and static file serving for the dashy web UI.
|
|
- Settings management (read/write params)
|
|
- File browser for drive logs
|
|
- Static file serving for web UI
|
|
"""
|
|
|
|
import argparse
|
|
import ast
|
|
import asyncio
|
|
import json
|
|
import operator
|
|
import os
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
from functools import wraps
|
|
from urllib.parse import quote
|
|
|
|
from aiohttp import web
|
|
|
|
from cereal import messaging
|
|
|
|
from openpilot.common.params import Params
|
|
from openpilot.system.hardware import PC, HARDWARE
|
|
from openpilot.system.ui.lib.multilang import multilang as base_multilang
|
|
from dragonpilot.settings import SETTINGS
|
|
|
|
try:
|
|
from openpilot.system.version import get_build_metadata as _get_build_metadata
|
|
except Exception:
|
|
_get_build_metadata = None
|
|
|
|
# --- Configuration ---
|
|
DEFAULT_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), '..') if PC else '/data/media/0/realdata')
|
|
WEB_DIST_PATH = os.path.join(os.path.dirname(__file__), "web", "dist")
|
|
CAR_PARAMS_CACHE_TTL = 30 # seconds
|
|
|
|
logger = logging.getLogger("dashy")
|
|
|
|
|
|
class MockParams:
|
|
"""In-memory params mock for dev mode."""
|
|
_store = {}
|
|
def get(self, key, default=None): return self._store.get(key, default)
|
|
def get_bool(self, key, default=False): return bool(self._store.get(key)) if key in self._store else default
|
|
def put(self, key, value): self._store[key] = value
|
|
def put_bool(self, key, value): self._store[key] = value
|
|
def remove(self, key): self._store.pop(key, None)
|
|
def check_key(self, key): return True
|
|
|
|
|
|
# --- Caching Layer ---
|
|
class AppCache:
|
|
"""Centralized cache for expensive operations."""
|
|
|
|
def __init__(self):
|
|
self._params = None
|
|
self._car_params = None
|
|
self._car_params_time = 0
|
|
self._context = None
|
|
self._context_time = 0
|
|
self._settings_cache = None
|
|
self._settings_cache_time = 0
|
|
|
|
@property
|
|
def params(self):
|
|
"""Get shared Params instance (or mock if unavailable)."""
|
|
if self._params is None:
|
|
try:
|
|
self._params = Params()
|
|
except Exception as e:
|
|
logger.warning(f"Params unavailable, using mock: {e}")
|
|
self._params = MockParams()
|
|
return self._params
|
|
|
|
def get_car_params(self):
|
|
"""Get cached CarParams data (brand, longitudinal control)."""
|
|
now = time.time()
|
|
if self._car_params is None or (now - self._car_params_time) > CAR_PARAMS_CACHE_TTL:
|
|
self._car_params = self._parse_car_params()
|
|
self._car_params_time = now
|
|
return self._car_params
|
|
|
|
def _parse_car_params(self):
|
|
"""Parse CarParams from Params store."""
|
|
result = {'brand': '', 'openpilot_longitudinal_control': False}
|
|
try:
|
|
# CarParams is cleared offroad/at boot; CarParamsPersistent keeps the last car's
|
|
# params so brand/longitudinal-gated settings still show when configuring parked.
|
|
car_params_bytes = self.params.get("CarParamsPersistent") or self.params.get("CarParams")
|
|
if car_params_bytes:
|
|
from cereal import car
|
|
with car.CarParams.from_bytes(car_params_bytes) as cp:
|
|
result['brand'] = cp.brand
|
|
result['openpilot_longitudinal_control'] = cp.openpilotLongitudinalControl
|
|
except Exception as e:
|
|
logger.debug(f"Could not parse CarParams: {e}")
|
|
return result
|
|
|
|
def get_settings_context(self):
|
|
"""Get context dict for settings condition evaluation."""
|
|
now = time.time()
|
|
if self._context is None or (now - self._context_time) > CAR_PARAMS_CACHE_TTL:
|
|
car_params = self.get_car_params()
|
|
self._context = {
|
|
'brand': car_params['brand'],
|
|
'openpilotLongitudinalControl': car_params['openpilot_longitudinal_control'],
|
|
'LITE': os.getenv("LITE") is not None,
|
|
'MICI': self._check_mici(),
|
|
# Upstream-mirror items gate on these.
|
|
'DASHY': True,
|
|
'IS_RELEASE': self._is_release_channel(),
|
|
}
|
|
self._context_time = now
|
|
return self._context
|
|
|
|
def _check_mici(self):
|
|
"""Check if device is MICI type."""
|
|
try:
|
|
return HARDWARE.get_device_type() == "mici"
|
|
except Exception:
|
|
return False
|
|
|
|
def _is_release_channel(self):
|
|
if _get_build_metadata is None:
|
|
return False
|
|
try:
|
|
return bool(_get_build_metadata().release_channel)
|
|
except Exception:
|
|
return False
|
|
|
|
def get_bool_safe(self, key, default=False):
|
|
"""Safely get a boolean param with default."""
|
|
try:
|
|
return self.params.get_bool(key)
|
|
except Exception:
|
|
return default
|
|
|
|
def invalidate(self):
|
|
"""Invalidate all caches."""
|
|
self._car_params = None
|
|
self._context = None
|
|
self._settings_cache = None
|
|
|
|
|
|
# --- Helper Functions ---
|
|
def api_handler(func):
|
|
"""Decorator for API handlers with consistent error handling."""
|
|
@wraps(func)
|
|
async def wrapper(request):
|
|
try:
|
|
return await func(request)
|
|
except web.HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"{func.__name__} error: {e}", exc_info=True)
|
|
return web.json_response({'error': str(e)}, status=500)
|
|
return wrapper
|
|
|
|
|
|
def get_safe_path(requested_path):
|
|
"""Ensures the requested path is within DEFAULT_DIR."""
|
|
combined_path = os.path.join(DEFAULT_DIR, requested_path.lstrip('/'))
|
|
safe_path = os.path.realpath(combined_path)
|
|
if os.path.commonpath((safe_path, DEFAULT_DIR)) == DEFAULT_DIR:
|
|
return safe_path
|
|
return None
|
|
|
|
|
|
_CMP_OPS = {
|
|
ast.Eq: operator.eq,
|
|
ast.NotEq: operator.ne,
|
|
ast.Lt: operator.lt,
|
|
ast.LtE: operator.le,
|
|
ast.Gt: operator.gt,
|
|
ast.GtE: operator.ge,
|
|
}
|
|
|
|
|
|
def _eval_node(node, context):
|
|
"""Evaluate a tightly restricted AST node against a context dict.
|
|
|
|
Only the operators that SETTINGS conditions actually use are supported:
|
|
Name lookup, literal Constants, and / or / not, and the six numeric
|
|
comparisons. No function calls, attribute access, subscripts, or
|
|
arithmetic — those would re-open the eval-sandbox escape paths.
|
|
"""
|
|
if isinstance(node, ast.Expression):
|
|
return _eval_node(node.body, context)
|
|
if isinstance(node, ast.Constant):
|
|
return node.value
|
|
if isinstance(node, ast.Name):
|
|
return context.get(node.id, False)
|
|
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
|
return not _eval_node(node.operand, context)
|
|
if isinstance(node, ast.BoolOp):
|
|
values = [_eval_node(v, context) for v in node.values]
|
|
if isinstance(node.op, ast.And):
|
|
return all(values)
|
|
if isinstance(node.op, ast.Or):
|
|
return any(values)
|
|
if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
|
|
op_type = type(node.ops[0])
|
|
if op_type in _CMP_OPS:
|
|
left = _eval_node(node.left, context)
|
|
right = _eval_node(node.comparators[0], context)
|
|
return _CMP_OPS[op_type](left, right)
|
|
raise ValueError(f"Unsupported node: {type(node).__name__}")
|
|
|
|
|
|
def eval_condition(condition, context):
|
|
"""Evaluate a SETTINGS condition expression in a sandboxed AST walker."""
|
|
if not condition:
|
|
return True
|
|
try:
|
|
tree = ast.parse(condition, mode='eval')
|
|
return bool(_eval_node(tree, context))
|
|
except Exception as e:
|
|
logger.debug(f"Condition evaluation failed: {condition}, error: {e}")
|
|
return False
|
|
|
|
|
|
def resolve_value(value):
|
|
"""Resolve callable values (lambdas) for JSON serialization."""
|
|
return value() if callable(value) else value
|
|
|
|
|
|
# Map of settings-declared param keys to their setting dict.
|
|
# Used as an allowlist for /api/settings/params/{name} read/write so
|
|
# LAN clients can only touch keys that the UI knowingly exposes.
|
|
def _build_param_setting_map():
|
|
out = {}
|
|
for section in SETTINGS:
|
|
for setting in section.get('settings', []):
|
|
key = setting.get('key')
|
|
if not key:
|
|
continue
|
|
# action_item entries use `key` as the action name, not a real
|
|
# param — skip so they don't leak into the param read/write
|
|
# allowlist.
|
|
if setting.get('type') == 'action_item':
|
|
continue
|
|
out[key] = setting
|
|
return out
|
|
|
|
|
|
_PARAM_SETTINGS = _build_param_setting_map()
|
|
|
|
# Control-tab / one-off params the UI legitimately reads or writes that
|
|
# are not part of the SETTINGS schema. Kept as an explicit allowlist so
|
|
# the broader 'unknown param' guard still blocks arbitrary writes.
|
|
_CONTROL_PARAMS = {
|
|
'dp_dev_go_off_road', # Controls tab: force-offroad toggle
|
|
'DoReboot', # Controls tab: reboot button
|
|
'ExperimentalMode', # Tesla HUD: tap set-speed circle to toggle
|
|
}
|
|
|
|
|
|
def _param_allowed(key):
|
|
return key in _PARAM_SETTINGS or key in _CONTROL_PARAMS
|
|
|
|
|
|
# --- API Endpoints ---
|
|
@api_handler
|
|
async def init_api(request):
|
|
"""Provide initial data to the client."""
|
|
cache: AppCache = request.app['cache']
|
|
return web.json_response({
|
|
'dp_dev_dashy': cache.get_bool_safe("dp_dev_dashy", True),
|
|
'isOffroad': cache.get_bool_safe("IsOffroad", False),
|
|
})
|
|
|
|
|
|
@api_handler
|
|
async def list_files_api(request):
|
|
"""List files and folders."""
|
|
path_param = request.query.get('path', '/')
|
|
safe_path = get_safe_path(path_param)
|
|
|
|
if not safe_path or not os.path.isdir(safe_path):
|
|
return web.json_response({'error': 'Invalid or Not Found Path'}, status=404)
|
|
|
|
items = []
|
|
for entry in os.listdir(safe_path):
|
|
full_path = os.path.join(safe_path, entry)
|
|
# Skip entries whose real target escapes DEFAULT_DIR (e.g., symlinks).
|
|
# get_safe_path only validates the requested directory itself; each
|
|
# child has to be re-checked to prevent listing files outside the tree.
|
|
real_full = os.path.realpath(full_path)
|
|
if os.path.commonpath((real_full, DEFAULT_DIR)) != DEFAULT_DIR:
|
|
continue
|
|
try:
|
|
stat = os.stat(full_path)
|
|
is_dir = os.path.isdir(full_path)
|
|
items.append({
|
|
'name': entry,
|
|
'is_dir': is_dir,
|
|
'mtime': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
|
|
'size': stat.st_size if not is_dir else 0
|
|
})
|
|
except FileNotFoundError:
|
|
continue
|
|
|
|
# Sort: directories first (by mtime desc), then files (by mtime desc)
|
|
dirs = sorted([i for i in items if i['is_dir']], key=lambda x: x['mtime'], reverse=True)
|
|
files = sorted([i for i in items if not i['is_dir']], key=lambda x: x['mtime'], reverse=True)
|
|
|
|
relative_path = os.path.relpath(safe_path, DEFAULT_DIR)
|
|
return web.json_response({
|
|
'path': '' if relative_path == '.' else relative_path,
|
|
'files': dirs + files
|
|
})
|
|
|
|
|
|
@api_handler
|
|
async def serve_player_api(request):
|
|
"""Serve the HLS player page."""
|
|
file_path = request.query.get('file')
|
|
if not file_path:
|
|
return web.Response(text="File parameter is required.", status=400)
|
|
if get_safe_path(file_path) is None:
|
|
return web.Response(text="Invalid file path.", status=400)
|
|
|
|
player_html_path = os.path.join(WEB_DIST_PATH, 'pages', 'player.html')
|
|
try:
|
|
with open(player_html_path, 'r') as f:
|
|
html_template = f.read()
|
|
except FileNotFoundError:
|
|
return web.Response(text="Player HTML not found.", status=500)
|
|
|
|
html = html_template.replace('{{FILE_PATH}}', quote(file_path, safe=''))
|
|
return web.Response(text=html, content_type='text/html')
|
|
|
|
|
|
@api_handler
|
|
async def serve_manifest_api(request):
|
|
"""Dynamically generate m3u8 playlist."""
|
|
file_path = request.query.get('file', '').lstrip('/')
|
|
if not file_path:
|
|
return web.Response(text="File parameter is required.", status=400)
|
|
if get_safe_path(file_path) is None:
|
|
return web.Response(text="Invalid file path.", status=400)
|
|
|
|
encoded_path = quote(file_path)
|
|
manifest = f"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:60\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXTINF:60.0,\n/media/{encoded_path}\n#EXT-X-ENDLIST\n"
|
|
return web.Response(text=manifest, content_type='application/vnd.apple.mpegurl')
|
|
|
|
|
|
@api_handler
|
|
async def get_settings_config_api(request):
|
|
"""Get the settings configuration from settings.py."""
|
|
cache: AppCache = request.app['cache']
|
|
|
|
# Return cached settings if fresh (2 second TTL)
|
|
now = time.time()
|
|
if cache._settings_cache is not None and (now - cache._settings_cache_time) < 2:
|
|
return web.json_response(cache._settings_cache)
|
|
|
|
params = cache.params
|
|
|
|
# Update language if changed
|
|
current_lang = params.get("LanguageSetting")
|
|
if current_lang:
|
|
lang_str = current_lang.decode() if isinstance(current_lang, bytes) else str(current_lang)
|
|
lang_str = lang_str.removeprefix("main_")
|
|
if lang_str != base_multilang.language and lang_str in base_multilang.languages.values():
|
|
base_multilang._language = lang_str
|
|
base_multilang.setup()
|
|
|
|
context = cache.get_settings_context()
|
|
settings_with_values = []
|
|
|
|
for section in SETTINGS:
|
|
if not eval_condition(section.get('condition'), context):
|
|
continue
|
|
|
|
section_copy = section.copy()
|
|
settings_list = []
|
|
|
|
for setting in section.get('settings', []):
|
|
if not eval_condition(setting.get('condition'), context):
|
|
continue
|
|
|
|
setting_copy = setting.copy()
|
|
key = setting['key']
|
|
|
|
# Resolve callable values
|
|
for field in ['title', 'description', 'suffix', 'special_value_text']:
|
|
if field in setting_copy:
|
|
setting_copy[field] = resolve_value(setting_copy[field])
|
|
if 'options' in setting_copy:
|
|
setting_copy['options'] = [resolve_value(opt) for opt in setting_copy['options']]
|
|
|
|
# Get current value based on type
|
|
setting_copy['current_value'] = _get_setting_value(params, setting)
|
|
settings_list.append(setting_copy)
|
|
|
|
if settings_list:
|
|
section_copy['settings'] = settings_list
|
|
settings_with_values.append(section_copy)
|
|
|
|
response_data = {'settings': settings_with_values}
|
|
cache._settings_cache = response_data
|
|
cache._settings_cache_time = now
|
|
return web.json_response(response_data)
|
|
|
|
|
|
def _get_setting_value(params, setting):
|
|
"""Get current value for a setting from Params."""
|
|
key = setting['key']
|
|
setting_type = setting['type']
|
|
default = setting.get('default', 0)
|
|
|
|
try:
|
|
if setting_type == 'toggle_item':
|
|
return params.get_bool(key)
|
|
elif setting_type == 'double_spin_button_item':
|
|
value = params.get(key)
|
|
return float(value) if value is not None else float(default)
|
|
elif setting_type in ('text_input_item', 'text_display_item'):
|
|
value = params.get(key)
|
|
if value is None:
|
|
return ''
|
|
return value.decode('utf-8', errors='replace') if isinstance(value, bytes) else str(value)
|
|
elif setting_type == 'action_item':
|
|
# Pure action buttons have no stored value; return None so the
|
|
# UI treats it as display-only.
|
|
return None
|
|
else: # spin_button_item, text_spin_button_item
|
|
value = params.get(key)
|
|
return int(value) if value is not None else int(default)
|
|
except Exception as e:
|
|
logger.warning(f"Error getting value for {key}: {e}")
|
|
if setting_type == 'toggle_item':
|
|
return False
|
|
elif setting_type == 'double_spin_button_item':
|
|
return float(default)
|
|
elif setting_type in ('text_input_item', 'text_display_item'):
|
|
return ''
|
|
elif setting_type == 'action_item':
|
|
return None
|
|
return int(default)
|
|
|
|
|
|
@api_handler
|
|
async def save_param_api(request):
|
|
"""Save a single param value.
|
|
|
|
Usage: POST /api/settings/params/{name}
|
|
Body: { "value": <value> }
|
|
"""
|
|
param_name = request.match_info.get('param_name')
|
|
if not param_name:
|
|
return web.json_response({'error': 'param_name is required'}, status=400)
|
|
if not _param_allowed(param_name):
|
|
return web.json_response({'error': 'Unknown param'}, status=403)
|
|
|
|
setting = _PARAM_SETTINGS.get(param_name)
|
|
if setting is not None and setting.get('type') == 'text_display_item':
|
|
return web.json_response({'error': 'Read-only param'}, status=403)
|
|
|
|
cache: AppCache = request.app['cache']
|
|
params = cache.params
|
|
data = await request.json()
|
|
|
|
if 'value' not in data:
|
|
return web.json_response({'error': 'value is required in body'}, status=400)
|
|
|
|
_save_param(params, param_name, data['value'])
|
|
cache.invalidate()
|
|
logger.info(f"Param saved: {param_name}={data['value']}")
|
|
|
|
return web.json_response({'status': 'success', 'key': param_name, 'value': data['value']})
|
|
|
|
|
|
def _save_param(params, key, value):
|
|
"""Save a single param value with proper type handling."""
|
|
try:
|
|
param_type = params.get_type(key)
|
|
|
|
if param_type == 1: # BOOL
|
|
params.put_bool(key, bool(value))
|
|
elif param_type == 2: # INT
|
|
params.put(key, int(value))
|
|
elif param_type == 3: # FLOAT
|
|
params.put(key, float(value))
|
|
elif isinstance(value, bool):
|
|
params.put_bool(key, value)
|
|
else:
|
|
params.put(key, str(value) if not isinstance(value, str) else value)
|
|
|
|
logger.debug(f"Saved {key}={value} (type={param_type})")
|
|
except Exception as e:
|
|
logger.error(f"Error saving param {key}={value}: {e}")
|
|
raise
|
|
|
|
|
|
def _get_param_value(params, key):
|
|
"""Get a single param value via its declared setting type, or as a
|
|
bool for control-only params that have no SETTINGS entry."""
|
|
setting = _PARAM_SETTINGS.get(key)
|
|
if setting is not None:
|
|
return _get_setting_value(params, setting)
|
|
if key in _CONTROL_PARAMS:
|
|
try:
|
|
return params.get_bool(key)
|
|
except Exception:
|
|
return False
|
|
return None
|
|
|
|
|
|
@api_handler
|
|
async def get_param_api(request):
|
|
"""Get a single param value."""
|
|
param_name = request.match_info.get('param_name')
|
|
if not param_name:
|
|
return web.json_response({'error': 'param_name is required'}, status=400)
|
|
if not _param_allowed(param_name):
|
|
return web.json_response({'error': 'Unknown param'}, status=403)
|
|
|
|
cache: AppCache = request.app['cache']
|
|
try:
|
|
value = _get_param_value(cache.params, param_name)
|
|
except Exception:
|
|
value = None
|
|
|
|
return web.json_response({'key': param_name, 'value': value})
|
|
|
|
|
|
# --- Action endpoints ---
|
|
# Named side-effectful operations declared by settings items via the
|
|
# `action` field (text_input_item / action_item). Each handler receives
|
|
# the parsed JSON body and the AppCache; it returns a dict that is
|
|
# serialized as the JSON response. Errors should be raised — the wrapper
|
|
# converts them to 502/500 responses.
|
|
SSH_KEY_FETCH_TIMEOUT_S = 10
|
|
SSH_KEY_MAX_BYTES = 16 * 1024 # plenty for any realistic ~/.ssh/authorized_keys
|
|
GITHUB_USERNAME_MAX_LEN = 39 # github's own limit
|
|
|
|
|
|
def _validate_github_username(username):
|
|
"""GitHub username: 1-39 chars, alnum or single hyphen, no leading/trailing hyphen."""
|
|
if not username or len(username) > GITHUB_USERNAME_MAX_LEN:
|
|
return False
|
|
if username.startswith('-') or username.endswith('-'):
|
|
return False
|
|
if '--' in username:
|
|
return False
|
|
return all(c.isalnum() or c == '-' for c in username)
|
|
|
|
|
|
async def _fetch_github_ssh_keys(username):
|
|
"""Fetch https://github.com/{username}.keys. Returns the body text on
|
|
HTTP 200; raises web.HTTPException with an upstream-derived status on
|
|
failure so the action endpoint surfaces the real reason."""
|
|
import aiohttp
|
|
url = f"https://github.com/{quote(username, safe='')}.keys"
|
|
timeout = aiohttp.ClientTimeout(total=SSH_KEY_FETCH_TIMEOUT_S)
|
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
async with session.get(url) as resp:
|
|
if resp.status == 404:
|
|
raise web.HTTPNotFound(reason=f"GitHub user '{username}' not found")
|
|
if resp.status != 200:
|
|
raise web.HTTPBadGateway(reason=f"github.com returned HTTP {resp.status}")
|
|
body = await resp.content.read(SSH_KEY_MAX_BYTES + 1)
|
|
if len(body) > SSH_KEY_MAX_BYTES:
|
|
raise web.HTTPBadGateway(reason="SSH key response too large")
|
|
return body.decode('utf-8', errors='replace')
|
|
|
|
|
|
async def _action_ssh_key_set(request, payload, cache):
|
|
"""Fetch the user's GitHub SSH keys and write both params atomically.
|
|
Body: { "value": "<github-username>" }. On success the device's
|
|
sshd_config drop-in is updated by openpilot's own SSH manager."""
|
|
username = (payload.get('value') or '').strip()
|
|
if not _validate_github_username(username):
|
|
raise web.HTTPBadRequest(reason="Invalid GitHub username")
|
|
|
|
keys_body = await _fetch_github_ssh_keys(username)
|
|
if not keys_body.strip():
|
|
raise web.HTTPBadRequest(reason=f"GitHub user '{username}' has no public SSH keys")
|
|
|
|
params = cache.params
|
|
# Write keys first; only commit the username if keys were stored
|
|
# successfully — keeps the two params consistent.
|
|
params.put('GithubSshKeys', keys_body)
|
|
params.put('GithubUsername', username)
|
|
cache.invalidate()
|
|
logger.info(f"SSH keys set from github.com/{username} ({len(keys_body)} bytes)")
|
|
return {'status': 'ok', 'username': username, 'key_bytes': len(keys_body)}
|
|
|
|
|
|
async def _action_ssh_key_clear(request, payload, cache):
|
|
params = cache.params
|
|
params.put('GithubSshKeys', '')
|
|
params.put('GithubUsername', '')
|
|
cache.invalidate()
|
|
logger.info("SSH keys cleared")
|
|
return {'status': 'ok'}
|
|
|
|
|
|
_ACTION_HANDLERS = {
|
|
'ssh_key_set': _action_ssh_key_set,
|
|
'ssh_key_clear': _action_ssh_key_clear,
|
|
}
|
|
|
|
|
|
@api_handler
|
|
async def run_action_api(request):
|
|
"""Dispatch /api/action/{name} → registered handler."""
|
|
name = request.match_info.get('name', '')
|
|
handler = _ACTION_HANDLERS.get(name)
|
|
if handler is None:
|
|
return web.json_response({'error': f'Unknown action: {name}'}, status=404)
|
|
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
payload = {}
|
|
|
|
cache: AppCache = request.app['cache']
|
|
result = await handler(request, payload, cache)
|
|
return web.json_response(result)
|
|
|
|
|
|
@api_handler
|
|
async def get_model_list_api(request):
|
|
"""Get the model list and current selection."""
|
|
cache: AppCache = request.app['cache']
|
|
params = cache.params
|
|
|
|
# Get model list. JSON-typed params come back already-parsed in
|
|
# newer dragonpilot; older builds returned bytes/str — handle both.
|
|
model_list = {}
|
|
try:
|
|
raw = params.get("dp_dev_model_list")
|
|
if raw:
|
|
if isinstance(raw, (bytes, str)):
|
|
model_list = json.loads(raw)
|
|
elif isinstance(raw, dict):
|
|
model_list = raw
|
|
except Exception as e:
|
|
logger.debug(f"Could not parse dp_dev_model_list: {e}")
|
|
|
|
# Get current selection
|
|
selected_model = ""
|
|
try:
|
|
selected_raw = params.get("dp_dev_model_selected")
|
|
if selected_raw:
|
|
selected_model = selected_raw.decode('utf-8') if isinstance(selected_raw, bytes) else str(selected_raw)
|
|
except Exception as e:
|
|
logger.debug(f"Could not get dp_dev_model_selected: {e}")
|
|
|
|
return web.json_response({
|
|
'model_list': model_list,
|
|
'selected_model': selected_model
|
|
})
|
|
|
|
|
|
@api_handler
|
|
async def save_model_selection_api(request):
|
|
"""Save the selected model."""
|
|
cache: AppCache = request.app['cache']
|
|
params = cache.params
|
|
data = await request.json()
|
|
|
|
selected_model = data.get('selected_model', '')
|
|
|
|
if not selected_model or selected_model == "[AUTO]":
|
|
params.put("dp_dev_model_selected", "")
|
|
logger.info("Model selection cleared (AUTO mode)")
|
|
else:
|
|
params.put("dp_dev_model_selected", selected_model)
|
|
logger.info(f"Model selection saved: {selected_model}")
|
|
|
|
return web.json_response({'status': 'success'})
|
|
|
|
|
|
# --- WebSocket endpoint for data streaming ---
|
|
# One shared publisher task polls the dashyState SubMaster and fans out
|
|
# to every connected client. The previous per-connection design ran
|
|
# blocking ZMQ I/O on the event loop, which starved every other request
|
|
# under multi-client load.
|
|
async def _publisher_loop(app):
|
|
# IMPORTANT: ZMQ sockets are thread-affined. Construct the SubMaster on
|
|
# the asyncio main thread and call update() on the same thread — using
|
|
# asyncio.to_thread bounces between worker threads and silently breaks
|
|
# the receive. The 0-timeout update is cheap enough on the event loop;
|
|
# the per-client send is what we actually need to be async for.
|
|
try:
|
|
sm = messaging.SubMaster(['dashyState'])
|
|
except Exception as e:
|
|
logger.warning(f"Publisher disabled (SubMaster init failed): {e}")
|
|
return
|
|
|
|
logger.info("dashyState publisher loop started")
|
|
|
|
while True:
|
|
try:
|
|
sm.update(0)
|
|
if sm.updated['dashyState']:
|
|
json_data = sm['dashyState'].json
|
|
if isinstance(json_data, bytes):
|
|
json_data = json_data.decode('utf-8')
|
|
|
|
clients = list(app['ws_clients'])
|
|
for ws in clients:
|
|
if ws.closed:
|
|
app['ws_clients'].discard(ws)
|
|
continue
|
|
try:
|
|
await ws.send_str(json_data)
|
|
except Exception as e:
|
|
logger.debug(f"WebSocket send failed, dropping client: {e}")
|
|
app['ws_clients'].discard(ws)
|
|
|
|
await asyncio.sleep(0.01)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
# Don't let a transient error tear down the loop silently.
|
|
logger.exception(f"Publisher loop error: {e}")
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
async def websocket_handler(request):
|
|
"""WebSocket endpoint for data-only connections - streams dashyState directly."""
|
|
ws = web.WebSocketResponse()
|
|
await ws.prepare(request)
|
|
|
|
logger.info("WebSocket client connected")
|
|
request.app['ws_clients'].add(ws)
|
|
|
|
try:
|
|
# Wait until the client disconnects; no inbound traffic expected.
|
|
async for _ in ws:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"WebSocket error: {e}")
|
|
finally:
|
|
request.app['ws_clients'].discard(ws)
|
|
logger.info("WebSocket client disconnected")
|
|
|
|
return ws
|
|
|
|
|
|
# --- No-cache middleware for web assets ---
|
|
# Dashy is a same-origin LAN app; no CORS headers are emitted so that
|
|
# browsers will block cross-origin JS from mutating settings via the
|
|
# JSON endpoints (the preflight will fail for non-simple requests).
|
|
@web.middleware
|
|
async def no_cache_middleware(request, handler):
|
|
response = await handler(request)
|
|
|
|
path = request.path.lower()
|
|
if path.endswith(('.html', '.js', '.css')) or path == '/':
|
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
response.headers['Pragma'] = 'no-cache'
|
|
response.headers['Expires'] = '0'
|
|
|
|
return response
|
|
|
|
|
|
# --- Application Setup ---
|
|
async def on_startup(app):
|
|
"""Initialize app-level resources."""
|
|
app['cache'] = AppCache()
|
|
app['ws_clients'] = set()
|
|
app['publisher_task'] = asyncio.create_task(_publisher_loop(app))
|
|
logger.info("Dashy server started")
|
|
|
|
|
|
async def on_cleanup(app):
|
|
"""Cleanup app-level resources."""
|
|
task = app.get('publisher_task')
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
logger.info("Dashy server stopped")
|
|
|
|
|
|
def setup_aiohttp_app(host: str, port: int, debug: bool):
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if debug else logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
app = web.Application(middlewares=[no_cache_middleware])
|
|
|
|
# API routes
|
|
app.router.add_get("/api/init", init_api)
|
|
app.router.add_get("/api/files", list_files_api)
|
|
app.router.add_get("/api/play", serve_player_api)
|
|
app.router.add_get("/api/manifest.m3u8", serve_manifest_api)
|
|
app.router.add_get("/api/settings", get_settings_config_api)
|
|
app.router.add_get("/api/settings/params/{param_name}", get_param_api)
|
|
app.router.add_post("/api/settings/params/{param_name}", save_param_api)
|
|
app.router.add_get("/api/models", get_model_list_api)
|
|
app.router.add_post("/api/models/select", save_model_selection_api)
|
|
app.router.add_post("/api/action/{name}", run_action_api)
|
|
app.router.add_get("/api/ws", websocket_handler) # WebSocket for data streaming
|
|
|
|
# Static files
|
|
app.router.add_static('/media', path=DEFAULT_DIR, name='media', show_index=False, follow_symlinks=False)
|
|
app.router.add_static('/download', path=DEFAULT_DIR, name='download', show_index=False, follow_symlinks=False)
|
|
app.router.add_get("/", lambda r: web.FileResponse(os.path.join(WEB_DIST_PATH, "index.html")))
|
|
app.router.add_static("/", path=WEB_DIST_PATH)
|
|
|
|
app.on_startup.append(on_startup)
|
|
app.on_cleanup.append(on_cleanup)
|
|
|
|
return app
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Dashy Server")
|
|
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on")
|
|
parser.add_argument("--port", type=int, default=5088, help="Port to listen on")
|
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
|
args = parser.parse_args()
|
|
|
|
app = setup_aiohttp_app(args.host, args.port, args.debug)
|
|
web.run_app(app, host=args.host, port=args.port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|