diff --git a/.gitignore b/.gitignore index f5121db..1b6014b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ env/ # Distribution / packaging build/ dist/ +Output/ *.egg-info/ +*.spec # ------------------------- # Logs + runtime artifacts diff --git a/build.bat b/build.bat index 4fd4dee..17a0472 100644 --- a/build.bat +++ b/build.bat @@ -3,9 +3,13 @@ echo Building series3-watcher.exe... pip install pyinstaller pystray Pillow 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" ( - 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 ( echo [INFO] icon.ico not found -- building without custom icon. pyinstaller --onefile --windowed --name series3-watcher series3_tray.py diff --git a/config-template.ini b/config-template.ini index dbcd591..3d69ae6 100644 --- a/config-template.ini +++ b/config-template.ini @@ -19,7 +19,7 @@ MISSING_HOURS = 24 # Logging 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 # Console colors - (Doesn't work on windows 7) diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..cce0f2a Binary files /dev/null and b/icon.ico differ diff --git a/installer.iss b/installer.iss index 2b9e5be..cbb508b 100644 --- a/installer.iss +++ b/installer.iss @@ -3,7 +3,7 @@ [Setup] AppName=Series 3 Watcher -AppVersion=1.4.0 +AppVersion=1.4.1 AppPublisher=Terra-Mechanics Inc. DefaultDirName={pf}\Series3Watcher DefaultGroupName=Series 3 Watcher @@ -16,6 +16,10 @@ PrivilegesRequired=admin [Tasks] 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] ; Main executable — built by build.bat / PyInstaller Source: "dist\series3-watcher.exe"; DestDir: "{app}"; Flags: ignoreversion diff --git a/series3_tray.py b/series3_tray.py index c128487..454e498 100644 --- a/series3_tray.py +++ b/series3_tray.py @@ -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) Run with: pythonw series3_tray.py (no console window) @@ -126,7 +126,12 @@ def apply_update(download_url): # --------------- 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") @@ -141,11 +146,50 @@ COLORS = { } 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): - """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"]) + + 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)) draw = ImageDraw.Draw(img) margin = 6 diff --git a/series3_watcher.py b/series3_watcher.py index e1d76f5..7ab22ad 100644 --- a/series3_watcher.py +++ b/series3_watcher.py @@ -1,5 +1,5 @@ """ -Series 3 Watcher — v1.4.0 +Series 3 Watcher — v1.4.1 Environment: - Python 3.8 (Windows 7 compatible) @@ -17,6 +17,7 @@ Key Features: import os import re +import sys import time import json import threading @@ -62,7 +63,7 @@ def load_config(path: str) -> Dict[str, Any]: "OK_HOURS": float(get_int("OK_HOURS", 12)), "MISSING_HOURS": float(get_int("MISSING_HOURS", 24)), "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), "COLORIZE": get_bool("COLORIZE", False), # Win7 default off "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 --- -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]: @@ -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["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") 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: hb_payload = build_sfm_payload(latest, cfg) 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", "")) last_api_ts = now_ts # Surface update signal to tray diff --git a/settings_dialog.py b/settings_dialog.py index 817a6b8..2013a00 100644 --- a/settings_dialog.py +++ b/settings_dialog.py @@ -34,7 +34,7 @@ DEFAULTS = { "MISSING_HOURS": "24", "MLG_HEADER_BYTES": "2048", "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", "COLORIZE": "false", } @@ -206,7 +206,12 @@ class SettingsDialog: # Connection 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_source_id = tk.StringVar(value=v["SOURCE_ID"]) 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_entry( - f, 1, "terra-view URL", self.var_api_url, - hint="http://192.168.x.x:8000/api/heartbeat", + # URL row — entry + Test button in an inner frame + tk.Label(f, text="Terra-View URL", anchor="w").grid( + 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("", _on_focus_in) + url_entry.bind("", _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) @@ -289,6 +324,40 @@ class SettingsDialog: _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): f = self._tab_frame(nb, "Paths") @@ -376,13 +445,12 @@ class SettingsDialog: if source_id.startswith("Defaults to hostname"): source_id = "" - # Resolve api_url placeholder + # Resolve api_url — append the fixed endpoint, strip placeholder api_url = self.var_api_url.get().strip() - if api_url.startswith("http://192.168"): - # 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": + if api_url == "http://192.168.x.x:8000" or not api_url: api_url = "" + else: + api_url = api_url.rstrip("/") + "/api/series3/heartbeat" values = { "API_ENABLED": "true" if self.var_api_enabled.get() else "false",