Feat: v1.4.1 - Windows installer updated.

This commit is contained in:
serversdwn
2026-03-16 20:00:42 -04:00
parent 1b8c63025f
commit e67b6eb89f
8 changed files with 160 additions and 21 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

View File

@@ -3,9 +3,13 @@ echo Building series3-watcher.exe...
pip install pyinstaller pystray Pillow pip install pyinstaller pystray Pillow
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 series3-watcher ^
--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 series3-watcher series3_tray.py

View File

@@ -19,7 +19,7 @@ MISSING_HOURS = 24
# Logging # Logging
ENABLE_LOGGING = True ENABLE_LOGGING = True
LOG_FILE = C:\SeismoEmitter\agent_logs\series3_watcher.log LOG_FILE = C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log
LOG_RETENTION_DAYS = 30 LOG_RETENTION_DAYS = 30
# Console colors - (Doesn't work on windows 7) # Console colors - (Doesn't work on windows 7)

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.1
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.1
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)
@@ -126,7 +126,12 @@ def apply_update(download_url):
# --------------- Paths --------------- # --------------- Paths ---------------
HERE = os.path.dirname(os.path.abspath(__file__)) # When frozen by PyInstaller, __file__ points to a temp extraction dir.
# sys.executable is always the real .exe location — use that instead.
if getattr(sys, "frozen", False):
HERE = os.path.dirname(os.path.abspath(sys.executable))
else:
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(HERE, "config.ini") CONFIG_PATH = os.path.join(HERE, "config.ini")
@@ -141,11 +146,50 @@ COLORS = {
} }
ICON_SIZE = 64 ICON_SIZE = 64
ICON_FILE = os.path.join(HERE, "icon.ico")
# Load base icon once at startup; fall back to None if missing.
# When frozen, bundled data files live in sys._MEIPASS.
def _load_base_icon():
candidates = [ICON_FILE]
if getattr(sys, "frozen", False):
candidates.insert(0, os.path.join(sys._MEIPASS, "icon.ico"))
for path in candidates:
try:
img = Image.open(path)
img = img.convert("RGBA").resize((ICON_SIZE, ICON_SIZE), Image.LANCZOS)
return img
except Exception:
continue
return None
_BASE_ICON = _load_base_icon()
def make_icon(status): def make_icon(status):
"""Draw a solid filled circle on a transparent background.""" """
Use icon.ico as the base and overlay a small status dot in the
bottom-right corner. Falls back to a plain colored circle if the
ico file is not available.
"""
color = COLORS.get(status, COLORS["starting"]) color = COLORS.get(status, COLORS["starting"])
if _BASE_ICON is not None:
img = _BASE_ICON.copy()
draw = ImageDraw.Draw(img)
# Dot size and position — bottom-right corner
dot_r = ICON_SIZE // 5 # radius ~12px on 64px icon
margin = 2
x0 = ICON_SIZE - dot_r * 2 - margin
y0 = ICON_SIZE - dot_r * 2 - margin
x1 = ICON_SIZE - margin
y1 = ICON_SIZE - margin
# White outline for contrast on dark/light backgrounds
draw.ellipse([x0 - 2, y0 - 2, x1 + 2, y1 + 2], fill=(255, 255, 255, 220))
draw.ellipse([x0, y0, x1, y1], fill=color + (255,))
return img
# Fallback: plain colored circle
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)
margin = 6 margin = 6

View File

@@ -1,5 +1,5 @@
""" """
Series 3 Watcher — v1.4.0 Series 3 Watcher — v1.4.1
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
@@ -62,7 +63,7 @@ def load_config(path: str) -> Dict[str, Any]:
"OK_HOURS": float(get_int("OK_HOURS", 12)), "OK_HOURS": float(get_int("OK_HOURS", 12)),
"MISSING_HOURS": float(get_int("MISSING_HOURS", 24)), "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", r"C:\Program Files\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 "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)),
@@ -216,7 +217,19 @@ def scan_latest(
# --- API heartbeat / SFM telemetry helpers --- # --- API heartbeat / SFM telemetry helpers ---
VERSION = "1.4.0" VERSION = "1.4.1"
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]:
@@ -301,7 +314,10 @@ def run_watcher(state: Dict[str, Any], stop_event: threading.Event) -> 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):
here = os.path.dirname(os.path.abspath(sys.executable))
else:
here = os.path.dirname(os.path.abspath(__file__)) or "."
config_path = os.path.join(here, "config.ini") config_path = os.path.join(here, "config.ini")
state["status"] = "starting" state["status"] = "starting"
@@ -441,6 +457,7 @@ 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["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 # Surface update signal to tray

View File

@@ -34,7 +34,7 @@ DEFAULTS = {
"MISSING_HOURS": "24", "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": r"C:\Program Files\Series3Watcher\agent_logs\series3_watcher.log",
"LOG_RETENTION_DAYS": "30", "LOG_RETENTION_DAYS": "30",
"COLORIZE": "false", "COLORIZE": "false",
} }
@@ -206,7 +206,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"])
@@ -277,10 +282,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 +324,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")
@@ -376,13 +445,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",