Ctrl K
ComunidadGuías…

config.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
config.py — configuration, paths, environment variables, and static data.
No dependency on the rest of the project's modules (stdlib only).
"""
import os, sys, hashlib
from pathlib import Path

# ── Paths (PyInstaller-aware) ─────────────────────────────────────────────────
# The code and frontend (public/, rigs/) are READ-ONLY and can be bundled
# into the EXE. User data (config.json, users.json, .env, logs) MUST be
# persistent and writable - next to the EXE, not in PyInstaller's temp folder.
#
# Environment detection:
#   - PyInstaller onefile: sys.frozen=True, static data in sys._MEIPASS,
#     but the EXE physically lives at sys.executable (that's where we keep user data).
#   - Regular Python: everything next to config.py.

def _is_frozen() -> bool:
    return getattr(sys, "frozen", False)


def _writable_data_dir() -> "Path":
    """
    Choose a PERSISTENT, WRITABLE directory for user data (config, users, .env, logs).

    Logic:
      1. Dev mode (not frozen): next to config.py.
      2. Frozen (EXE):
         a) If the directory next to the EXE is writable (e.g. EXE on the
            desktop, in Downloads, portable) -> use next to the EXE (simple, portable).
         b) If NOT (e.g. installed in Program Files, read-only) ->
            use %APPDATA%\\HAMCTRL (Windows) / ~/.hamctrl (other OSes).

    This lets the same EXE work both as a portable app (data alongside it)
    and as an install in Program Files (data in APPDATA).
    """
    if not _is_frozen():
        return Path(__file__).parent

    exe_dir = Path(sys.executable).parent

    # CRITICAL (data loss on update): for an install in Program Files,
    # ALWAYS use APPDATA — no writability test. The "can I write next to
    # the EXE" test passes when the server is run AS ADMINISTRATOR, which
    # meant qso.db/users.json/config loaded FROM Program Files and every
    # update/uninstall deleted them ("installing a new version wipes the
    # log"). Portable mode (data next to the EXE) only applies OUTSIDE
    # Program Files.
    _pf = [os.environ.get("ProgramFiles", r"C:\Program Files"),
           os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")]
    _in_program_files = any(
        str(exe_dir).lower().startswith(str(p).lower())
        for p in _pf if p
    )

    if not _in_program_files:
        # Test whether the directory next to the EXE is writable (portable mode)
        try:
            _test = exe_dir / ".write_test"
            _test.write_text("x", encoding="utf-8")
            _test.unlink()
            return exe_dir  # writable - portable mode
        except Exception:
            pass  # read-only - fall back to APPDATA

    # Application data directory
    appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
    if appdata:
        d = Path(appdata) / "HAMCTRL"
    else:
        d = Path.home() / ".hamctrl"
    try:
        d.mkdir(parents=True, exist_ok=True)
    except Exception:
        d = Path.home()  # last-resort fallback

    # RESCUE MIGRATION: if old data is sitting NEXT TO the EXE (a side
    # effect of an earlier bug: running as admin -> writing to Program
    # Files succeeded), move it to APPDATA before an update/uninstall
    # deletes it. We only copy a file if it does NOT already exist in
    # APPDATA (never overwrite newer data with older data).
    try:
        import shutil as _sh
        # qso.db-wal / -shm are SQLite's WAL-mode companion files —
        # they hold the MOST RECENT transactions, not yet merged into the
        # main database. Without them, the migration would lose the most
        # recently logged QSOs.
        for _fn in ("qso.db", "qso.db-wal", "qso.db-shm",
                    "users.json", "config.json", ".env"):
            _src = exe_dir / _fn
            _dst = d / _fn
            if _src.exists() and not _dst.exists():
                _sh.copy2(str(_src), str(_dst))
                print(f"[config] Data migration: {_fn} (next to EXE -> APPDATA)",
                      flush=True)
        # DIRECTORIES: the Let's Encrypt certificate (letsencrypt/) and the
        # tunnel (cloudflared/) also used to live next to the EXE — without
        # migrating them the cert "disappeared" (the code looks in
        # DATA=APPDATA, but the files stayed in Program Files).
        for _dn in ("letsencrypt", "cloudflared", "logs"):
            _src = exe_dir / _dn
            _dst = d / _dn
            if _src.is_dir() and not _dst.exists():
                try:
                    _sh.copytree(str(_src), str(_dst))
                    print(f"[config] Directory migration: {_dn}/ "
                          f"(next to EXE -> APPDATA)", flush=True)
                except Exception as _de:
                    # The private key in Program Files may have been created
                    # by win-acme (or certbot, pre-2026) as admin — reading it
                    # without admin rights may be blocked. A clear instruction
                    # instead of a silent failure.
                    print(f"[config] COULD NOT migrate {_dn}/ ({_de}). "
                          f"Copy it manually: '{_src}' -> '{_dst}' "
                          f"(e.g. as administrator).", flush=True)
    except Exception as _e:
        print(f"[config] Data migration skipped: {_e}", flush=True)

    return d


# BUNDLE = directory with read-only assets (public/, rigs/)
if _is_frozen():
    BUNDLE = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
    DATA = _writable_data_dir()
else:
    BUNDLE = Path(__file__).parent
    DATA = Path(__file__).parent

BASE   = DATA            # name kept for compatibility (user data)
PUBLIC = BUNDLE / "public"   # frontend - read-only, from the bundle
CFG_F  = DATA / "config.json"
USR_F  = DATA / "users.json"
LOG_F  = DATA / "qso_log.json"
ENV_F  = DATA / ".env"


def load_env():
    e = {}
    if ENV_F.exists():
        for line in ENV_F.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#"): continue
            if "=" in line:
                k, _, v = line.partition("=")
                e[k.strip()] = v.strip()
    return e


def ensure_env():
    """
    On first run (no .env), creates .env with SAFE values for this
    specific installation:

    - JWT_SECRET: RANDOM (secrets.token_hex) - each install has its own
      key. CRITICAL for a multi-club product: without this every
      installation would share the same key and tokens could be forged.
    - FIRST_RUN: 1 - a flag meaning the setup wizard hasn't run yet
      (forces the admin password change and station configuration).

    Returns the updated ENV dict. If .env already exists - leaves it alone.
    """
    import secrets
    if ENV_F.exists():
        return load_env()
    # First start - generate a fresh .env
    jwt_secret = secrets.token_hex(32)  # 256-bit random key
    lines = [
        "# HAM RADIO CTRL - installation configuration",
        "# Generated automatically on first run.",
        "# DO NOT SHARE this file - it contains this installation's JWT key.",
        "",
        f"JWT_SECRET={jwt_secret}",
        "FIRST_RUN=1",
        "",
    ]
    try:
        ENV_F.write_text("\n".join(lines), encoding="utf-8")
        print(f"[config] Created .env with a random JWT key (first start)",
              flush=True)
    except Exception as e:
        print(f"[config] WARNING: could not write .env: {e}", flush=True)
    return load_env()


ENV          = ensure_env()
PORT         = int(os.environ.get("PORT", ENV.get("PORT", 8000)))
CALLSIGN     = ENV.get("CALLSIGN", "SP0ABC")
LOCATOR      = ENV.get("STATION_LOCATOR", "KO02")
# SECRET always comes from .env (ensure_env guarantees it exists and is random).
# The fallback is only an emergency measure if writing .env failed - still
# random per-process (not a shared hardcoded value like the old "hamradio2025").
SECRET       = ENV.get("JWT_SECRET") or __import__("secrets").token_hex(32)
ADMIN_PW     = ENV.get("ADMIN_PASSWORD", "Admin1234!")
FIRST_RUN    = ENV.get("FIRST_RUN", "0") == "1"
# VERBOSE: chatty logs (radio status every 2s etc). Disabled by default -
# the product has a clean console. Enable via HAM_VERBOSE=1 in the
# environment or VERBOSE=1 in .env when diagnosing.
VERBOSE      = (os.environ.get("HAM_VERBOSE", ENV.get("VERBOSE", "0")) == "1")
HAMLIB       = ENV.get("HAMLIB_PATH", "rigctld")
# HAMLIB_PORT: HAMCTRL's OWN broadcast rigctld EMULATOR (hamlib_server.py) -
# external apps (WSJT-X, loggers) connect here, N clients at once. This is
# NOT a real rigctld and does not talk to hardware directly - it operates
# on the same in-memory self.rig object the rest of the server uses.
HAMLIB_PORT  = int(ENV.get("HAMLIB_PORT", 4532))
# RIGCTLD_PORT: the port rigcat.py's OWN real rigctld.exe (talking to the
# physical radio over a COM port) listens on. MUST be different from
# HAMLIB_PORT - both defaulted to 4532 until 2026-09-01, which meant
# rigcat.py's connect() always found port 4532 already bound by HAMCTRL's
# own broadcast emulator (started earlier at boot) and concluded "a rigctld
# is already running" - it never spawned the real one, so RigCAT-driven
# rigs (Hamlib, e.g. non-CI-V/older Icoms) silently never reached the
# actual hardware: reads returned whatever was last cached in self.rig, and
# writes recursed back into the emulator's own SET_FREQ/SET_MODE handlers
# (which call self.rig.set_freq()/set_mode() again) instead of the radio.
# civ.py never hit this because it talks to the COM port directly over
# serial, not through this TCP port at all.
RIGCTLD_PORT = int(ENV.get("RIGCTLD_PORT", 4599))
# WEBRTC_UDP_PORTS: staly zakres portow UDP dla audio WebRTC (RX i TX z
# mikrofonu), np. "40000-40019" (20 portow = kilka osob jednoczesnie). Domyslnie ponizej puli portow dynamicznych
# Windows (49152-65535) - tam system sam rezerwuje bloki (np. 50000-50059
# zarezerwowane na komputerze deweloperskim), wiec bind by sie nie udal. Bez niego aiortc/aioice bierze losowe porty
# i audio zza NAT dziala tylko, gdy routery "przepuszcza" polaczenie (zywy
# przypadek 2026-10-27: ICE utknelo w "checking", audio wrocilo dopiero po
# restarcie serwera). Ten zakres UDP przekierowac na routerze na komputer
# z HAMCTRL. Pusty / "0" = losowe porty jak dawniej.
WEBRTC_UDP_PORTS = ENV.get("WEBRTC_UDP_PORTS", "40000-40019")
# WEBRTC_UPNP: serwer sam otwiera powyzszy zakres na routerze przez UPnP
# (upnp_ports.py) - np. Funbox Orange nie pozwala recznie przekierowac zakresu.
# "0" = wylaczone (porty przekierowane recznie albo wcale).
WEBRTC_UPNP = ENV.get("WEBRTC_UPNP", "1") == "1"
RIGCTLD_LOG  = str(Path(__file__).resolve().parent / "rigctld.log")

MIME = {
    ".html": "text/html;charset=utf-8", ".css": "text/css",
    ".js": "application/javascript", ".json": "application/json",
    ".png": "image/png", ".ico": "image/x-icon", ".svg": "image/svg+xml",
    ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
    ".mp3": "audio/mpeg", ".wav": "audio/wav",
}

# Models with a built-in spectroscope (scope) → direct CI-V mode (control + scope).
SCOPE_MODELS = {"3073", "3078", "3085", "3068", "3070", "3081"}  # IC-7300/7610/705/9100/7100/9700

# Models driven natively via CI-V (civ.py, direct serial) even WITHOUT a
# scope - superset of SCOPE_MODELS. Added 2026-09-02 for IC-746 (3023) after
# rigctld (Hamlib) kept crashing mid-session on this radio (see rigcat.py's
# _respawn_rigctld) - civ.py talks straight to the serial port, no separate
# process/TCP hop that can die out from under a live session. Verified
# against Hamlib's own icom.c (RIG_IS_IC746 branches) before adding a model
# here - an old-generation Icom like the 746 needs its OWN civ_profiles.py
# entry with the legacy (no filter byte, no DATA-mode command) mode-set
# quirks, or civ.py would send frames this radio doesn't understand. Any
# model in here but not in SCOPE_MODELS gets scope UI/endpoints disabled via
# its profile's capabilities["scope"]=False (see webapp.py's /api/scope
# gate), not just a missing scope_max/scope_header_len.
CIV_NATIVE_MODELS = SCOPE_MODELS | {"3023"}  # + IC-746

HAMLIB_MODELS = {
    "Icom": [
        {"id":"3073","name":"IC-7300"},{"id":"3046","name":"IC-746 Pro"},
        {"id":"3023","name":"IC-746"},{"id":"3085","name":"IC-705"},
        {"id":"3078","name":"IC-7610"},{"id":"3070","name":"IC-7100"},
        {"id":"3060","name":"IC-7000"},{"id":"3068","name":"IC-9100"},
        {"id":"3081","name":"IC-9700"},
        {"id":"3011","name":"IC-706MkIIG"},
    ],
    "Yaesu": [
        {"id":"1035","name":"FT-991A"},{"id":"1034","name":"FT-991"},
        {"id":"1031","name":"FT-857D"},{"id":"1030","name":"FT-817"},
        {"id":"1066","name":"FT-DX10"},{"id":"1067","name":"FT-710"},
    ],
    "Kenwood": [
        {"id":"2014","name":"TS-590SG"},{"id":"2022","name":"TS-890S"},
        {"id":"2010","name":"TS-2000"},{"id":"2017","name":"TS-990S"},
    ],
    "Elecraft": [
        {"id":"2050","name":"K3"},{"id":"2051","name":"K3S"},
        {"id":"2054","name":"KX3"},{"id":"2053","name":"K4"},
    ],
    "Inne": [{"id":"2","name":"Symulacja (test)"}],
}