|
|
|
|
@ -1,12 +1,13 @@
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import copy
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import math
|
|
|
|
|
import mimetypes
|
|
|
|
|
import statistics
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
@ -22,13 +23,16 @@ from triangulation import (
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Point3D = Tuple[float, float, float]
|
|
|
|
|
MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST.
|
|
|
|
|
HZ_IN_MHZ = 1_000_000.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_json(path: str) -> Dict[str, object]:
|
|
|
|
|
file_path = Path(path)
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
raise SystemExit(f"Config file not found: {path}")
|
|
|
|
|
with file_path.open("r", encoding="utf-8") as fh:
|
|
|
|
|
# Accept optional UTF-8 BOM to avoid startup failures with edited JSON files.
|
|
|
|
|
with file_path.open("r", encoding="utf-8-sig") as fh:
|
|
|
|
|
data = json.load(fh)
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
raise SystemExit("Config root must be a JSON object.")
|
|
|
|
|
@ -80,6 +84,113 @@ def _float_from_measurement(
|
|
|
|
|
raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _float_with_key_from_measurement(
|
|
|
|
|
item: Dict[str, object],
|
|
|
|
|
keys: Sequence[str],
|
|
|
|
|
field_name: str,
|
|
|
|
|
source_label: str,
|
|
|
|
|
row_index: int,
|
|
|
|
|
) -> Tuple[str, float]:
|
|
|
|
|
for key in keys:
|
|
|
|
|
if key in item:
|
|
|
|
|
value = _float_from_measurement(
|
|
|
|
|
item=item,
|
|
|
|
|
keys=(key,),
|
|
|
|
|
field_name=field_name,
|
|
|
|
|
source_label=source_label,
|
|
|
|
|
row_index=row_index,
|
|
|
|
|
)
|
|
|
|
|
return key, value
|
|
|
|
|
raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_frequency_hz_from_measurement(
|
|
|
|
|
row: Dict[str, object],
|
|
|
|
|
source_label: str,
|
|
|
|
|
row_index: int,
|
|
|
|
|
) -> float:
|
|
|
|
|
key, value = _float_with_key_from_measurement(
|
|
|
|
|
row,
|
|
|
|
|
keys=(
|
|
|
|
|
"frequency_hz",
|
|
|
|
|
"freq_hz",
|
|
|
|
|
"frequency_mhz",
|
|
|
|
|
"freq_mhz",
|
|
|
|
|
"frequency",
|
|
|
|
|
"freq",
|
|
|
|
|
),
|
|
|
|
|
field_name="frequency",
|
|
|
|
|
source_label=source_label,
|
|
|
|
|
row_index=row_index,
|
|
|
|
|
)
|
|
|
|
|
if key in ("frequency_hz", "freq_hz"):
|
|
|
|
|
return value
|
|
|
|
|
if key in ("frequency_mhz", "freq_mhz"):
|
|
|
|
|
return value * HZ_IN_MHZ
|
|
|
|
|
# For generic fields "frequency"/"freq" default to MHz in this project.
|
|
|
|
|
# Keep backward compatibility: very large values are treated as Hz.
|
|
|
|
|
if value >= 10_000_000.0:
|
|
|
|
|
return value
|
|
|
|
|
return value * HZ_IN_MHZ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_receiver_input_filter(
|
|
|
|
|
receiver_obj: Dict[str, object], receiver_id: str
|
|
|
|
|
) -> Dict[str, object]:
|
|
|
|
|
filter_obj = receiver_obj.get("input_filter", {})
|
|
|
|
|
if filter_obj is None:
|
|
|
|
|
filter_obj = {}
|
|
|
|
|
if not isinstance(filter_obj, dict):
|
|
|
|
|
raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.")
|
|
|
|
|
|
|
|
|
|
min_freq_mhz_raw = filter_obj.get("min_frequency_mhz")
|
|
|
|
|
max_freq_mhz_raw = filter_obj.get("max_frequency_mhz")
|
|
|
|
|
if min_freq_mhz_raw is None and "min_frequency_hz" in filter_obj:
|
|
|
|
|
min_freq_mhz_raw = float(filter_obj["min_frequency_hz"]) / HZ_IN_MHZ
|
|
|
|
|
if max_freq_mhz_raw is None and "max_frequency_hz" in filter_obj:
|
|
|
|
|
max_freq_mhz_raw = float(filter_obj["max_frequency_hz"]) / HZ_IN_MHZ
|
|
|
|
|
|
|
|
|
|
parsed = {
|
|
|
|
|
"enabled": bool(filter_obj.get("enabled", False)),
|
|
|
|
|
"min_frequency_mhz": float(min_freq_mhz_raw if min_freq_mhz_raw is not None else 0.0),
|
|
|
|
|
"max_frequency_mhz": float(max_freq_mhz_raw if max_freq_mhz_raw is not None else 1_000_000_000.0),
|
|
|
|
|
"min_rssi_dbm": float(filter_obj.get("min_rssi_dbm", -200.0)),
|
|
|
|
|
"max_rssi_dbm": float(filter_obj.get("max_rssi_dbm", 50.0)),
|
|
|
|
|
}
|
|
|
|
|
if parsed["max_frequency_mhz"] < parsed["min_frequency_mhz"]:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"receiver '{receiver_id}': input_filter.max_frequency_mhz must be >= min_frequency_mhz."
|
|
|
|
|
)
|
|
|
|
|
if parsed["max_rssi_dbm"] < parsed["min_rssi_dbm"]:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"receiver '{receiver_id}': input_filter.max_rssi_dbm must be >= min_rssi_dbm."
|
|
|
|
|
)
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_receiver_input_filter(
|
|
|
|
|
measurements: Sequence[Tuple[float, float]],
|
|
|
|
|
receiver_filter: Dict[str, object],
|
|
|
|
|
) -> List[Tuple[float, float]]:
|
|
|
|
|
if not bool(receiver_filter.get("enabled", False)):
|
|
|
|
|
return list(measurements)
|
|
|
|
|
|
|
|
|
|
min_frequency_mhz = float(receiver_filter["min_frequency_mhz"])
|
|
|
|
|
max_frequency_mhz = float(receiver_filter["max_frequency_mhz"])
|
|
|
|
|
min_rssi_dbm = float(receiver_filter["min_rssi_dbm"])
|
|
|
|
|
max_rssi_dbm = float(receiver_filter["max_rssi_dbm"])
|
|
|
|
|
|
|
|
|
|
filtered = []
|
|
|
|
|
for frequency_hz, rssi_dbm in measurements:
|
|
|
|
|
frequency_mhz = frequency_hz / HZ_IN_MHZ
|
|
|
|
|
if not (min_frequency_mhz <= frequency_mhz <= max_frequency_mhz):
|
|
|
|
|
continue
|
|
|
|
|
if not (min_rssi_dbm <= rssi_dbm <= max_rssi_dbm):
|
|
|
|
|
continue
|
|
|
|
|
filtered.append((frequency_hz, rssi_dbm))
|
|
|
|
|
return filtered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_source_payload(
|
|
|
|
|
payload: object,
|
|
|
|
|
source_label: str,
|
|
|
|
|
@ -110,10 +221,8 @@ def parse_source_payload(
|
|
|
|
|
for row_index, row in enumerate(raw_items, start=1):
|
|
|
|
|
if not isinstance(row, dict):
|
|
|
|
|
raise ValueError(f"{source_label}: row #{row_index} must be an object.")
|
|
|
|
|
frequency_hz = _float_from_measurement(
|
|
|
|
|
row,
|
|
|
|
|
keys=("frequency_hz", "freq_hz", "frequency", "freq"),
|
|
|
|
|
field_name="frequency_hz",
|
|
|
|
|
frequency_hz = _parse_frequency_hz_from_measurement(
|
|
|
|
|
row=row,
|
|
|
|
|
source_label=source_label,
|
|
|
|
|
row_index=row_index,
|
|
|
|
|
)
|
|
|
|
|
@ -210,6 +319,7 @@ class AutoService:
|
|
|
|
|
raise ValueError("solver.z_preference must be 'positive' or 'negative'.")
|
|
|
|
|
|
|
|
|
|
self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0))
|
|
|
|
|
self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip()
|
|
|
|
|
output_obj = runtime_obj.get("output_server", {})
|
|
|
|
|
if output_obj is None:
|
|
|
|
|
output_obj = {}
|
|
|
|
|
@ -221,8 +331,34 @@ class AutoService:
|
|
|
|
|
self.output_port = int(output_obj.get("port", 8080))
|
|
|
|
|
self.output_path = str(output_obj.get("path", "/triangulation"))
|
|
|
|
|
self.output_timeout_s = float(output_obj.get("timeout_s", 3.0))
|
|
|
|
|
self.output_frequency_filter_enabled = bool(
|
|
|
|
|
output_obj.get("frequency_filter_enabled", False)
|
|
|
|
|
)
|
|
|
|
|
min_frequency_mhz_raw = output_obj.get("min_frequency_mhz")
|
|
|
|
|
max_frequency_mhz_raw = output_obj.get("max_frequency_mhz")
|
|
|
|
|
if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj:
|
|
|
|
|
min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ
|
|
|
|
|
if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj:
|
|
|
|
|
max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ
|
|
|
|
|
self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0)
|
|
|
|
|
self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0)
|
|
|
|
|
self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ
|
|
|
|
|
self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ
|
|
|
|
|
if self.output_enabled and not self.output_ip:
|
|
|
|
|
raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.")
|
|
|
|
|
if self.output_frequency_filter_enabled:
|
|
|
|
|
if self.output_min_frequency_mhz <= 0.0:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled."
|
|
|
|
|
)
|
|
|
|
|
if self.output_max_frequency_mhz <= 0.0:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled."
|
|
|
|
|
)
|
|
|
|
|
if self.output_max_frequency_mhz < self.output_min_frequency_mhz:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0))
|
|
|
|
|
self.aggregation = str(input_obj.get("aggregation", "median"))
|
|
|
|
|
@ -246,6 +382,10 @@ class AutoService:
|
|
|
|
|
"receiver_id": str(receiver["receiver_id"]),
|
|
|
|
|
"center": _center_from_obj(receiver),
|
|
|
|
|
"source_url": str(receiver["source_url"]),
|
|
|
|
|
"input_filter": _parse_receiver_input_filter(
|
|
|
|
|
receiver_obj=receiver,
|
|
|
|
|
receiver_id=str(receiver["receiver_id"]),
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
self.receivers = parsed_receivers
|
|
|
|
|
@ -273,7 +413,6 @@ class AutoService:
|
|
|
|
|
self.poll_thread.join(timeout=2.0)
|
|
|
|
|
|
|
|
|
|
def refresh_once(self) -> None:
|
|
|
|
|
spheres_all: List[Sphere] = []
|
|
|
|
|
receiver_payloads: List[Dict[str, object]] = []
|
|
|
|
|
grouped_by_receiver: List[Dict[float, List[Tuple[float, float]]]] = []
|
|
|
|
|
|
|
|
|
|
@ -281,22 +420,30 @@ class AutoService:
|
|
|
|
|
receiver_id = str(receiver["receiver_id"])
|
|
|
|
|
center = receiver["center"]
|
|
|
|
|
source_url = str(receiver["source_url"])
|
|
|
|
|
measurements = _fetch_measurements(
|
|
|
|
|
raw_measurements = _fetch_measurements(
|
|
|
|
|
source_url,
|
|
|
|
|
timeout_s=self.source_timeout_s,
|
|
|
|
|
expected_receiver_id=receiver_id,
|
|
|
|
|
)
|
|
|
|
|
receiver_filter = receiver["input_filter"]
|
|
|
|
|
measurements = _apply_receiver_input_filter(
|
|
|
|
|
raw_measurements, receiver_filter=receiver_filter
|
|
|
|
|
)
|
|
|
|
|
if not measurements:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"receiver '{receiver_id}': no measurements left after input_filter."
|
|
|
|
|
)
|
|
|
|
|
grouped = _group_by_frequency(measurements)
|
|
|
|
|
grouped_by_receiver.append(grouped)
|
|
|
|
|
|
|
|
|
|
radius_m = aggregate_radius(measurements, model=self.model, method=self.aggregation)
|
|
|
|
|
spheres_all.append(Sphere(center=center, radius=radius_m))
|
|
|
|
|
|
|
|
|
|
samples = []
|
|
|
|
|
for frequency_hz, amplitude_dbm in measurements:
|
|
|
|
|
samples.append(
|
|
|
|
|
{
|
|
|
|
|
"frequency_hz": frequency_hz,
|
|
|
|
|
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
|
|
|
|
|
"amplitude_dbm": amplitude_dbm,
|
|
|
|
|
"distance_m": rssi_to_distance_m(
|
|
|
|
|
amplitude_dbm=amplitude_dbm,
|
|
|
|
|
@ -312,11 +459,15 @@ class AutoService:
|
|
|
|
|
"center": {"x": center[0], "y": center[1], "z": center[2]},
|
|
|
|
|
"source_url": source_url,
|
|
|
|
|
"aggregation": self.aggregation,
|
|
|
|
|
"input_filter": receiver_filter,
|
|
|
|
|
"raw_samples_count": len(raw_measurements),
|
|
|
|
|
"filtered_samples_count": len(measurements),
|
|
|
|
|
"radius_m_all_freq": radius_m,
|
|
|
|
|
"samples": samples,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Only compare homogeneous measurements: same frequency across all receivers.
|
|
|
|
|
common_frequencies = (
|
|
|
|
|
set(grouped_by_receiver[0].keys())
|
|
|
|
|
& set(grouped_by_receiver[1].keys())
|
|
|
|
|
@ -356,6 +507,7 @@ class AutoService:
|
|
|
|
|
receiver_payloads[index].setdefault("per_frequency", []).append(
|
|
|
|
|
{
|
|
|
|
|
"frequency_hz": frequency_hz,
|
|
|
|
|
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
|
|
|
|
|
"radius_m": spheres_for_frequency[index].radius,
|
|
|
|
|
"residual_m": residual,
|
|
|
|
|
"samples_count": len(grouped_by_receiver[index][frequency_hz]),
|
|
|
|
|
@ -364,6 +516,7 @@ class AutoService:
|
|
|
|
|
|
|
|
|
|
row = {
|
|
|
|
|
"frequency_hz": frequency_hz,
|
|
|
|
|
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
|
|
|
|
|
"position": {
|
|
|
|
|
"x": result.point[0],
|
|
|
|
|
"y": result.point[1],
|
|
|
|
|
@ -383,6 +536,7 @@ class AutoService:
|
|
|
|
|
payload = {
|
|
|
|
|
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"selected_frequency_hz": best_row["frequency_hz"],
|
|
|
|
|
"selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ,
|
|
|
|
|
"position": best_row["position"],
|
|
|
|
|
"exact": best_row["exact"],
|
|
|
|
|
"rmse_m": best_row["rmse_m"],
|
|
|
|
|
@ -403,13 +557,36 @@ class AutoService:
|
|
|
|
|
self.last_error = ""
|
|
|
|
|
|
|
|
|
|
if self.output_enabled:
|
|
|
|
|
output_payload = self._build_output_payload(payload)
|
|
|
|
|
if output_payload is None:
|
|
|
|
|
with self.state_lock:
|
|
|
|
|
self.last_output_delivery = {
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"status": "skipped",
|
|
|
|
|
"http_status": None,
|
|
|
|
|
"response_body": "No frequencies in configured output range",
|
|
|
|
|
"sent_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
"target": {
|
|
|
|
|
"ip": self.output_ip,
|
|
|
|
|
"port": self.output_port,
|
|
|
|
|
"path": self.output_path,
|
|
|
|
|
},
|
|
|
|
|
"frequency_filter": {
|
|
|
|
|
"enabled": self.output_frequency_filter_enabled,
|
|
|
|
|
"min_frequency_mhz": self.output_min_frequency_mhz,
|
|
|
|
|
"max_frequency_mhz": self.output_max_frequency_mhz,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
status_code, response_body = send_payload_to_server(
|
|
|
|
|
server_ip=self.output_ip,
|
|
|
|
|
payload=payload,
|
|
|
|
|
payload=output_payload,
|
|
|
|
|
port=self.output_port,
|
|
|
|
|
path=self.output_path,
|
|
|
|
|
timeout_s=self.output_timeout_s,
|
|
|
|
|
)
|
|
|
|
|
# Keep delivery diagnostics in snapshot so UI/API can show transport health.
|
|
|
|
|
with self.state_lock:
|
|
|
|
|
self.last_output_delivery = {
|
|
|
|
|
"enabled": True,
|
|
|
|
|
@ -422,6 +599,11 @@ class AutoService:
|
|
|
|
|
"port": self.output_port,
|
|
|
|
|
"path": self.output_path,
|
|
|
|
|
},
|
|
|
|
|
"frequency_filter": {
|
|
|
|
|
"enabled": self.output_frequency_filter_enabled,
|
|
|
|
|
"min_frequency_mhz": self.output_min_frequency_mhz,
|
|
|
|
|
"max_frequency_mhz": self.output_max_frequency_mhz,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if status_code < 200 or status_code >= 300:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
@ -429,6 +611,55 @@ class AutoService:
|
|
|
|
|
f"HTTP {status_code}, body={response_body}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _build_output_payload(self, payload: Dict[str, object]) -> Optional[Dict[str, object]]:
|
|
|
|
|
if not self.output_frequency_filter_enabled:
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
# Keep internal calculations unchanged, but limit data sent to output server by frequency.
|
|
|
|
|
payload_copy = copy.deepcopy(payload)
|
|
|
|
|
table_obj = payload_copy.get("frequency_table")
|
|
|
|
|
if not isinstance(table_obj, list):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
filtered_rows = []
|
|
|
|
|
for row in table_obj:
|
|
|
|
|
if not isinstance(row, dict):
|
|
|
|
|
continue
|
|
|
|
|
frequency_hz = row.get("frequency_hz")
|
|
|
|
|
if not isinstance(frequency_hz, (int, float)):
|
|
|
|
|
continue
|
|
|
|
|
if self.output_min_frequency_hz <= float(frequency_hz) <= self.output_max_frequency_hz:
|
|
|
|
|
filtered_rows.append(row)
|
|
|
|
|
if not filtered_rows:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
best_row = min(filtered_rows, key=lambda row: float(row.get("rmse_m", float("inf"))))
|
|
|
|
|
payload_copy["frequency_table"] = filtered_rows
|
|
|
|
|
payload_copy["selected_frequency_hz"] = best_row.get("frequency_hz")
|
|
|
|
|
payload_copy["selected_frequency_mhz"] = float(best_row.get("frequency_hz", 0.0)) / HZ_IN_MHZ
|
|
|
|
|
payload_copy["position"] = best_row.get("position")
|
|
|
|
|
payload_copy["exact"] = best_row.get("exact")
|
|
|
|
|
payload_copy["rmse_m"] = best_row.get("rmse_m")
|
|
|
|
|
|
|
|
|
|
receivers_obj = payload_copy.get("receivers")
|
|
|
|
|
if isinstance(receivers_obj, list):
|
|
|
|
|
for receiver in receivers_obj:
|
|
|
|
|
if not isinstance(receiver, dict):
|
|
|
|
|
continue
|
|
|
|
|
per_frequency = receiver.get("per_frequency")
|
|
|
|
|
if not isinstance(per_frequency, list):
|
|
|
|
|
continue
|
|
|
|
|
receiver["per_frequency"] = [
|
|
|
|
|
row
|
|
|
|
|
for row in per_frequency
|
|
|
|
|
if isinstance(row, dict)
|
|
|
|
|
and isinstance(row.get("frequency_hz"), (int, float))
|
|
|
|
|
and self.output_min_frequency_hz
|
|
|
|
|
<= float(row["frequency_hz"])
|
|
|
|
|
<= self.output_max_frequency_hz
|
|
|
|
|
]
|
|
|
|
|
return payload_copy
|
|
|
|
|
|
|
|
|
|
def _poll_loop(self) -> None:
|
|
|
|
|
while not self.stop_event.is_set():
|
|
|
|
|
try:
|
|
|
|
|
@ -450,6 +681,22 @@ class AutoService:
|
|
|
|
|
|
|
|
|
|
def _make_handler(service: AutoService):
|
|
|
|
|
class ServiceHandler(BaseHTTPRequestHandler):
|
|
|
|
|
def _is_write_authorized(self) -> bool:
|
|
|
|
|
expected_token = service.write_api_token
|
|
|
|
|
if not expected_token:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
header_token = self.headers.get("X-API-Token", "")
|
|
|
|
|
if hmac.compare_digest(header_token, expected_token):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
authorization = self.headers.get("Authorization", "")
|
|
|
|
|
if authorization.lower().startswith("bearer "):
|
|
|
|
|
bearer_token = authorization[7:].strip()
|
|
|
|
|
if hmac.compare_digest(bearer_token, expected_token):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _write_bytes(
|
|
|
|
|
self,
|
|
|
|
|
status_code: int,
|
|
|
|
|
@ -473,7 +720,10 @@ def _make_handler(service: AutoService):
|
|
|
|
|
def _write_static(self, relative_path: str) -> None:
|
|
|
|
|
web_root = Path(__file__).resolve().parent / "web"
|
|
|
|
|
file_path = (web_root / relative_path).resolve()
|
|
|
|
|
if not str(file_path).startswith(str(web_root.resolve())):
|
|
|
|
|
# Protect against path traversal outside /web.
|
|
|
|
|
try:
|
|
|
|
|
file_path.relative_to(web_root.resolve())
|
|
|
|
|
except ValueError:
|
|
|
|
|
self._write_json(404, {"error": "not_found"})
|
|
|
|
|
return
|
|
|
|
|
if not file_path.exists() or not file_path.is_file():
|
|
|
|
|
@ -554,6 +804,7 @@ def _make_handler(service: AutoService):
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"updated_at_utc": snapshot["updated_at_utc"],
|
|
|
|
|
"selected_frequency_hz": payload.get("selected_frequency_hz"),
|
|
|
|
|
"selected_frequency_mhz": payload.get("selected_frequency_mhz"),
|
|
|
|
|
"frequency_table": payload.get("frequency_table", []),
|
|
|
|
|
"output_delivery": snapshot["output_delivery"],
|
|
|
|
|
},
|
|
|
|
|
@ -561,12 +812,18 @@ def _make_handler(service: AutoService):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if path == "/config":
|
|
|
|
|
public_config = json.loads(json.dumps(service.config))
|
|
|
|
|
runtime_obj = public_config.get("runtime")
|
|
|
|
|
if isinstance(runtime_obj, dict):
|
|
|
|
|
if "write_api_token" in runtime_obj:
|
|
|
|
|
runtime_obj["write_api_token"] = ""
|
|
|
|
|
runtime_obj["write_api_token_set"] = bool(service.write_api_token)
|
|
|
|
|
self._write_json(
|
|
|
|
|
200,
|
|
|
|
|
{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"config_path": service.config_path,
|
|
|
|
|
"config": service.config,
|
|
|
|
|
"config": public_config,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
@ -575,12 +832,34 @@ def _make_handler(service: AutoService):
|
|
|
|
|
|
|
|
|
|
def do_POST(self) -> None:
|
|
|
|
|
path = parse.urlparse(self.path).path
|
|
|
|
|
if not self._is_write_authorized():
|
|
|
|
|
self._write_json(
|
|
|
|
|
401,
|
|
|
|
|
{"status": "error", "error": "unauthorized: missing or invalid API token"},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if path == "/config":
|
|
|
|
|
try:
|
|
|
|
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
self._write_json(400, {"status": "error", "error": "Invalid Content-Length"})
|
|
|
|
|
return
|
|
|
|
|
if content_length <= 0:
|
|
|
|
|
self._write_json(400, {"status": "error", "error": "Empty request body"})
|
|
|
|
|
return
|
|
|
|
|
if content_length > MAX_CONFIG_BODY_BYTES:
|
|
|
|
|
self._write_json(
|
|
|
|
|
413,
|
|
|
|
|
{
|
|
|
|
|
"status": "error",
|
|
|
|
|
"error": (
|
|
|
|
|
f"Config payload too large: {content_length} bytes, "
|
|
|
|
|
f"max is {MAX_CONFIG_BODY_BYTES}"
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
body = self.rfile.read(content_length) if content_length > 0 else b""
|
|
|
|
|
try:
|
|
|
|
|
new_config = json.loads(body.decode("utf-8"))
|
|
|
|
|
@ -591,6 +870,13 @@ def _make_handler(service: AutoService):
|
|
|
|
|
self._write_json(400, {"status": "error", "error": "Config must be JSON object"})
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Avoid accidental token wipe when /config GET response is redacted in clients.
|
|
|
|
|
runtime_obj = new_config.get("runtime")
|
|
|
|
|
if isinstance(runtime_obj, dict) and service.write_api_token:
|
|
|
|
|
incoming_token = str(runtime_obj.get("write_api_token", "")).strip()
|
|
|
|
|
if not incoming_token:
|
|
|
|
|
runtime_obj["write_api_token"] = service.write_api_token
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
AutoService(new_config)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|