config.py
Rama main · réplica de lectura del repositorio original.
#!/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)"}],
}