You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

427 lines
17 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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