3 Commits
dev ... v1.4.2

Author SHA1 Message Date
0bea6ca4ea Merge pull request 'v1.4.2' (#3) from dev into main
Reviewed-on: #3
2026-03-17 16:15:22 -04:00
2456fd0ee8 Merge pull request 'Merge v1.4.1 from dev' (#2) from dev into main
## [1.4.1] - 2026-03-17

### Fixed
- `config.ini` now saves to `AppData\Local\Series3Watcher\` instead of `Program Files` — fixes permission denied error on first-run wizard save.
- Config path resolution in both `series3_tray.py` and `series3_watcher.py` updated to use `sys.frozen` + `LOCALAPPDATA` when running as a PyInstaller `.exe`.
- Status menu item now uses a callable so it updates every time the menu opens — was showing stale "Starting..." while tooltip correctly showed current status.
- Settings dialog now opens in its own thread — fixes unresponsive tabs and text fields while the watcher loop is running.
- Tray icon reverted to plain colored dot — custom icon graphic was unreadable at 16px tray size. `.ico` file is still used for the `.exe` file icon.

### Changed
- Terra-View URL field in settings wizard now accepts base URL only (e.g. `http://192.168.x.x:8000`) — `/api/series3/heartbeat` endpoint appended automatically.
- Test Connection button now hits `/health` endpoint instead of posting a fake heartbeat — no database side effects.
- "terra-view URL" label capitalized to "Terra-View URL".
- Default log path updated to `AppData\Local\Series3Watcher\agent_logs\series3_watcher.log`.
- Installer now creates `agent_logs\` folder on install.
- `BUILDING.md` added — step-by-step guide for building, releasing, and updating.

## [1.4.0] - 2026-03-12

### Added
- `series3_tray.py` — system tray launcher using `pystray` + `Pillow`. Color-coded icon (green=OK, amber=Pending, red=Missing, purple=Error, grey=Starting). Right-click menu shows live status, unit count, last scan age, Open Log Folder, and Exit.
- `run_watcher(state, stop_event)` in `series3_watcher.py` for background thread use by the tray. Shared `state` dict updated on every scan cycle with status, unit list, last scan time, and last error.
- Interruptible sleep in watcher loop — tray exit is immediate, no waiting out the full scan interval.

### Changed
- `main()` now calls `run_watcher()` — standalone behavior unchanged.
- `requirements.txt` updated to document tray dependencies (`pystray`, `Pillow`); watcher itself remains stdlib-only.
2026-03-17 14:31:50 -04:00
c133932b29 Merge pull request 'Merge: dev to main, refactor rename' (#1) from dev into main
Reviewed-on: serversdown/series3-agent#1
2026-03-03 17:12:58 -05:00
8 changed files with 40 additions and 270 deletions

View File

@@ -6,24 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [1.4.4] - 2026-03-17
### Removed
- `OK_HOURS` and `MISSING_HOURS` config keys and Settings dialog fields removed — unit status thresholds are calculated by terra-view from raw `age_minutes`, not by the watcher. These fields had no effect since v1.4.2.
## [1.4.3] - 2026-03-17
### Added
- Auto-updater now logs all activity to the watcher log file (`[updater]` prefix) — silent failures are now visible.
- Configurable update source: `UPDATE_SOURCE = gitea` (default), `url`, or `disabled`. In `url` mode the watcher fetches `version.txt` and the `.exe` from a custom base URL (e.g. terra-view) instead of the Gitea API — enables updates on isolated networks that cannot reach Gitea. `disabled` turns off automatic checks while keeping the remote push path (from terra-view) functional.
- New **Updates** tab in the Settings dialog to configure `UPDATE_SOURCE` and `UPDATE_URL`.
### Fixed
- Downloaded `.exe` is now validated before applying: absolute size floor (100 KB), relative size floor (50% of current exe), and MZ magic bytes check. A corrupt or truncated download is now rejected and logged rather than silently overwriting the live exe.
- Swap `.bat` now backs up the current exe as `<exe>.old` before overwriting, providing a manual rollback copy if needed.
- Swap `.bat` retry loop is now capped at 5 attempts — was previously infinite if the file remained locked.
- Swap `.bat` now cleans up the temp download file on both success and failure.
## [1.4.2] - 2026-03-17 ## [1.4.2] - 2026-03-17
### Changed ### Changed

View File

@@ -1,4 +1,4 @@
# Series 3 Watcher v1.4.4 # Series 3 Watcher v1.4.2
Monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. Runs as a **system tray app** that starts automatically on login, reports heartbeats to terra-view, and self-updates from Gitea. Monitors Instantel **Series 3 (Minimate)** call-in activity on a Blastware server. Runs as a **system tray app** that starts automatically on login, reports heartbeats to terra-view, and self-updates from Gitea.
@@ -71,6 +71,8 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here
| Key | Description | | Key | Description |
|-----|-------------| |-----|-------------|
| `SCAN_INTERVAL_SECONDS` | How often to scan the folder (default `300`) | | `SCAN_INTERVAL_SECONDS` | How often to scan the folder (default `300`) |
| `OK_HOURS` | Age threshold for OK status (default `12`) |
| `MISSING_HOURS` | Age threshold for Missing status (default `24`) |
| `MLG_HEADER_BYTES` | Bytes to read from each `.MLG` header for unit ID (default `2048`) | | `MLG_HEADER_BYTES` | Bytes to read from each `.MLG` header for unit ID (default `2048`) |
| `RECENT_WARN_DAYS` | Log unsniffable files newer than this window | | `RECENT_WARN_DAYS` | Log unsniffable files newer than this window |
@@ -81,13 +83,6 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here
| `ENABLE_LOGGING` | `true` / `false` | | `ENABLE_LOGGING` | `true` / `false` |
| `LOG_RETENTION_DAYS` | Auto-clear log after this many days (default `30`) | | `LOG_RETENTION_DAYS` | Auto-clear log after this many days (default `30`) |
### Auto-Updater
| Key | Description |
|-----|-------------|
| `UPDATE_SOURCE` | `gitea` (default) or `url` — where to check for updates |
| `UPDATE_URL` | Base URL of the update server when `UPDATE_SOURCE = url` (e.g. terra-view URL). The watcher fetches `/api/updates/series3-watcher/version.txt` and `/api/updates/series3-watcher/series3-watcher.exe` from this base. |
--- ---
## Tray Icon ## Tray Icon
@@ -121,7 +116,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**.
## Versioning ## Versioning
Follows **Semantic Versioning**. Current release: **v1.4.4**. Follows **Semantic Versioning**. Current release: **v1.4.2**.
See `CHANGELOG.md` for full history. See `CHANGELOG.md` for full history.
--- ---

View File

@@ -2,32 +2,19 @@
echo Building series3-watcher.exe... echo Building series3-watcher.exe...
pip install pyinstaller pystray Pillow pip install pyinstaller pystray Pillow
REM Extract version from series3_watcher.py (looks for: VERSION = "1.4.2")
for /f "tokens=3 delims= " %%V in ('findstr /C:"VERSION = " series3_watcher.py') do set RAW_VER=%%V
set VERSION=%RAW_VER:"=%
set EXE_NAME=series3-watcher-%VERSION%
echo Version: %VERSION%
echo Output: dist\%EXE_NAME%.exe
REM Check whether icon.ico exists alongside this script. REM Check whether icon.ico exists alongside this script.
REM If it does, embed it as the .exe icon AND bundle it as a data file REM If it does, embed it as the .exe icon AND bundle it as a data file
REM so the tray overlay can load it at runtime. REM so the tray overlay can load it at runtime.
if exist "%~dp0icon.ico" ( if exist "%~dp0icon.ico" (
pyinstaller --onefile --windowed --name "%EXE_NAME%" ^ pyinstaller --onefile --windowed --name series3-watcher ^
--icon="%~dp0icon.ico" ^ --icon="%~dp0icon.ico" ^
--add-data "%~dp0icon.ico;." ^ --add-data "%~dp0icon.ico;." ^
series3_tray.py series3_tray.py
) else ( ) else (
echo [INFO] icon.ico not found -- building without custom icon. echo [INFO] icon.ico not found -- building without custom icon.
pyinstaller --onefile --windowed --name "%EXE_NAME%" series3_tray.py pyinstaller --onefile --windowed --name series3-watcher series3_tray.py
) )
REM Copy versioned exe to plain name for Inno Setup
copy /Y "dist\%EXE_NAME%.exe" "dist\series3-watcher.exe"
echo. echo.
echo Done. echo Done. Check dist\series3-watcher.exe
echo Gitea upload: dist\%EXE_NAME%.exe
echo Inno Setup: dist\series3-watcher.exe (copy of above)
pause pause

View File

@@ -14,6 +14,8 @@ MAX_EVENT_AGE_DAYS = 365
# Scanning # Scanning
SCAN_INTERVAL_SECONDS = 30 SCAN_INTERVAL_SECONDS = 30
OK_HOURS = 12
MISSING_HOURS = 24
# Logging # Logging
ENABLE_LOGGING = True ENABLE_LOGGING = True
@@ -27,8 +29,3 @@ MLG_HEADER_BYTES = 2048 ; used for unit-id extraction
DEEP_SNIFF = True ; toggle deep sniff on/off DEEP_SNIFF = True ; toggle deep sniff on/off
SNIFF_BYTES = 65536 ; max bytes to scan for Notes/Cal SNIFF_BYTES = 65536 ; max bytes to scan for Notes/Cal
# Auto-updater source: gitea (default) or url
UPDATE_SOURCE = gitea
# If UPDATE_SOURCE = url, set UPDATE_URL to the base URL of the update server (e.g. terra-view)
UPDATE_URL =

View File

@@ -3,7 +3,7 @@
[Setup] [Setup]
AppName=Series 3 Watcher AppName=Series 3 Watcher
AppVersion=1.4.4 AppVersion=1.4.2
AppPublisher=Terra-Mechanics Inc. AppPublisher=Terra-Mechanics Inc.
DefaultDirName={pf}\Series3Watcher DefaultDirName={pf}\Series3Watcher
DefaultGroupName=Series 3 Watcher DefaultGroupName=Series 3 Watcher

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — System Tray Launcher v1.4.4 Series 3 Watcher — System Tray Launcher v1.4.2
Requires: pystray, Pillow, tkinter (stdlib) Requires: pystray, Pillow, tkinter (stdlib)
Run with: pythonw series3_tray.py (no console window) Run with: pythonw series3_tray.py (no console window)
@@ -16,7 +16,6 @@ import sys
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import configparser
import urllib.request import urllib.request
import urllib.error import urllib.error
from datetime import datetime from datetime import datetime
@@ -53,24 +52,11 @@ def _version_tuple(v):
return tuple(parts) return tuple(parts)
def _update_log(msg): def check_for_update():
"""Append a timestamped line to the watcher log for update events.""" """
try: Query Gitea for the latest release.
log_path = os.path.join( Returns (tag, download_url) if an update is available, else (None, None).
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "", """
"Series3Watcher", "agent_logs", "series3_watcher.log"
)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a") as f:
f.write("[{}] [updater] {}\n".format(
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg
))
except Exception:
pass
def _check_for_update_gitea():
"""Query Gitea API for latest release. Returns (tag, download_url) or (None, None)."""
import json as _json import json as _json
try: try:
req = urllib.request.Request( req = urllib.request.Request(
@@ -85,78 +71,21 @@ def _check_for_update_gitea():
tag = latest.get("tag_name", "") tag = latest.get("tag_name", "")
if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION): if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION):
return None, None return None, None
# Find the .exe asset
assets = latest.get("assets", []) assets = latest.get("assets", [])
for asset in assets: for asset in assets:
name = asset.get("name", "").lower() name = asset.get("name", "")
if name.endswith(".exe") and "setup" not in name: if name.lower().endswith(".exe"):
return tag, asset.get("browser_download_url") return tag, asset.get("browser_download_url")
_update_log("Newer release {} found but no valid .exe asset".format(tag))
return tag, None return tag, None
except Exception as e:
_update_log("check_for_update (gitea) failed: {}".format(e))
return None, None
def _check_for_update_url(base_url):
"""Query a custom URL server for latest version. Returns (tag, download_url) or (None, None)."""
if not base_url:
_update_log("UPDATE_SOURCE=url but UPDATE_URL is empty — skipping")
return None, None
try:
ver_url = base_url.rstrip("/") + "/api/updates/series3-watcher/version.txt"
req = urllib.request.Request(
ver_url,
headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)},
)
with urllib.request.urlopen(req, timeout=8) as resp:
tag = resp.read().decode("utf-8").strip()
if not tag:
return None, None
if _version_tuple(tag) <= _version_tuple(_CURRENT_VERSION):
return None, None
exe_url = base_url.rstrip("/") + "/api/updates/series3-watcher/series3-watcher.exe"
return tag, exe_url
except Exception as e:
_update_log("check_for_update (url mode) failed: {}".format(e))
return None, None
def check_for_update():
"""
Check for an update using the configured source (gitea, url, or disabled).
Reads UPDATE_SOURCE and UPDATE_URL from config.ini at check time.
Returns (tag, download_url) if an update is available, else (None, None).
Returns (None, None) immediately if UPDATE_SOURCE = disabled.
"""
try:
cp = configparser.ConfigParser(inline_comment_prefixes=(";", "#"))
cp.optionxform = str
cp.read(CONFIG_PATH, encoding="utf-8")
section = cp["agent"] if cp.has_section("agent") else {}
update_source = section.get("UPDATE_SOURCE", "gitea").strip().lower()
update_url = section.get("UPDATE_URL", "").strip()
except Exception: except Exception:
update_source = "gitea"
update_url = ""
if update_source == "disabled":
return None, None return None, None
_update_log("Checking for update (source={}, version={})".format(
update_source, _CURRENT_VERSION
))
if update_source == "url":
return _check_for_update_url(update_url)
else:
return _check_for_update_gitea()
def apply_update(download_url): def apply_update(download_url):
""" """
Download new .exe to a temp file, validate it, write a swap .bat, launch it, exit. Download new .exe to a temp file, write a swap .bat, launch it, exit.
The bat backs up the old exe, retries the copy up to 5 times if locked, then relaunches. The bat waits for us to exit, then swaps the files and relaunches.
The .exe.old backup is left in place as a rollback copy.
""" """
exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0]) exe_path = os.path.abspath(sys.executable if getattr(sys, "frozen", False) else sys.argv[0])
@@ -164,8 +93,6 @@ def apply_update(download_url):
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="s3w_update_") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".exe", prefix="s3w_update_")
os.close(tmp_fd) os.close(tmp_fd)
_update_log("Downloading update from: {}".format(download_url))
req = urllib.request.Request( req = urllib.request.Request(
download_url, download_url,
headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)}, headers={"User-Agent": "series3-watcher/{}".format(_CURRENT_VERSION)},
@@ -174,79 +101,26 @@ def apply_update(download_url):
with open(tmp_path, "wb") as f: with open(tmp_path, "wb") as f:
f.write(resp.read()) f.write(resp.read())
# Three-layer validation before touching the live exe
try:
dl_size = os.path.getsize(tmp_path)
current_size = os.path.getsize(exe_path)
_update_log("Download complete ({} bytes), validating...".format(dl_size))
if dl_size < 100 * 1024:
_update_log("Validation failed: too small ({} bytes) — aborting".format(dl_size))
os.remove(tmp_path)
return False
if current_size > 0 and dl_size < current_size * 0.5:
_update_log("Validation failed: suspiciously small ({} bytes vs current {} bytes) — aborting".format(
dl_size, current_size
))
os.remove(tmp_path)
return False
with open(tmp_path, "rb") as _f:
magic = _f.read(2)
if magic != b"MZ":
_update_log("Validation failed: not a valid Windows exe (bad magic bytes) — aborting")
os.remove(tmp_path)
return False
_update_log("Validation passed ({} bytes, MZ ok)".format(dl_size))
except Exception as e:
_update_log("Validation error: {} — aborting".format(e))
try:
os.remove(tmp_path)
except Exception:
pass
return False
bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="s3w_swap_") bat_fd, bat_path = tempfile.mkstemp(suffix=".bat", prefix="s3w_swap_")
os.close(bat_fd) os.close(bat_fd)
bat_content = ( bat_content = (
"@echo off\r\n" "@echo off\r\n"
"ping 127.0.0.1 -n 4 > nul\r\n" "ping 127.0.0.1 -n 4 > nul\r\n"
"copy /Y \"{exe}\" \"{exe}.old\"\r\n"
"set RETRIES=0\r\n"
":retry\r\n"
"copy /Y \"{new}\" \"{exe}\"\r\n" "copy /Y \"{new}\" \"{exe}\"\r\n"
"if errorlevel 1 (\r\n"
" set /a RETRIES+=1\r\n"
" if %RETRIES% GEQ 5 goto fail\r\n"
" ping 127.0.0.1 -n 3 > nul\r\n"
" goto retry\r\n"
")\r\n"
"start \"\" \"{exe}\"\r\n" "start \"\" \"{exe}\"\r\n"
"del \"{new}\"\r\n"
"del \"%~f0\"\r\n" "del \"%~f0\"\r\n"
"exit /b 0\r\n"
":fail\r\n"
"del \"{new}\"\r\n"
"del \"%~f0\"\r\n"
"exit /b 1\r\n"
).format(new=tmp_path, exe=exe_path) ).format(new=tmp_path, exe=exe_path)
with open(bat_path, "w") as f: with open(bat_path, "w") as f:
f.write(bat_content) f.write(bat_content)
_update_log("Launching swap bat — exiting for update")
subprocess.Popen( subprocess.Popen(
["cmd", "/C", bat_path], ["cmd", "/C", bat_path],
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
) )
return True return True
except Exception as e: except Exception:
_update_log("apply_update failed: {}".format(e))
return False return False
@@ -478,7 +352,7 @@ class WatcherTray:
self._do_update() self._do_update()
return # exit loop; swap bat will relaunch return # exit loop; swap bat will relaunch
# Periodic update check (every ~5 min) # Periodic Gitea update check (every ~5 min)
update_check_counter += 1 update_check_counter += 1
if update_check_counter >= 30: if update_check_counter >= 30:
update_check_counter = 0 update_check_counter = 0
@@ -505,7 +379,7 @@ class WatcherTray:
self.stop_event.set() self.stop_event.set()
if self._icon is not None: if self._icon is not None:
self._icon.stop() self._icon.stop()
# If update failed, keep running silently — error is in the log # If update failed, just keep running silently
# --- Entry point --- # --- Entry point ---

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — v1.4.3 Series 3 Watcher — v1.4.2
Environment: Environment:
- Python 3.8 (Windows 7 compatible) - Python 3.8 (Windows 7 compatible)
@@ -60,6 +60,8 @@ def load_config(path: str) -> Dict[str, Any]:
return { return {
"WATCH_PATH": get_str("SERIES3_PATH", r"C:\Blastware 10\Event\autocall home"), "WATCH_PATH": get_str("SERIES3_PATH", r"C:\Blastware 10\Event\autocall home"),
"SCAN_INTERVAL": get_int("SCAN_INTERVAL_SECONDS", 300), "SCAN_INTERVAL": get_int("SCAN_INTERVAL_SECONDS", 300),
"OK_HOURS": float(get_int("OK_HOURS", 12)),
"MISSING_HOURS": float(get_int("MISSING_HOURS", 24)),
"ENABLE_LOGGING": get_bool("ENABLE_LOGGING", True), "ENABLE_LOGGING": get_bool("ENABLE_LOGGING", True),
"LOG_FILE": get_str("LOG_FILE", os.path.join( "LOG_FILE": get_str("LOG_FILE", os.path.join(
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\", os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
@@ -76,10 +78,6 @@ def load_config(path: str) -> Dict[str, Any]:
"API_INTERVAL_SECONDS": get_int("API_INTERVAL_SECONDS", 300), "API_INTERVAL_SECONDS": get_int("API_INTERVAL_SECONDS", 300),
"SOURCE_ID": get_str("SOURCE_ID", gethostname()), "SOURCE_ID": get_str("SOURCE_ID", gethostname()),
"SOURCE_TYPE": get_str("SOURCE_TYPE", "series3_watcher"), "SOURCE_TYPE": get_str("SOURCE_TYPE", "series3_watcher"),
# Auto-updater source
"UPDATE_SOURCE": get_str("UPDATE_SOURCE", "gitea"),
"UPDATE_URL": get_str("UPDATE_URL", ""),
} }
@@ -217,7 +215,7 @@ def scan_latest(
# --- API heartbeat / SFM telemetry helpers --- # --- API heartbeat / SFM telemetry helpers ---
VERSION = "1.4.4" VERSION = "1.4.2"
def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]: def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]:

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — Settings Dialog v1.4.4 Series 3 Watcher — Settings Dialog v1.4.2
Provides a Tkinter settings dialog that doubles as a first-run wizard. Provides a Tkinter settings dialog that doubles as a first-run wizard.
@@ -30,6 +30,8 @@ DEFAULTS = {
"SERIES3_PATH": r"C:\Blastware 10\Event\autocall home", "SERIES3_PATH": r"C:\Blastware 10\Event\autocall home",
"MAX_EVENT_AGE_DAYS": "365", "MAX_EVENT_AGE_DAYS": "365",
"SCAN_INTERVAL_SECONDS":"300", "SCAN_INTERVAL_SECONDS":"300",
"OK_HOURS": "12",
"MISSING_HOURS": "24",
"MLG_HEADER_BYTES": "2048", "MLG_HEADER_BYTES": "2048",
"ENABLE_LOGGING": "true", "ENABLE_LOGGING": "true",
"LOG_FILE": os.path.join( "LOG_FILE": os.path.join(
@@ -37,10 +39,6 @@ DEFAULTS = {
"Series3Watcher", "agent_logs", "series3_watcher.log" "Series3Watcher", "agent_logs", "series3_watcher.log"
), ),
"LOG_RETENTION_DAYS": "30", "LOG_RETENTION_DAYS": "30",
# Auto-updater
"UPDATE_SOURCE": "gitea",
"UPDATE_URL": "",
} }
@@ -227,16 +225,14 @@ class SettingsDialog:
# Scanning # Scanning
self.var_scan_interval = tk.StringVar(value=v["SCAN_INTERVAL_SECONDS"]) self.var_scan_interval = tk.StringVar(value=v["SCAN_INTERVAL_SECONDS"])
self.var_ok_hours = tk.StringVar(value=v["OK_HOURS"])
self.var_missing_hours = tk.StringVar(value=v["MISSING_HOURS"])
self.var_mlg_header_bytes = tk.StringVar(value=v["MLG_HEADER_BYTES"]) self.var_mlg_header_bytes = tk.StringVar(value=v["MLG_HEADER_BYTES"])
# Logging # Logging
self.var_enable_logging = tk.BooleanVar(value=v["ENABLE_LOGGING"].lower() in ("1","true","yes","on")) self.var_enable_logging = tk.BooleanVar(value=v["ENABLE_LOGGING"].lower() in ("1","true","yes","on"))
self.var_log_retention_days = tk.StringVar(value=v["LOG_RETENTION_DAYS"]) self.var_log_retention_days = tk.StringVar(value=v["LOG_RETENTION_DAYS"])
# Updates
self.var_update_source = tk.StringVar(value=v["UPDATE_SOURCE"].lower() if v["UPDATE_SOURCE"].lower() in ("gitea", "url", "disabled") else "gitea")
self.var_update_url = tk.StringVar(value=v["UPDATE_URL"])
# --- UI construction --- # --- UI construction ---
def _build_ui(self): def _build_ui(self):
@@ -263,7 +259,6 @@ class SettingsDialog:
self._build_tab_paths(nb) self._build_tab_paths(nb)
self._build_tab_scanning(nb) self._build_tab_scanning(nb)
self._build_tab_logging(nb) self._build_tab_logging(nb)
self._build_tab_updates(nb)
# Buttons # Buttons
btn_frame = tk.Frame(outer) btn_frame = tk.Frame(outer)
@@ -394,75 +389,15 @@ class SettingsDialog:
def _build_tab_scanning(self, nb): def _build_tab_scanning(self, nb):
f = self._tab_frame(nb, "Scanning") f = self._tab_frame(nb, "Scanning")
_add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600) _add_label_spinbox(f, 0, "Scan Interval (sec)", self.var_scan_interval, 10, 3600)
_add_label_spinbox(f, 1, "MLG Header Bytes", self.var_mlg_header_bytes, 256, 65536) _add_label_spinbox(f, 1, "OK Hours", self.var_ok_hours, 1, 168)
_add_label_spinbox(f, 2, "Missing Hours", self.var_missing_hours, 1, 168)
_add_label_spinbox(f, 3, "MLG Header Bytes", self.var_mlg_header_bytes, 256, 65536)
def _build_tab_logging(self, nb): def _build_tab_logging(self, nb):
f = self._tab_frame(nb, "Logging") f = self._tab_frame(nb, "Logging")
_add_label_check(f, 0, "Enable Logging", self.var_enable_logging) _add_label_check(f, 0, "Enable Logging", self.var_enable_logging)
_add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365) _add_label_spinbox(f, 1, "Log Retention (days)", self.var_log_retention_days, 1, 365)
def _build_tab_updates(self, nb):
f = self._tab_frame(nb, "Updates")
tk.Label(
f,
text="Auto-Update Source",
anchor="w",
).grid(row=0, column=0, sticky="w", padx=(8, 4), pady=(8, 2))
radio_frame = tk.Frame(f)
radio_frame.grid(row=0, column=1, sticky="w", padx=(0, 8), pady=(8, 2))
rb_gitea = ttk.Radiobutton(
radio_frame, text="Gitea (default)",
variable=self.var_update_source, value="gitea",
command=self._on_update_source_change,
)
rb_gitea.grid(row=0, column=0, sticky="w", padx=(0, 12))
rb_url = ttk.Radiobutton(
radio_frame, text="Custom URL",
variable=self.var_update_source, value="url",
command=self._on_update_source_change,
)
rb_url.grid(row=0, column=1, sticky="w", padx=(0, 12))
rb_disabled = ttk.Radiobutton(
radio_frame, text="Disabled",
variable=self.var_update_source, value="disabled",
command=self._on_update_source_change,
)
rb_disabled.grid(row=0, column=2, sticky="w")
tk.Label(f, text="Update Server URL", anchor="w").grid(
row=1, column=0, sticky="w", padx=(8, 4), pady=4
)
self._update_url_entry = ttk.Entry(f, textvariable=self.var_update_url, width=42)
self._update_url_entry.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4)
hint_text = (
"Gitea: checks the Gitea release page automatically every 5 minutes.\n"
"Custom URL: fetches version.txt and series3-watcher.exe from a web\n"
"server — use when Gitea is not reachable (e.g. terra-view URL).\n"
"Disabled: no automatic update checks. Remote push from terra-view\n"
"still works when disabled."
)
tk.Label(f, text=hint_text, justify="left", fg="#555555",
wraplength=380).grid(
row=2, column=0, columnspan=2, sticky="w", padx=(8, 8), pady=(4, 8)
)
# Set initial state of URL entry
self._on_update_source_change()
def _on_update_source_change(self):
"""Enable/disable the URL entry based on selected update source."""
if self.var_update_source.get() == "url":
self._update_url_entry.config(state="normal")
else:
self._update_url_entry.config(state="disabled")
# --- Validation helpers --- # --- Validation helpers ---
def _get_int_var(self, var, name, min_val, max_val, default): def _get_int_var(self, var, name, min_val, max_val, default):
@@ -492,6 +427,8 @@ class SettingsDialog:
(self.var_api_interval, "API Interval", 30, 3600, 300), (self.var_api_interval, "API Interval", 30, 3600, 300),
(self.var_max_event_age_days, "Max Event Age Days", 1, 3650, 365), (self.var_max_event_age_days, "Max Event Age Days", 1, 3650, 365),
(self.var_scan_interval, "Scan Interval", 10, 3600, 300), (self.var_scan_interval, "Scan Interval", 10, 3600, 300),
(self.var_ok_hours, "OK Hours", 1, 168, 12),
(self.var_missing_hours, "Missing Hours", 1, 168, 24),
(self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048), (self.var_mlg_header_bytes, "MLG Header Bytes", 256, 65536, 2048),
(self.var_log_retention_days, "Log Retention Days", 1, 365, 30), (self.var_log_retention_days, "Log Retention Days", 1, 365, 30),
] ]
@@ -524,12 +461,12 @@ class SettingsDialog:
"SERIES3_PATH": self.var_series3_path.get().strip(), "SERIES3_PATH": self.var_series3_path.get().strip(),
"MAX_EVENT_AGE_DAYS": str(int_values["Max Event Age Days"]), "MAX_EVENT_AGE_DAYS": str(int_values["Max Event Age Days"]),
"SCAN_INTERVAL_SECONDS":str(int_values["Scan Interval"]), "SCAN_INTERVAL_SECONDS":str(int_values["Scan Interval"]),
"OK_HOURS": str(int_values["OK Hours"]),
"MISSING_HOURS": str(int_values["Missing Hours"]),
"MLG_HEADER_BYTES": str(int_values["MLG Header Bytes"]), "MLG_HEADER_BYTES": str(int_values["MLG Header Bytes"]),
"ENABLE_LOGGING": "true" if self.var_enable_logging.get() else "false", "ENABLE_LOGGING": "true" if self.var_enable_logging.get() else "false",
"LOG_FILE": self.var_log_file.get().strip(), "LOG_FILE": self.var_log_file.get().strip(),
"LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]), "LOG_RETENTION_DAYS": str(int_values["Log Retention Days"]),
"UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea",
"UPDATE_URL": self.var_update_url.get().strip(),
} }
try: try: