# toml_persist.py from __future__ import annotations import os import time import logging from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from . import config from .geom import ang_diff_signed, normalize360 from .ptz_io import read_ptz_azimuth_deg, goto_preset_token logger = logging.getLogger("PTZTracker.PERSIST") def _cameras_toml_path() -> Path: """ Должны писать в тот же файл, который читает config.reload_cameras(). Приоритет: 1) env CAMERAS_FILE 2) cameras.toml рядом с config.py """ env = os.getenv("CAMERAS_FILE") if env: return Path(env).resolve() # рядом с config.py (и тем самым совместимо с config._load_cameras_from_file) cfg_dir = Path(getattr(config, "__file__", __file__)).resolve().parent return (cfg_dir / "cameras.toml").resolve() def _load_toml(path: Path) -> Dict[str, Any]: if not path.exists(): return {} try: import tomllib with open(path, "rb") as f: return tomllib.load(f) except Exception as e: logger.error("[PERSIST] TOML read failed: %s", e) return {} def _atomic_write(path: Path, text: str) -> None: tmp = path.with_suffix(path.suffix + ".tmp") with open(tmp, "w", encoding="utf-8") as f: f.write(text) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) def _to_float(val: Any) -> Optional[float]: try: return float(val) except Exception: return None def _render_cameras_table(cams: List[Dict[str, Any]]) -> str: lines: List[str] = [] for c in sorted(cams, key=lambda x: int(x.get("id", 0))): lines.append("[[camera]]") lines.append(f"id = {int(c['id'])}") lines.append(f"ip = \"{c.get('ip','')}\"") lines.append(f"username = \"{c.get('username','')}\"") lines.append(f"password = \"{c.get('password','')}\"") lines.append(f"ptz_channel = {int(c.get('ptz_channel', 1))}") if "wiper_channel" in c: lines.append(f"wiper_channel = {int(c.get('wiper_channel', 1))}") if "preset1" in c and c.get("preset1") not in (None, "None"): lines.append(f"preset1 = \"{c.get('preset1')}\"") if "preset2" in c and c.get("preset2") not in (None, "None"): lines.append(f"preset2 = \"{c.get('preset2')}\"") if "sweep_sign" in c: lines.append(f"sweep_sign = {int(c.get('sweep_sign', 1))}") if "is_ptz" in c: lines.append(f"is_ptz = {str(bool(c.get('is_ptz'))).lower()}") if "mac" in c and isinstance(c["mac"], str): lines.append(f"mac = \"{c['mac']}\"") for k in ("north_offset_deg", "preset1_ref_geo_deg", "preset2_ref_geo_deg"): if isinstance(c.get(k), (int, float)): lines.append(f"{k} = {float(c[k]):.6f}") for k in ("preset1_deg_geo", "preset2_deg_geo", "sector_min_deg_geo", "sector_max_deg_geo"): if isinstance(c.get(k), (int, float)): lines.append(f"{k} = {float(c[k]):.6f}") for k in ("preset1_deg", "preset2_deg", "preset1_tilt_deg", "preset2_tilt_deg", "sector_min_deg", "sector_max_deg"): if isinstance(c.get(k), (int, float)): lines.append(f"{k} = {float(c[k]):.6f}") for k in ("north_last_source", "geo_last_source"): if isinstance(c.get(k), str): lines.append(f"{k} = \"{c[k]}\"") for k in ("north_last_at", "geo_last_at"): if isinstance(c.get(k), int): lines.append(f"{k} = {c[k]}") lines.append("") return "\n".join(lines).strip() + "\n" def save_all_cameras() -> None: path = _cameras_toml_path() cams = list(config.CAMERA_CONFIG.values()) text = _render_cameras_table(cams) try: _atomic_write(path, text) logger.info("[PERSIST] cameras.toml saved (%d cameras)", len(cams)) try: config.reload_cameras() except Exception as e: logger.warning("[PERSIST] reload_cameras failed: %s", e) except Exception as e: logger.error("[PERSIST] write failed: %s", e) # ---------- низкоуровневая запись одного поля ---------- def _persist_field(cam_id: int, key: str, value) -> bool: """ Записать камере поле key=value в cameras.toml. True, если действительно изменилось. """ path = _cameras_toml_path() data = _load_toml(path) cams = data.get("camera") or data.get("cameras") or [] if not isinstance(cams, list): logger.error("[PERSIST] cameras.toml format unexpected: no [[camera]] array") return False changed = False found = False for c in cams: if not isinstance(c, dict) or "id" not in c: continue if int(c["id"]) == int(cam_id): found = True before = c.get(key) if before != value: c[key] = value changed = True break if not found or not changed: return False text = _render_cameras_table(cams) try: _atomic_write(path, text) try: config.reload_cameras() except Exception as e: logger.warning("[PERSIST] reload_cameras failed: %s", e) return True except Exception as e: logger.error("[PERSIST] write failed: %s", e) return False # ---------- публичный API: north_offset ---------- def persist_north_offset(cam_id: int, north_deg: float, source: str = "unknown") -> bool: """ Обновляет/добавляет north_offset_deg; ставит метаданные; перезагружает config. """ path = _cameras_toml_path() data = _load_toml(path) cams = data.get("camera") or data.get("cameras") or [] if not isinstance(cams, list): logger.error("[PERSIST] cameras.toml format unexpected: no [[camera]] array") return False north_deg = normalize360(float(north_deg)) changed = False found = False for c in cams: if not isinstance(c, dict) or "id" not in c: continue if int(c["id"]) == int(cam_id): found = True old = _to_float(c.get("north_offset_deg")) if old is None or abs(old - north_deg) > 1e-6: c["north_offset_deg"] = float(north_deg) c["north_last_source"] = source c["north_last_at"] = int(time.time()) changed = True break if not found: logger.warning("[PERSIST] camera id=%s not found in cameras.toml; will not add a new block", cam_id) return False if not changed: return False text = _render_cameras_table(cams) try: _atomic_write(path, text) logger.info("[PERSIST] cameras.toml updated (cam %s north=%.2f° via %s)", cam_id, north_deg, source) try: config.reload_cameras() except Exception as e: logger.warning("[PERSIST] reload_cameras failed: %s", e) return True except Exception as e: logger.error("[PERSIST] write failed: %s", e) return False # ---------- публичный API: массовая запись geo-полей ---------- def persist_geo_fields(cam_id: int, fields: Dict[str, float], source: str = "unknown") -> bool: path = _cameras_toml_path() data = _load_toml(path) cams = data.get("camera") or data.get("cameras") or [] if not isinstance(cams, list): logger.error("[PERSIST] cameras.toml format unexpected: no [[camera]] array") return False changed = False found = False for c in cams: if not isinstance(c, dict) or "id" not in c: continue if int(c["id"]) == int(cam_id): found = True for k, v in fields.items(): if not isinstance(v, (int, float)): continue vv = normalize360(float(v)) if k == "north_offset_deg" else float(v) old = _to_float(c.get(k)) if old is None or abs(old - vv) > 1e-6: c[k] = vv changed = True if changed: c["geo_last_source"] = source c["geo_last_at"] = int(time.time()) break if not found or not changed: return False text = _render_cameras_table(cams) try: _atomic_write(path, text) logger.info("[PERSIST] cameras.toml updated (cam %s fields=%s via %s)", cam_id, list(fields.keys()), source) try: config.reload_cameras() except Exception as e: logger.warning("[PERSIST] reload_cameras failed: %s", e) return True except Exception as e: logger.error("[PERSIST] write failed: %s", e) return False # ---------- публичный API: preset1_ref_geo_deg ---------- def persist_preset1_ref_geo(cam_id: int, ref_geo_deg: float, source: str = "auto") -> bool: """ Записывает preset1_ref_geo_deg (если изменился) + reload. """ ref_geo_deg = normalize360(float(ref_geo_deg)) ok = _persist_field(cam_id, "preset1_ref_geo_deg", ref_geo_deg) if ok: logger.info("[PERSIST] cam %s preset1_ref_geo_deg=%.2f (%s)", cam_id, ref_geo_deg, source) return ok # ---------- утилиты чтения статуса ---------- async def _wait_az_stable(cam: int, timeout: float = 2.5, tol_deg: float = 0.3) -> Optional[float]: """ Ждём пока pan успокоится (изменение <= tol_deg). Возвращаем pan (0..360) или None. """ import asyncio loop = asyncio.get_event_loop() t0 = loop.time() last = None stable = 0 while loop.time() - t0 < timeout: a = read_ptz_azimuth_deg(cam) if a is None: await asyncio.sleep(0.12) continue a = normalize360(a) # подписанная разница diff = (a - (last if last is not None else a) + 540.0) % 360.0 - 180.0 if last is None or abs(diff) > tol_deg: stable = 0 else: stable += 1 if stable >= 1: return a last = a await asyncio.sleep(0.12) return last # ---------- авторасчёт и запись preset1_ref_geo_deg (от самого PTZ) ---------- async def compute_and_persist_preset1_ref_geo(cam_id: int) -> Optional[float]: """ Если у камеры задан `north_offset_deg` и есть `preset1`, вычисляет preset1_ref_geo_deg = normalize360(pan_on_preset1 + north_offset_deg), сохраняет в cameras.toml и возвращает значение. """ cfg = config.CAMERA_CONFIG.get(cam_id, {}) north = cfg.get("north_offset_deg") p1 = cfg.get("preset1") if not isinstance(north, (int, float)) or not p1: logger.info("[PERSIST] cam %s: cannot compute preset1_ref_geo_deg (north or preset1 missing)", cam_id) return None try: goto_preset_token(cam_id, str(p1)) import asyncio await asyncio.sleep(0.6) pan = await _wait_az_stable(cam_id) if pan is None: logger.warning("[PERSIST] cam %s: cannot read stable pan on preset1", cam_id) return None ref_geo = normalize360(float(pan) + float(north)) changed = persist_geo_fields( cam_id, {"preset1_ref_geo_deg": ref_geo}, source="compute_and_persist_preset1_ref_geo", ) if changed: logger.info("[PERSIST] cam %s: preset1_ref_geo_deg=%.2f written to cameras.toml", cam_id, ref_geo) else: logger.info("[PERSIST] cam %s: preset1_ref_geo_deg already up-to-date (%.2f)", cam_id, ref_geo) # обновим рантайм для удобства cfg["preset1_ref_geo_deg"] = ref_geo cfg["preset1_deg_geo"] = ref_geo if isinstance(cfg.get("preset2_deg"), (int, float)): cfg["preset2_deg_geo"] = normalize360(cfg["preset2_deg"] + float(north)) span_geo = ang_diff_signed(cfg["preset2_deg_geo"], cfg["preset1_deg_geo"]) if span_geo >= 0: cfg["sector_min_deg_geo"] = normalize360(cfg["preset1_deg_geo"] - config.SECTOR_MARGIN_DEG) cfg["sector_max_deg_geo"] = normalize360(cfg["preset2_deg_geo"] + config.SECTOR_MARGIN_DEG) else: cfg["sector_min_deg_geo"] = normalize360(cfg["preset2_deg_geo"] - config.SECTOR_MARGIN_DEG) cfg["sector_max_deg_geo"] = normalize360(cfg["preset1_deg_geo"] + config.SECTOR_MARGIN_DEG) _persist_field(cam_id, "preset1_deg_geo", float(cfg["preset1_deg_geo"])) _persist_field(cam_id, "preset2_deg_geo", float(cfg["preset2_deg_geo"])) _persist_field(cam_id, "sector_min_deg_geo", float(cfg["sector_min_deg_geo"])) _persist_field(cam_id, "sector_max_deg_geo", float(cfg["sector_max_deg_geo"])) return ref_geo except Exception as e: logger.warning("[PERSIST] cam %s: compute_and_persist_preset1_ref_geo failed: %s", cam_id, e) return None # ---------- авторасчёт рефов от кромок панорамы и запись ---------- def _pano_edges_geo(pano_id: int) -> Optional[Tuple[float, float, float]]: """ Возвращает (center_geo, left_geo, right_geo) для панорамы. Требует: north_offset_deg (пано) и bullet_hfov_deg/hfov_deg (пано). center_geo берём из runtime, если там есть pan_deg; иначе считаем центр по north_offset. """ pcfg = config.CAMERA_CONFIG.get(pano_id, {}) north = pcfg.get("north_offset_deg") hfov = pcfg.get("bullet_hfov_deg") or pcfg.get("hfov_deg") or 90.0 if not isinstance(north, (int, float)): return None pan_abs = None try: from . import state st = getattr(state, "ptz_states", {}).get(pano_id) or {} pan_abs = st.get("pan_deg") except Exception: pan_abs = None if pan_abs is None: pan_abs = 0.0 center_geo = normalize360(float(pan_abs) + float(north)) left_geo = normalize360(center_geo - float(hfov) * 0.5) right_geo = normalize360(center_geo + float(hfov) * 0.5) return center_geo, left_geo, right_geo async def compute_ref_from_pano_edges_and_persist(ptz_id: int, bind: str = "preset1=left,preset2=right") -> Optional[Tuple[float, float]]: """ Находит «спаренную» панораму (тот же IP, channel=2), берёт её north_offset_deg и hfov, вычисляет гео-азимуты левой/правой кромки и сохраняет в cameras.toml: preset1_ref_geo_deg и preset2_ref_geo_deg. bind: схема привязки (default: preset1->left, preset2->right). Возвращает (p1_ref, p2_ref) или None. """ # 1) попытка найти панораму по PTZ_TO_PAN pano_id = config.PTZ_TO_PAN.get(ptz_id) if pano_id is None: # резервный поиск по IP/каналу cfg = config.CAMERA_CONFIG.get(ptz_id, {}) ip = cfg.get("ip") if ip: for cid, c in config.CAMERA_CONFIG.items(): if c.get("ip") == ip and int(c.get("ptz_channel", 0)) == 2: pano_id = cid break if pano_id is None: logger.info("[PERSIST] cam %s: paired panorama not found", ptz_id) return None edges = _pano_edges_geo(pano_id) if edges is None: logger.info("[PERSIST] cam %s: panorama %s has no north_offset/hfov — cannot compute", ptz_id, pano_id) return None _, left_geo, right_geo = edges ptz_cfg = config.CAMERA_CONFIG.get(ptz_id, {}) p1 = ptz_cfg.get("preset1"); p2 = ptz_cfg.get("preset2") if not (p1 and p2): logger.info("[PERSIST] cam %s: no preset1/preset2 — skip", ptz_id) return None b = bind.lower().replace(" ", "") if "preset1=right" in b: p1_ref, p2_ref = right_geo, left_geo else: p1_ref, p2_ref = left_geo, right_geo fields = { "preset1_ref_geo_deg": float(p1_ref), "preset2_ref_geo_deg": float(p2_ref), } changed = persist_geo_fields(ptz_id, fields, source="pano_edges_autobind") if changed: logger.info("[PERSIST] cam %s: wrote %s into cameras.toml", ptz_id, list(fields.keys())) try: ptz_cfg.update(fields) # обновим рантайм except Exception: pass else: logger.info("[PERSIST] cam %s: %s already up-to-date", ptz_id, list(fields.keys())) return p1_ref, p2_ref