17 Commits

Author SHA1 Message Date
1abdc13645 Merge pull request 'bump to 1.4.4 (the nonupdate update)' (#6) from dev into main
Reviewed-on: #6
2026-03-17 21:54:59 -04:00
serversdwn
010016d515 bump to 1.4.4 (the nonupdate update)
chore: clean up code, deprecate status config.
2026-03-17 21:54:15 -04:00
f790b21808 Merge pull request 'merge v1.4.3' (#5) from dev into main
Reviewed-on: #5
2026-03-17 21:11:41 -04:00
serversdwn
439feb9942 Feat: Update settings tab implemented.
Auto-updates now configurable (URL, source (gitea or private server), log activity for auto updates.
fix: Update now hardened to prevent installation of corrupt or incorrect .exe files. (security to be hardened in the future)
2026-03-17 21:08:37 -04:00
0bea6ca4ea Merge pull request 'v1.4.2' (#3) from dev into main
Reviewed-on: #3
2026-03-17 16:15:22 -04:00
serversdwn
d2a8c2d928 Update to v1.4.2
Feat: tray icon now shows API/watcher health rather than unit ages. unit submenu removed, now handled by recieving software.

Chore: remove old unneeded code from deprecated features (console colorization, Missing/pending age limits)
2026-03-17 16:02:24 -04:00
serversdwn
3303e22843 fix: update version to v1.4.2 and improve status reporting in tray
feat: now sends watcher_status via payload to terra-view
2026-03-17 15:23:55 -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
serversdwn
d2fd3b7182 docs: v1.4.1 changelog entry 2026-03-17 14:29:46 -04:00
serversdwn
1d94c5dd04 docs: delete deprecated client specific readme 2026-03-17 14:26:59 -04:00
serversdwn
814b6f915e fix: settings dialog now runs in its own thread, increasing responsiveness. 2026-03-17 13:36:35 -04:00
serversdwn
9cfdebe553 fix: watcher correctly uses AppData directory, not program files. 2026-03-17 03:40:04 -04:00
serversdwn
f773e1dac9 fix: tray icon more legible 2026-03-17 03:27:53 -04:00
serversdwn
326658ed26 doc: readme bummped to 1.4.1 2026-03-17 01:23:01 -04:00
serversdwn
504ee1d470 bugfix: log directory now writes to appdata folder, avoiding permissions issues. log folder accessible from tray icon.
doc: deployment/build doc added
2026-03-17 01:10:40 -04:00
serversdwn
e67b6eb89f Feat: v1.4.1 - Windows installer updated. 2026-03-16 20:00:42 -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
12 changed files with 619 additions and 201 deletions

2
.gitignore vendored
View File

@@ -22,7 +22,9 @@ env/
# Distribution / packaging # Distribution / packaging
build/ build/
dist/ dist/
Output/
*.egg-info/ *.egg-info/
*.spec
# ------------------------- # -------------------------
# Logs + runtime artifacts # Logs + runtime artifacts

117
BUILDING.md Normal file
View File

@@ -0,0 +1,117 @@
# Building & Releasing Series 3 Watcher
## Prerequisites (Win7 VM — do this once)
- Python 3.7.2 (or 3.8.10 if SP1 is installed)
- Inno Setup 6 — installed at `C:\Program Files (x86)\Inno Setup 6\`
- PyInstaller, pystray, Pillow — installed automatically by `build.bat`
The Win7 VM is the build machine. All builds must happen there to ensure
compatibility with the production DL2 computer.
---
## First-Time Install on a New Machine
Do this when setting up a brand new machine that has never had the watcher before.
**Step 1 — Build the .exe (on the Win7 VM)**
1. Copy the `series3-watcher/` folder to the VM (shared folder, USB, etc.)
2. Double-click `build.bat`
3. Wait for it to finish — output: `dist\series3-watcher.exe`
**Step 2 — Build the installer (on the Win7 VM)**
1. Open `installer.iss` in Inno Setup Compiler
2. Click **Build → Compile**
3. Output: `Output\series3-watcher-setup.exe`
**Step 3 — Create a Gitea release**
1. On your main machine, go to `https://gitea.serversdown.net/serversdown/series3-watcher`
2. Click **Releases → New Release**
3. Set the tag to match the version in `series3_watcher.py` (e.g. `v1.4.1`)
4. Upload **both** files as release assets:
- `dist\series3-watcher.exe` — used by the auto-updater on existing installs
- `Output\series3-watcher-setup.exe` — used for fresh installs
**Step 4 — Install on the target machine**
1. Download `series3-watcher-setup.exe` from the Gitea release
2. Run it on the target machine — installs to `C:\Program Files\Series3Watcher\`
3. The watcher launches automatically after install (or on next login)
4. The Setup Wizard appears on first run — fill in the Terra-View URL and Blastware path
---
## Releasing an Update (existing machines auto-update)
Do this for any code change — bug fix, new feature, etc.
**Step 1 — Bump the version**
In `series3_watcher.py`, update the `VERSION` string:
```python
VERSION = "1.4.2" # increment appropriately
```
Also update `installer.iss`:
```
AppVersion=1.4.2
```
**Step 2 — Build the .exe (on the Win7 VM)**
1. Pull the latest code to the VM
2. Double-click `build.bat`
3. Output: `dist\series3-watcher.exe`
> For hotfixes you can skip Inno Setup — existing machines only need the `.exe`.
> Only rebuild the installer if you need a fresh install package for a new machine.
**Step 3 — Create a Gitea release**
1. Go to `https://gitea.serversdown.net/serversdown/series3-watcher`
2. Click **Releases → New Release**
3. Tag must match the new version exactly (e.g. `v1.4.2`) — the auto-updater
compares this tag against its own version to decide whether to update
4. Upload `dist\series3-watcher.exe` as a release asset
5. Optionally upload `Output\series3-watcher-setup.exe` if you rebuilt the installer
**Step 4 — Done**
Existing installs check Gitea every ~5 minutes. When they see the new tag they
will download `series3-watcher.exe`, swap it in place, and relaunch silently.
No user action required on the target machine.
---
## Version Numbering
Follows Semantic Versioning: `MAJOR.MINOR.PATCH`
| Change type | Example |
|-------------|---------|
| Bug fix / text change | `1.4.1 → 1.4.2` |
| New feature | `1.4.x → 1.5.0` |
| Breaking change | `1.x.x → 2.0.0` |
---
## Files That Go in the Gitea Release
| File | Required for | Notes |
|------|-------------|-------|
| `dist\series3-watcher.exe` | Auto-updates on existing machines | Always upload this |
| `Output\series3-watcher-setup.exe` | Fresh installs on new machines | Only needed for new deployments |
---
## Files That Are NOT Committed to Git
- `dist/` — PyInstaller output
- `Output/` — Inno Setup output
- `build/` — PyInstaller temp files
- `*.spec` — PyInstaller spec file
- `config.ini` — machine-specific, never commit
- `agent_logs/` — log files

View File

@@ -6,6 +6,50 @@ 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
### Changed
- Tray icon color now reflects watcher + API health rather than unit ages — green=API OK, amber=API disabled, red=API failing, purple=watcher error.
- Status menu text updated to show `Running — API OK | N unit(s) | scan Xm ago`.
- Units submenu removed from tray — status tracking for individual units is handled by terra-view, not the watcher.
- Unit list still logged to console and log file for debugging, but no OK/Pending/Missing judgement applied.
- `watcher_status` field added to heartbeat payload so terra-view receives accurate watcher health data.
## [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 ## [1.4.0] - 2026-03-12
### Added ### Added

View File

@@ -1,4 +1,4 @@
# Series 3 Watcher v1.4 # Series 3 Watcher v1.4.4
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.
@@ -23,21 +23,12 @@ Updates can also be pushed remotely from terra-view → **Settings → Developer
--- ---
## Building from Source ## Building & Releasing
Prerequisites (on a **Python 3.8** machine — required for Win7 compatibility): See [BUILDING.md](BUILDING.md) for the full step-by-step process covering:
- First-time build and installer creation
``` - Publishing a release to Gitea
pip install pystray Pillow pyinstaller - Releasing hotfix updates (auto-updater picks them up automatically)
```
Then run:
```
build.bat
```
This produces `dist\series3-watcher.exe`. To build the installer, open `installer.iss` in [Inno Setup](https://jrsoftware.org/isinfo.php) and click Build.
--- ---
@@ -62,7 +53,7 @@ All settings live in `config.ini`. The Setup Wizard covers every field, but here
| Key | Description | | Key | Description |
|-----|-------------| |-----|-------------|
| `API_ENABLED` | `true` to send heartbeats to terra-view | | `API_ENABLED` | `true` to send heartbeats to terra-view |
| `API_URL` | terra-view heartbeat endpoint, e.g. `http://192.168.1.10:8000/api/series3/heartbeat` | | `API_URL` | Terra-View base URL, e.g. `http://192.168.1.10:8000` — the `/api/series3/heartbeat` endpoint is appended automatically |
| `API_INTERVAL_SECONDS` | How often to POST (default `300`) | | `API_INTERVAL_SECONDS` | How often to POST (default `300`) |
| `SOURCE_ID` | Identifier for this machine (defaults to hostname) | | `SOURCE_ID` | Identifier for this machine (defaults to hostname) |
| `SOURCE_TYPE` | Always `series3_watcher` | | `SOURCE_TYPE` | Always `series3_watcher` |
@@ -80,8 +71,6 @@ 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 |
@@ -91,7 +80,13 @@ 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`) |
| `COLORIZE` | ANSI colours in console — leave `false` on Win7 |
### 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. |
--- ---
@@ -126,7 +121,7 @@ To view connected watchers: **Settings → Developer → Watcher Manager**.
## Versioning ## Versioning
Follows **Semantic Versioning**. Current release: **v1.4.0**. Follows **Semantic Versioning**. Current release: **v1.4.4**.
See `CHANGELOG.md` for full history. See `CHANGELOG.md` for full history.
--- ---

View File

@@ -1,26 +0,0 @@
# Series 3 Watcher — v1_0(py38-safe) for DL2
**Target**: Windows 7 + Python 3.8.10
**Baseline**: v5_4 (no logic changes)
## Files
- series3_agent_v1_0_py38.py — main script (py38-safe)
- config.ini — your config (already included)
- series3_roster.csv — your roster (already included, this auto updates from a URL to a dropbox file)
- requirements.txt — none beyond stdlib
## Install
1) Create `C:\SeismoEmitter\` on DL2
2) Extract this ZIP into that folder
3) Open CMD:
```cmd
cd C:\SeismoEmitter
python series3_agent_v1_0_py38.py
```
(If the console shows escape codes on Win7, set `COLORIZE = False` in `config.ini`.)
## Quick validation
- Heartbeat prints Local/UTC timestamps
- One line per active roster unit with OK/Pending/Missing, Age, Last, File
- Unexpected units block shows .MLG not in roster
- agent.log rotates per LOG_RETENTION_DAYS

View File

@@ -2,15 +2,32 @@
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, include it; otherwise build without a custom icon. 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.
if exist "%~dp0icon.ico" ( if exist "%~dp0icon.ico" (
pyinstaller --onefile --windowed --name series3-watcher --icon="%~dp0icon.ico" series3_tray.py pyinstaller --onefile --windowed --name "%EXE_NAME%" ^
--icon="%~dp0icon.ico" ^
--add-data "%~dp0icon.ico;." ^
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 series3-watcher series3_tray.py pyinstaller --onefile --windowed --name "%EXE_NAME%" 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. Check dist\series3-watcher.exe echo Done.
echo Gitea upload: dist\%EXE_NAME%.exe
echo Inno Setup: dist\series3-watcher.exe (copy of above)
pause pause

View File

@@ -14,17 +14,12 @@ 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
LOG_FILE = C:\SeismoEmitter\agent_logs\series3_watcher.log LOG_FILE = C:\Users\%USERNAME%\AppData\Local\Series3Watcher\agent_logs\series3_watcher.log
LOG_RETENTION_DAYS = 30 LOG_RETENTION_DAYS = 30
# Console colors - (Doesn't work on windows 7)
COLORIZE = FALSE
# .MLG parsing # .MLG parsing
MLG_HEADER_BYTES = 2048 ; used for unit-id extraction MLG_HEADER_BYTES = 2048 ; used for unit-id extraction
@@ -32,3 +27,8 @@ 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 =

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

View File

@@ -3,7 +3,7 @@
[Setup] [Setup]
AppName=Series 3 Watcher AppName=Series 3 Watcher
AppVersion=1.4.0 AppVersion=1.4.4
AppPublisher=Terra-Mechanics Inc. AppPublisher=Terra-Mechanics Inc.
DefaultDirName={pf}\Series3Watcher DefaultDirName={pf}\Series3Watcher
DefaultGroupName=Series 3 Watcher DefaultGroupName=Series 3 Watcher
@@ -16,6 +16,10 @@ PrivilegesRequired=admin
[Tasks] [Tasks]
Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:"; Flags: unchecked Name: "desktopicon"; Description: "Create a &desktop icon"; GroupDescription: "Additional icons:"; Flags: unchecked
[Dirs]
; Create the agent_logs folder so the watcher can write logs on first run
Name: "{app}\agent_logs"
[Files] [Files]
; Main executable — built by build.bat / PyInstaller ; Main executable — built by build.bat / PyInstaller
Source: "dist\series3-watcher.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "dist\series3-watcher.exe"; DestDir: "{app}"; Flags: ignoreversion

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — System Tray Launcher v1.4.0 Series 3 Watcher — System Tray Launcher v1.4.4
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,6 +16,7 @@ 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
@@ -52,11 +53,24 @@ def _version_tuple(v):
return tuple(parts) return tuple(parts)
def check_for_update(): def _update_log(msg):
""" """Append a timestamped line to the watcher log for update events."""
Query Gitea for the latest release. try:
Returns (tag, download_url) if an update is available, else (None, None). log_path = os.path.join(
""" 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(
@@ -71,21 +85,78 @@ def check_for_update():
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", "") name = asset.get("name", "").lower()
if name.lower().endswith(".exe"): if name.endswith(".exe") and "setup" not in name:
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: except Exception as e:
_update_log("check_for_update (gitea) failed: {}".format(e))
return None, None 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:
update_source = "gitea"
update_url = ""
if update_source == "disabled":
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, write a swap .bat, launch it, exit. Download new .exe to a temp file, validate it, write a swap .bat, launch it, exit.
The bat waits for us to exit, then swaps the files and relaunches. The bat backs up the old exe, retries the copy up to 5 times if locked, then 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])
@@ -93,6 +164,8 @@ 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)},
@@ -101,33 +174,99 @@ 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: except Exception as e:
_update_log("apply_update failed: {}".format(e))
return False return False
# --------------- Paths --------------- # --------------- Paths ---------------
HERE = os.path.dirname(os.path.abspath(__file__)) # Executable location — used for bundled assets (icon.ico etc.)
CONFIG_PATH = os.path.join(HERE, "config.ini") if getattr(sys, "frozen", False):
HERE = os.path.dirname(os.path.abspath(sys.executable))
else:
HERE = os.path.dirname(os.path.abspath(__file__))
# config.ini lives in AppData so normal users can write it without UAC issues.
# Fall back to the exe directory when running from source (dev mode).
if getattr(sys, "frozen", False):
_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or HERE
CONFIG_DIR = os.path.join(_appdata, "Series3Watcher")
os.makedirs(CONFIG_DIR, exist_ok=True)
else:
CONFIG_DIR = HERE
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.ini")
# --------------- Icon drawing --------------- # --------------- Icon drawing ---------------
@@ -144,7 +283,7 @@ ICON_SIZE = 64
def make_icon(status): def make_icon(status):
"""Draw a solid filled circle on a transparent background.""" """Draw a plain colored circle for the system tray — clean and readable at 16px."""
color = COLORS.get(status, COLORS["starting"]) color = COLORS.get(status, COLORS["starting"])
img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0)) img = Image.new("RGBA", (ICON_SIZE, ICON_SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
@@ -233,15 +372,13 @@ class WatcherTray:
# --- Menu item callbacks --- # --- Menu item callbacks ---
def _open_settings(self, icon, item): def _open_settings(self, icon, item):
"""Open the settings dialog. On save, restart watcher thread.""" """Open the settings dialog in its own thread so the tray stays responsive."""
def _run():
from settings_dialog import show_dialog from settings_dialog import show_dialog
saved = show_dialog(CONFIG_PATH, wizard=False) saved = show_dialog(CONFIG_PATH, wizard=False)
if saved: if saved:
self._restart_watcher() self._restart_watcher()
# Rebuild menu so status label refreshes threading.Thread(target=_run, daemon=True, name="settings-dialog").start()
if self._icon is not None:
with self._menu_lock:
self._icon.menu = self._build_menu()
def _open_logs(self, icon, item): def _open_logs(self, icon, item):
log_dir = self.state.get("log_dir") log_dir = self.state.get("log_dir")
@@ -266,50 +403,48 @@ class WatcherTray:
status = self.state.get("status", "starting") status = self.state.get("status", "starting")
last_err = self.state.get("last_error") last_err = self.state.get("last_error")
last_scan = self.state.get("last_scan") last_scan = self.state.get("last_scan")
api_status = self.state.get("api_status", "disabled")
unit_count = len(self.state.get("units", []))
if status == "error": if status == "error":
return "Status: Error — {}".format(last_err or "unknown") return "Error — {}".format(last_err or "unknown")
if status == "starting": if status == "starting":
return "Status: Starting..." return "Starting..."
# Scan age
if last_scan is not None: if last_scan is not None:
age_secs = int((datetime.now() - last_scan).total_seconds()) age_secs = int((datetime.now() - last_scan).total_seconds())
if age_secs < 60: age_str = "{}s ago".format(age_secs) if age_secs < 60 else "{}m ago".format(age_secs // 60)
age_str = "{}s ago".format(age_secs)
else: else:
age_str = "{}m ago".format(age_secs // 60) age_str = "never"
unit_count = len(self.state.get("units", []))
return "Status: {} | {} unit(s) | scan {}".format(
status.upper(), unit_count, age_str
)
return "Status: {}".format(status.upper())
def _build_units_submenu(self): # API status label
units = self.state.get("units", []) if api_status == "ok":
if not units: api_str = "API OK"
items = [pystray.MenuItem("No units detected", None, enabled=False)] elif api_status == "fail":
api_str = "API FAIL"
else: else:
items = [] api_str = "API off"
for u in units:
label = "{uid}{status} ({age:.1f}h ago)".format( return "Running — {} | {} unit(s) | scan {}".format(api_str, unit_count, age_str)
uid=u["uid"],
status=u["status"], def _tray_status(self):
age=u["age_hours"], """Return the icon status key based on watcher + API health."""
) status = self.state.get("status", "starting")
items.append(pystray.MenuItem(label, None, enabled=False)) if status == "error":
return pystray.Menu(*items) return "error"
if status == "starting":
return "starting"
api_status = self.state.get("api_status", "disabled")
if api_status == "fail":
return "missing" # red — API failing
if api_status == "disabled":
return "pending" # amber — running but not reporting
return "ok" # green — running and API good
def _build_menu(self): def _build_menu(self):
# Capture current text/submenu at build time; pystray will call
# callables each render, but static strings are fine for infrequent
# menu rebuilds. We use callables for the dynamic items so that the
# text shown on hover/open is current.
status_text = self._status_text()
units_submenu = self._build_units_submenu()
return pystray.Menu( return pystray.Menu(
pystray.MenuItem(status_text, None, enabled=False), pystray.MenuItem(lambda item: self._status_text(), None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Units", units_submenu),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Settings...", self._open_settings), pystray.MenuItem("Settings...", self._open_settings),
pystray.MenuItem("Open Log Folder", self._open_logs), pystray.MenuItem("Open Log Folder", self._open_logs),
@@ -325,17 +460,17 @@ class WatcherTray:
update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks) update_check_counter = 0 # check for updates every ~5 min (30 * 10s ticks)
while not self.stop_event.is_set(): while not self.stop_event.is_set():
status = self.state.get("status", "starting") icon_status = self._tray_status()
if self._icon is not None: if self._icon is not None:
# Always rebuild menu every cycle so unit list and scan age stay fresh # Always rebuild menu every cycle so status text stays fresh
with self._menu_lock: with self._menu_lock:
self._icon.menu = self._build_menu() self._icon.menu = self._build_menu()
if status != last_status: if icon_status != last_status:
self._icon.icon = make_icon(status) self._icon.icon = make_icon(icon_status)
self._icon.title = "Series 3 Watcher — {}".format(status.upper()) self._icon.title = "Series 3 Watcher — {}".format(self._status_text())
last_status = status last_status = icon_status
# Check if terra-view signalled an update via heartbeat response # Check if terra-view signalled an update via heartbeat response
if self.state.get("update_available"): if self.state.get("update_available"):
@@ -343,7 +478,7 @@ class WatcherTray:
self._do_update() self._do_update()
return # exit loop; swap bat will relaunch return # exit loop; swap bat will relaunch
# Periodic Gitea update check (every ~5 min) # Periodic 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
@@ -370,7 +505,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, just keep running silently # If update failed, keep running silently — error is in the log
# --- Entry point --- # --- Entry point ---

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — v1.4.0 Series 3 Watcher — v1.4.3
Environment: Environment:
- Python 3.8 (Windows 7 compatible) - Python 3.8 (Windows 7 compatible)
@@ -17,6 +17,7 @@ Key Features:
import os import os
import re import re
import sys
import time import time
import json import json
import threading import threading
@@ -59,12 +60,12 @@ 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", r"C:\SeismoEmitter\agent_logs\series3_watcher.log"), "LOG_FILE": get_str("LOG_FILE", os.path.join(
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
"Series3Watcher", "agent_logs", "series3_watcher.log"
)),
"LOG_RETENTION_DAYS": get_int("LOG_RETENTION_DAYS", 30), "LOG_RETENTION_DAYS": get_int("LOG_RETENTION_DAYS", 30),
"COLORIZE": get_bool("COLORIZE", False), # Win7 default off
"MLG_HEADER_BYTES": max(256, min(get_int("MLG_HEADER_BYTES", 2048), 65536)), "MLG_HEADER_BYTES": max(256, min(get_int("MLG_HEADER_BYTES", 2048), 65536)),
"RECENT_WARN_DAYS": get_int("RECENT_WARN_DAYS", 30), "RECENT_WARN_DAYS": get_int("RECENT_WARN_DAYS", 30),
"MAX_EVENT_AGE_DAYS": get_int("MAX_EVENT_AGE_DAYS", 365), "MAX_EVENT_AGE_DAYS": get_int("MAX_EVENT_AGE_DAYS", 365),
@@ -75,13 +76,13 @@ 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", ""),
} }
# --------------- ANSI helpers ---------------
def ansi(enabled: bool, code: str) -> str:
return code if enabled else ""
# --------------- Logging -------------------- # --------------- Logging --------------------
def log_message(path: str, enabled: bool, msg: str) -> None: def log_message(path: str, enabled: bool, msg: str) -> None:
@@ -216,7 +217,19 @@ def scan_latest(
# --- API heartbeat / SFM telemetry helpers --- # --- API heartbeat / SFM telemetry helpers ---
VERSION = "1.4.0" VERSION = "1.4.4"
def _read_log_tail(log_file: str, n: int = 25) -> Optional[list]:
"""Return the last n lines of the log file as a list of strings, or None on failure."""
if not log_file:
return None
try:
with open(log_file, "r", errors="replace") as f:
lines = f.readlines()
return [l.rstrip("\n") for l in lines[-n:]]
except Exception:
return None
def send_api_payload(payload: dict, api_url: str) -> Optional[dict]: def send_api_payload(payload: dict, api_url: str) -> Optional[dict]:
@@ -294,15 +307,23 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
Main watcher loop. Runs in a background thread when launched from the tray. Main watcher loop. Runs in a background thread when launched from the tray.
state dict is written on every scan cycle: state dict is written on every scan cycle:
state["status"] — "ok" | "pending" | "missing" | "error" | "starting" state["status"] — "running" | "error" | "starting"
state["units"] — list of dicts: {uid, status, age_hours, last, fname} state["api_status"] "ok" | "fail" | "disabled"
state["units"] — list of dicts: {uid, age_hours, last, fname}
state["last_scan"] — datetime of last successful scan (or None) state["last_scan"] — datetime of last successful scan (or None)
state["last_api"] — datetime of last successful API POST (or None)
state["last_error"] — last error string (or None) state["last_error"] — last error string (or None)
state["log_dir"] — directory containing the log file state["log_dir"] — directory containing the log file
state["cfg"] — loaded config dict state["cfg"] — loaded config dict
""" """
here = os.path.dirname(__file__) or "." if getattr(sys, "frozen", False):
config_path = os.path.join(here, "config.ini") here = os.path.dirname(os.path.abspath(sys.executable))
_appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or here
config_dir = os.path.join(_appdata, "Series3Watcher")
else:
here = os.path.dirname(os.path.abspath(__file__)) or "."
config_dir = here
config_path = os.path.join(config_dir, "config.ini")
state["status"] = "starting" state["status"] = "starting"
state["units"] = [] state["units"] = []
@@ -323,21 +344,13 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
WATCH_PATH = cfg["WATCH_PATH"] WATCH_PATH = cfg["WATCH_PATH"]
SCAN_INTERVAL = int(cfg["SCAN_INTERVAL"]) SCAN_INTERVAL = int(cfg["SCAN_INTERVAL"])
OK_HOURS = float(cfg["OK_HOURS"])
MISSING_HOURS = float(cfg["MISSING_HOURS"])
ENABLE_LOGGING = bool(cfg["ENABLE_LOGGING"]) ENABLE_LOGGING = bool(cfg["ENABLE_LOGGING"])
LOG_FILE = cfg["LOG_FILE"] LOG_FILE = cfg["LOG_FILE"]
LOG_RETENTION_DAYS = int(cfg["LOG_RETENTION_DAYS"]) LOG_RETENTION_DAYS = int(cfg["LOG_RETENTION_DAYS"])
COLORIZE = bool(cfg["COLORIZE"])
MLG_HEADER_BYTES = int(cfg["MLG_HEADER_BYTES"]) MLG_HEADER_BYTES = int(cfg["MLG_HEADER_BYTES"])
RECENT_WARN_DAYS = int(cfg["RECENT_WARN_DAYS"]) RECENT_WARN_DAYS = int(cfg["RECENT_WARN_DAYS"])
MAX_EVENT_AGE_DAYS = int(cfg["MAX_EVENT_AGE_DAYS"]) MAX_EVENT_AGE_DAYS = int(cfg["MAX_EVENT_AGE_DAYS"])
C_OK = ansi(COLORIZE, "\033[92m")
C_PEN = ansi(COLORIZE, "\033[93m")
C_MIS = ansi(COLORIZE, "\033[91m")
C_RST = ansi(COLORIZE, "\033[0m")
print( print(
"[CFG] WATCH_PATH={} SCAN_INTERVAL={}s MAX_EVENT_AGE_DAYS={} API_ENABLED={}".format( "[CFG] WATCH_PATH={} SCAN_INTERVAL={}s MAX_EVENT_AGE_DAYS={} API_ENABLED={}".format(
WATCH_PATH, SCAN_INTERVAL, MAX_EVENT_AGE_DAYS, bool(cfg.get("API_ENABLED", False)) WATCH_PATH, SCAN_INTERVAL, MAX_EVENT_AGE_DAYS, bool(cfg.get("API_ENABLED", False))
@@ -376,45 +389,25 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
) )
now_epoch = time.time() now_epoch = time.time()
# Build per-unit status list for the tray # Log detected units to console and log file (info only, no status judgement)
unit_list = [] unit_list = []
worst = "ok" # tracks worst status across all units
if latest: if latest:
print("\nDetected Units (within last {} days):".format(MAX_EVENT_AGE_DAYS)) print("\nDetected Units (within last {} days):".format(MAX_EVENT_AGE_DAYS))
for uid in sorted(latest.keys()): for uid in sorted(latest.keys()):
info = latest[uid] info = latest[uid]
age_hours = (now_epoch - info["mtime"]) / 3600.0 age_hours = (now_epoch - info["mtime"]) / 3600.0
if age_hours > MISSING_HOURS:
status, col = "missing", C_MIS
elif age_hours > OK_HOURS:
status, col = "pending", C_PEN
else:
status, col = "ok", C_OK
# escalate worst status
if status == "missing":
worst = "missing"
elif status == "pending" and worst != "missing":
worst = "pending"
unit_list.append({ unit_list.append({
"uid": uid, "uid": uid,
"status": status,
"age_hours": age_hours, "age_hours": age_hours,
"last": fmt_last(info["mtime"]), "last": fmt_last(info["mtime"]),
"fname": info["fname"], "fname": info["fname"],
}) })
line = ( line = (
"{col}{uid:<8} {status:<8} Age: {age:<7} Last: {last} (File: {fname}){rst}".format( "{uid:<8} Age: {age:<7} Last: {last} (File: {fname})".format(
col=col,
uid=uid, uid=uid,
status=status.capitalize(),
age=fmt_age(now_epoch, info["mtime"]), age=fmt_age(now_epoch, info["mtime"]),
last=fmt_last(info["mtime"]), last=fmt_last(info["mtime"]),
fname=info["fname"], fname=info["fname"],
rst=C_RST,
) )
) )
print(line) print(line)
@@ -426,10 +419,9 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
ENABLE_LOGGING, ENABLE_LOGGING,
"[info] no recent MLG activity within {} days".format(MAX_EVENT_AGE_DAYS), "[info] no recent MLG activity within {} days".format(MAX_EVENT_AGE_DAYS),
) )
worst = "missing"
# Update shared state for tray # Update shared state for tray — status reflects watcher health, not unit ages
state["status"] = worst state["status"] = "running"
state["units"] = unit_list state["units"] = unit_list
state["last_scan"] = datetime.now() state["last_scan"] = datetime.now()
state["last_error"] = None state["last_error"] = None
@@ -441,11 +433,19 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> None:
if now_ts - last_api_ts >= interval: if now_ts - last_api_ts >= interval:
hb_payload = build_sfm_payload(latest, cfg) hb_payload = build_sfm_payload(latest, cfg)
hb_payload["version"] = VERSION hb_payload["version"] = VERSION
hb_payload["watcher_status"] = state.get("status", "unknown")
hb_payload["log_tail"] = _read_log_tail(cfg.get("LOG_FILE", ""), 25)
response = send_api_payload(hb_payload, cfg.get("API_URL", "")) response = send_api_payload(hb_payload, cfg.get("API_URL", ""))
last_api_ts = now_ts last_api_ts = now_ts
# Surface update signal to tray if response is not None:
if response and response.get("update_available"): state["api_status"] = "ok"
state["last_api"] = datetime.now()
if response.get("update_available"):
state["update_available"] = True state["update_available"] = True
else:
state["api_status"] = "fail"
else:
state["api_status"] = "disabled"
except Exception as e: except Exception as e:
err = "[loop-error] {}".format(e) err = "[loop-error] {}".format(e)

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — Settings Dialog v1.4.0 Series 3 Watcher — Settings Dialog v1.4.4
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,13 +30,17 @@ 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": r"C:\SeismoEmitter\agent_logs\series3_watcher.log", "LOG_FILE": os.path.join(
os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or "C:\\",
"Series3Watcher", "agent_logs", "series3_watcher.log"
),
"LOG_RETENTION_DAYS": "30", "LOG_RETENTION_DAYS": "30",
"COLORIZE": "false",
# Auto-updater
"UPDATE_SOURCE": "gitea",
"UPDATE_URL": "",
} }
@@ -206,7 +210,12 @@ class SettingsDialog:
# Connection # Connection
self.var_api_enabled = tk.BooleanVar(value=v["API_ENABLED"].lower() in ("1","true","yes","on")) self.var_api_enabled = tk.BooleanVar(value=v["API_ENABLED"].lower() in ("1","true","yes","on"))
self.var_api_url = tk.StringVar(value=v["API_URL"]) # Strip the fixed endpoint suffix so the dialog shows just the base URL
_raw_url = v["API_URL"]
_suffix = "/api/series3/heartbeat"
if _raw_url.endswith(_suffix):
_raw_url = _raw_url[:-len(_suffix)]
self.var_api_url = tk.StringVar(value=_raw_url)
self.var_api_interval = tk.StringVar(value=v["API_INTERVAL_SECONDS"]) self.var_api_interval = tk.StringVar(value=v["API_INTERVAL_SECONDS"])
self.var_source_id = tk.StringVar(value=v["SOURCE_ID"]) self.var_source_id = tk.StringVar(value=v["SOURCE_ID"])
self.var_source_type = tk.StringVar(value=v["SOURCE_TYPE"]) self.var_source_type = tk.StringVar(value=v["SOURCE_TYPE"])
@@ -218,14 +227,15 @@ 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"])
self.var_colorize = tk.BooleanVar(value=v["COLORIZE"].lower() in ("1","true","yes","on"))
# 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 ---
@@ -253,6 +263,7 @@ 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)
@@ -277,10 +288,40 @@ class SettingsDialog:
_add_label_check(f, 0, "API Enabled", self.var_api_enabled) _add_label_check(f, 0, "API Enabled", self.var_api_enabled)
_add_label_entry( # URL row — entry + Test button in an inner frame
f, 1, "terra-view URL", self.var_api_url, tk.Label(f, text="Terra-View URL", anchor="w").grid(
hint="http://192.168.x.x:8000/api/heartbeat", row=1, column=0, sticky="w", padx=(8, 4), pady=4
) )
url_frame = tk.Frame(f)
url_frame.grid(row=1, column=1, sticky="ew", padx=(0, 8), pady=4)
url_frame.columnconfigure(0, weight=1)
url_entry = ttk.Entry(url_frame, textvariable=self.var_api_url, width=32)
url_entry.grid(row=0, column=0, sticky="ew")
# Placeholder hint behaviour
_hint = "http://192.168.x.x:8000"
if not self.var_api_url.get():
url_entry.config(foreground="grey")
url_entry.insert(0, _hint)
def _on_focus_in(e):
if url_entry.get() == _hint:
url_entry.delete(0, tk.END)
url_entry.config(foreground="black")
def _on_focus_out(e):
if not url_entry.get():
url_entry.config(foreground="grey")
url_entry.insert(0, _hint)
self.var_api_url.set("")
url_entry.bind("<FocusIn>", _on_focus_in)
url_entry.bind("<FocusOut>", _on_focus_out)
self._test_btn = ttk.Button(url_frame, text="Test", width=6,
command=self._test_connection)
self._test_btn.grid(row=0, column=1, padx=(4, 0))
self._test_status = tk.Label(url_frame, text="", anchor="w", width=20)
self._test_status.grid(row=0, column=2, padx=(6, 0))
_add_label_spinbox(f, 2, "API Interval (sec)", self.var_api_interval, 30, 3600) _add_label_spinbox(f, 2, "API Interval (sec)", self.var_api_interval, 30, 3600)
@@ -289,6 +330,40 @@ class SettingsDialog:
_add_label_entry(f, 4, "Source Type", self.var_source_type, readonly=True) _add_label_entry(f, 4, "Source Type", self.var_source_type, readonly=True)
def _test_connection(self):
"""POST a minimal ping to the Terra-View heartbeat endpoint and show result."""
import urllib.request
import urllib.error
self._test_status.config(text="Testing...", foreground="grey")
self._test_btn.config(state="disabled")
self.root.update_idletasks()
raw = self.var_api_url.get().strip()
if not raw or raw == "http://192.168.x.x:8000":
self._test_status.config(text="Enter a URL first", foreground="orange")
self._test_btn.config(state="normal")
return
url = raw.rstrip("/") + "/health"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
self._test_status.config(text="Connected!", foreground="green")
else:
self._test_status.config(
text="HTTP {}".format(resp.status), foreground="orange"
)
except urllib.error.URLError as e:
reason = str(e.reason) if hasattr(e, "reason") else str(e)
self._test_status.config(text="Failed: {}".format(reason[:30]), foreground="red")
except Exception as e:
self._test_status.config(text="Error: {}".format(str(e)[:30]), foreground="red")
finally:
self._test_btn.config(state="normal")
def _build_tab_paths(self, nb): def _build_tab_paths(self, nb):
f = self._tab_frame(nb, "Paths") f = self._tab_frame(nb, "Paths")
@@ -319,15 +394,74 @@ 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, "OK Hours", self.var_ok_hours, 1, 168) _add_label_spinbox(f, 1, "MLG Header Bytes", self.var_mlg_header_bytes, 256, 65536)
_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)
_add_label_check(f, 2, "Colorize console output", self.var_colorize)
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 ---
@@ -358,8 +492,6 @@ 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),
] ]
@@ -376,13 +508,12 @@ class SettingsDialog:
if source_id.startswith("Defaults to hostname"): if source_id.startswith("Defaults to hostname"):
source_id = "" source_id = ""
# Resolve api_url placeholder # Resolve api_url — append the fixed endpoint, strip placeholder
api_url = self.var_api_url.get().strip() api_url = self.var_api_url.get().strip()
if api_url.startswith("http://192.168"): if api_url == "http://192.168.x.x:8000" or not api_url:
# This is the hint — only keep it if it looks like a real URL
pass # leave as-is; user may have typed it intentionally
if api_url == "http://192.168.x.x:8000/api/heartbeat":
api_url = "" api_url = ""
else:
api_url = api_url.rstrip("/") + "/api/series3/heartbeat"
values = { values = {
"API_ENABLED": "true" if self.var_api_enabled.get() else "false", "API_ENABLED": "true" if self.var_api_enabled.get() else "false",
@@ -393,13 +524,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"]),
"COLORIZE": "true" if self.var_colorize.get() else "false", "UPDATE_SOURCE": self.var_update_source.get().strip() or "gitea",
"UPDATE_URL": self.var_update_url.get().strip(),
} }
try: try: