mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 12:33:51 +08:00
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
"""Saved, opt-in local Wi-Fi access. No network effects in the HTTP process."""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import secrets
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
|
||
INTERFACE = 'galaxy0'
|
||
ADDRESS = '172.31.254.1'
|
||
NETWORK = '172.31.254.0/24'
|
||
STATUS_PATH = Path('/tmp/starpilot-galaxy-hotspot-status.json')
|
||
DEFAULT = {'enabled': False, 'ssid': 'Galaxy', 'password': ''}
|
||
|
||
|
||
def device_dongle_id():
|
||
from openpilot.common.params import Params
|
||
from openpilot.starpilot.connect.provider import galaxy_device_id
|
||
return galaxy_device_id(Params())
|
||
|
||
|
||
def hotspot_name(dongle_id):
|
||
if type(dongle_id) is not str or not re.fullmatch(r'[a-fA-F0-9]{16}', dongle_id):
|
||
raise ValueError('A registered comma dongle ID is required for the hotspot.')
|
||
return 'TheGalaxy-' + dongle_id[-4:].lower()
|
||
|
||
|
||
def validate_config(value):
|
||
if (type(value) is not dict or set(value) != set(DEFAULT) or type(value['enabled']) is not bool or
|
||
type(value['ssid']) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9 _-]{0,31}', value['ssid']) or
|
||
type(value['password']) is not str or not re.fullmatch(r'[\x21-\x7e]{12,63}', value['password'])):
|
||
raise ValueError('Use a 1–32 character network name and a 12–63 character password without spaces.')
|
||
return dict(value)
|
||
|
||
|
||
def atomic_json(path, value, mode=0o600):
|
||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||
try:
|
||
os.fchmod(fd, mode)
|
||
with os.fdopen(fd, 'w') as handle:
|
||
json.dump(value, handle)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(temporary, path)
|
||
finally:
|
||
if os.path.exists(temporary):
|
||
os.unlink(temporary)
|
||
|
||
|
||
def bounded_json(path):
|
||
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
|
||
with os.fdopen(fd, 'r') as handle:
|
||
raw = handle.read(4097)
|
||
if len(raw) > 4096:
|
||
raise ValueError('Hotspot document is too large')
|
||
return json.loads(raw)
|
||
|
||
|
||
class HotspotSettings:
|
||
def __init__(self, root=None, status_path=STATUS_PATH, clock=time.monotonic, dongle_id=device_dongle_id):
|
||
if root is None:
|
||
from openpilot.starpilot.storage import galaxy_storage_root
|
||
root = galaxy_storage_root()
|
||
self.path = Path(root) / 'hotspot.json'
|
||
self.status_path, self.clock = status_path, clock
|
||
self.dongle_id = dongle_id
|
||
self.lock = threading.Lock()
|
||
|
||
def read(self):
|
||
# Resolve the physical comma identity, including when another cloud provider
|
||
# is active. Older configurable SSIDs are normalized without losing secrets.
|
||
ssid = hotspot_name(self.dongle_id())
|
||
try:
|
||
return {**validate_config(bounded_json(self.path)), 'ssid': ssid}
|
||
except FileNotFoundError:
|
||
return {**DEFAULT, 'ssid': ssid}
|
||
|
||
@staticmethod
|
||
def revision(config):
|
||
return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
|
||
|
||
def snapshot(self, parked):
|
||
config = self.read()
|
||
runtime = {'active': False, 'state': 'unavailable', 'reason': 'Hotspot service has not reported its status.'}
|
||
try:
|
||
saved = bounded_json(self.status_path)
|
||
if (type(saved) is dict and type(saved.get('updated')) in (int, float) and
|
||
0 <= self.clock() - saved['updated'] < 10 and
|
||
saved.get('revision') == self.revision(config)):
|
||
runtime = {key: saved[key] for key in ('active', 'state', 'reason', 'channel') if key in saved}
|
||
except (OSError, ValueError, TypeError):
|
||
pass
|
||
return {'version': 1, 'config': config, 'revision': self.revision(config), 'editable': bool(parked),
|
||
'url': f'http://{ADDRESS}:8082/', **runtime}
|
||
|
||
def save(self, payload, permitted):
|
||
if type(payload) is not dict or set(payload) != {'revision', 'config'}:
|
||
raise ValueError('Invalid hotspot settings')
|
||
with self.lock:
|
||
current = self.read()
|
||
if payload['revision'] != self.revision(current):
|
||
raise FileExistsError('Hotspot settings changed; refresh and try again.')
|
||
candidate = payload['config']
|
||
if type(candidate) is dict and candidate.get('password') == '':
|
||
candidate = {**candidate, 'password': current['password'] or secrets.token_hex(12)}
|
||
candidate = validate_config(candidate)
|
||
if candidate['ssid'] != current['ssid']:
|
||
raise ValueError('The hotspot network name is fixed by the comma dongle ID.')
|
||
if not permitted():
|
||
raise PermissionError('Park and sign in before changing the hotspot.')
|
||
atomic_json(self.path, candidate)
|
||
return self.snapshot(permitted())
|