from __future__ import annotations import copy import json import re import threading from collections.abc import Callable from pathlib import Path from urllib.parse import urlsplit from .council import CLASSES from .errors import ToolError from .fields import FIELDS, NAME_LIMIT, expect from .records import read_json, write_atomic THEMES = ("sky", "light", "dark", "custom") TONES = ("mayday", "review", "direct", "radio") COLOR = re.compile(r"#[0-9a-f]{6}") CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]") MIDNIGHT = "24:00" STREAMS_LIMIT = 20 STREAM_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{1,2040}") HOSTNAME = re.compile(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*") DEFAULT_PORTS = {"http": 80, "https": 443} SETTINGS = { "latitude": 0.0, "longitude": 0.0, "theme": "sky", "colors": {"top": "#0b1020", "bottom": "#2b3a67", "accent": "#8fadff"}, "music": {"on": False, "volume": 0.4}, "sounds": { "mayday": {"on": True, "volume": 0.9}, "review": {"on": True, "volume": 0.6}, "direct": {"on": True, "volume": 0.5}, "radio": {"on": False, "volume": 0.25}, }, "notify": True, "streams": [], "speech": {"on": False, "classes": ["security", "pan-pan", "mayday"], "muted": []}, "space": { "graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0}, "sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0}, }, } SPACE_AXES = ("none", "time", "spawn") SPACE_LIMITS = { "graph": {"yaw": (-180.0, 180.0), "pitch": (-90.0, 90.0), "zoom": (0.25, 4.0), "pan_x": (-2000.0, 2000.0), "pan_y": (-2000.0, 2000.0)}, "sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)}, } MUTED_LIMIT = 200 def section(value: object, name: str, keys: tuple[str, ...]) -> dict: if not isinstance(value, dict): raise ToolError(f"{name} must be an object") try: expect(value, keys) except ToolError as error: raise ToolError(f"{name}: {error}") from None return value def number_in(value: object, name: str, low: float, high: float) -> float: if isinstance(value, bool) or not isinstance(value, int | float) or not low <= value <= high: raise ToolError(f"{name} must be a number from {low} to {high}") return float(value) def flag(value: object, name: str) -> bool: if not isinstance(value, bool): raise ToolError(f"{name} must be true or false") return value def color(value: object, name: str) -> str: if not isinstance(value, str) or not COLOR.fullmatch(value): raise ToolError(f"{name} must be a colour like #1a2b3c") return value def stream_origin(url: object) -> str: if not isinstance(url, str) or not STREAM_URL.fullmatch(url): raise ToolError("a stream url must be an http or https URL without spaces or quotes") parts = urlsplit(url) try: port = parts.port except ValueError: raise ToolError("a stream url needs a valid port") from None host = parts.hostname or "" if parts.username is not None or parts.password is not None or not HOSTNAME.fullmatch(host): raise ToolError("a stream url needs a plain host name and no user info") return f"{parts.scheme}://{host}" if port in (None, DEFAULT_PORTS[parts.scheme]) else f"{parts.scheme}://{host}:{port}" def clock_minutes(value: object, name: str, end: bool) -> int: if end and value == MIDNIGHT: return 24 * 60 if not isinstance(value, str) or not CLOCK.fullmatch(value): raise ToolError(f"{name} must be a time like 07:30") hours, minutes = value.split(":") return int(hours) * 60 + int(minutes) def stream_entry(raw: object, index: int) -> dict: name = f"streams[{index}]" entry = section(raw, name, ("name", "url", "from", "to", "days", "on")) label, days = entry["name"], entry["days"] if not isinstance(label, str) or not FIELDS["label"][0].fullmatch(label): raise ToolError(f"{name}.name must be one line of 1 to {NAME_LIMIT} characters") stream_origin(entry["url"]) if clock_minutes(entry["from"], f"{name}.from", False) == clock_minutes(entry["to"], f"{name}.to", True): raise ToolError(f"{name} must end at another time than it starts") if not isinstance(days, list) or not days or len(set(map(str, days))) != len(days) or any(isinstance(day, bool) or not isinstance(day, int) or not 0 <= day <= 6 for day in days): raise ToolError(f"{name}.days must list distinct weekdays from 0 (Monday) to 6 (Sunday)") return {"name": label, "url": entry["url"], "from": entry["from"], "to": entry["to"], "days": sorted(days), "on": flag(entry["on"], f"{name}.on")} def tone_setting(raw: object, kind_name: str) -> dict: entry = section(raw, f"sounds.{kind_name}", ("on", "volume")) return {"on": flag(entry["on"], f"sounds.{kind_name}.on"), "volume": number_in(entry["volume"], f"sounds.{kind_name}.volume", 0.0, 1.0)} def distinct_texts(value: object, valid: Callable[[str], bool], limit: int) -> bool: if not isinstance(value, list) or len(value) > limit or not all(isinstance(item, str) and valid(item) for item in value): return False return len(set(value)) == len(value) def speech_setting(raw: object) -> dict: entry = section(raw, "speech", ("on", "classes", "muted")) classes, muted = entry["classes"], entry["muted"] if not distinct_texts(classes, lambda name: name in CLASSES, len(CLASSES)): raise ToolError(f"speech.classes must list distinct classes of {', '.join(CLASSES)}") if "mayday" not in classes: raise ToolError("speech.classes must include mayday: a MAYDAY is never quieter than anything else") if not distinct_texts(muted, FIELDS["agent"][0].fullmatch, MUTED_LIMIT): raise ToolError(f"speech.muted must list at most {MUTED_LIMIT} distinct agent keys, each one line of 1 to 200 characters") return {"on": flag(entry["on"], "speech.on"), "classes": [name for name in CLASSES if name in classes], "muted": sorted(muted)} def space_setting(raw: object) -> dict: entry = section(raw, "space", tuple(SETTINGS["space"])) graph = section(entry["graph"], "space.graph", tuple(SETTINGS["space"]["graph"])) sky = section(entry["sky"], "space.sky", tuple(SETTINGS["space"]["sky"])) if not isinstance(graph["axis"], str) or graph["axis"] not in SPACE_AXES: raise ToolError(f"space.graph.axis must be one of {', '.join(SPACE_AXES)}") views = {"graph": graph, "sky": sky} numbers = {view: {key: number_in(views[view][key], f"space.{view}.{key}", low, high) for key, (low, high) in limits.items()} for view, limits in SPACE_LIMITS.items()} return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]} def valid_settings(raw: object) -> dict: data = section(raw, "settings", tuple(SETTINGS)) colors = section(data["colors"], "colors", tuple(SETTINGS["colors"])) music = section(data["music"], "music", ("on", "volume")) sounds = section(data["sounds"], "sounds", TONES) if not isinstance(data["theme"], str) or data["theme"] not in THEMES: raise ToolError(f"theme must be one of {', '.join(THEMES)}") if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT: raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries") return { "latitude": number_in(data["latitude"], "latitude", -90.0, 90.0), "longitude": number_in(data["longitude"], "longitude", -180.0, 180.0), "theme": data["theme"], "colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]}, "music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)}, "sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES}, "notify": flag(data["notify"], "notify"), "streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])], "speech": speech_setting(data["speech"]), "space": space_setting(data["space"]), } def media_origins(settings: dict) -> list[str]: return sorted({stream_origin(stream["url"]) for stream in settings["streams"]}) class Settings: def __init__(self, file: Path) -> None: self.file = file self.lock = threading.Lock() stored = read_json(file) try: self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored) except ToolError: self.current = valid_settings(SETTINGS) def get(self) -> dict: with self.lock: return copy.deepcopy(self.current) def put(self, raw: object) -> dict: settings = valid_settings(raw) with self.lock: write_atomic(self.file, json.dumps(settings, indent=1, sort_keys=True).encode("utf-8")) self.current = settings return copy.deepcopy(settings)