diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..acc6f4b --- /dev/null +++ b/install.ps1 @@ -0,0 +1,623 @@ +# eve-watcher-install.ps1 — ONE file: writes, installs, starts all watchers. +$dir = Join-Path $HOME "eve-watcher" +New-Item -ItemType Directory -Force -Path $dir | Out-Null +Set-Location $dir +Write-Host "Installing to $dir" +@' +#!/usr/bin/env python3 +""" +eve_orehold_watcher.py - Ping me when my Retriever's ore hold is full. + +Runs on Goliath (Windows). Two detection modes (set in config.ini): + + mode = ocr Reads the in-game "Ore Hold" inventory window every POLL_SECS + via screen capture + OCR, parses " / m3", + and alerts when fill% >= ALERT_PCT. Most accurate. Requires + Tesseract-OCR installed and a one-time region calibration + (run with --snip to draw the box). + + mode = timer No screen reading. You give it your ore-hold size and effective + yield (m3/min); it counts down from when you press Enter and + alerts at the projected fill time. Dead reliable, zero setup. + Only blind spot: a rock depleting earlier than estimated. + +Both modes deliver to: + - ntfy (push to your phone; reuses the same ntfy app you already have) + - Windows toast (on-screen on Goliath, so you see it without alt-tabbing) + +Alerts re-arm automatically (OCR: when the hold drops back below RESET_PCT, +e.g. after you unload; timer: when you start a new run) and respect a cooldown. + +Usage: + python eve_orehold_watcher.py # run the watcher (mode from config) + python eve_orehold_watcher.py --snip # draw the OCR capture region, save it + python eve_orehold_watcher.py --test # fire one test alert and exit +""" + +import configparser +import json +import os +import re +import sys +import time +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +CONFIG_PATH = os.path.join(HERE, "config.ini") + + +# --------------------------------------------------------------------------- # +# Config +# --------------------------------------------------------------------------- # +def load_config(): + if not os.path.exists(CONFIG_PATH): + sys.exit(f"No config.ini found. Copy config.ini.example -> config.ini " + f"and edit it.\n(looked in {CONFIG_PATH})") + cp = configparser.ConfigParser() + cp.read(CONFIG_PATH) + return cp + + +def save_region(cp, left, top, width, height): + if not cp.has_section("ocr"): + cp.add_section("ocr") + cp["ocr"]["region"] = f"{left},{top},{width},{height}" + with open(CONFIG_PATH, "w") as f: + cp.write(f) + + +# --------------------------------------------------------------------------- # +# Delivery +# --------------------------------------------------------------------------- # +_BOT_STATE = {"t": 0, "muted": False} + + +def bot_muted(cp): + """Coordinate with the Discord bot: poll the shared alert-state it publishes, so a + `!mute` in Discord also silences these local watchers. Cached 30s.""" + url = cp.get("coordination", "bot_state_url", fallback="").strip() \ + if cp.has_section("coordination") else "" + if not url: + return False + if time.time() - _BOT_STATE["t"] < 30: + return _BOT_STATE["muted"] + try: + d = json.loads(urllib.request.urlopen(url, timeout=5).read()) + _BOT_STATE["muted"] = bool(d.get("muted")) + _BOT_STATE["t"] = time.time() + except Exception: + pass + return _BOT_STATE["muted"] + + +def notify(cp, title, message, priority="high", tags="rock,bell"): + """Fan out to ntfy (phone), a Windows toast, and a Discord channel.""" + if bot_muted(cp): + print("[muted by bot]") + return + _ntfy(cp, title, message, priority, tags) + _toast(title, message) + _discord(cp, title, message) + + +def _discord(cp, title, message): + """Post to a Discord channel via webhook (shared with fleetmates).""" + webhook = cp.get("discord", "webhook", fallback="").strip() + if not webhook: + return + mention = cp.get("discord", "mention", fallback="").strip() # e.g. <@USERID> or @here + payload = json.dumps({"content": f"{mention} **{title}**\n{message}".strip()}) + try: + req = urllib.request.Request( + webhook, data=payload.encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=10) as r: + r.read() + print("[discord] sent") + except Exception as e: + print(f"[discord] FAILED: {e}") + + +def _ntfy(cp, title, message, priority, tags): + server = cp.get("ntfy", "server", fallback="https://ntfy.sh").rstrip("/") + topic = cp.get("ntfy", "topic", fallback="").strip() + if not topic: + print("[ntfy] no topic set, skipping push") + return + url = f"{server}/{topic}" + headers = { + "Title": title, + "Priority": priority, + "Tags": tags, + } + auth = cp.get("ntfy", "auth", fallback="").strip() # "user:pass" for self-hosted + if auth: + import base64 + headers["Authorization"] = "Basic " + base64.b64encode( + auth.encode()).decode() + try: + req = urllib.request.Request(url, data=message.encode("utf-8"), + headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=10) as r: + r.read() + print(f"[ntfy] sent -> {url}") + except Exception as e: + print(f"[ntfy] FAILED: {e}") + + +def _toast(title, message): + try: + from winotify import Notification, audio + t = Notification(app_id="Eve Ore Watcher", title=title, msg=message, + duration="long") + t.set_audio(audio.Default, loop=False) + t.show() + print("[toast] shown") + except Exception as e: + print(f"[toast] FAILED (is 'winotify' installed?): {e}") + + +# --------------------------------------------------------------------------- # +# OCR mode +# --------------------------------------------------------------------------- # +def parse_orehold_text(text): + """Pull (current, capacity) m3 out of OCR text like '12,345 / 22,000 m3'.""" + cleaned = text.replace(",", "").replace(".", "").replace(" ", "") + m = re.search(r"(\d{2,7})/(\d{2,7})", cleaned) + if not m: + return None + cur, cap = int(m.group(1)), int(m.group(2)) + if cap <= 0 or cur > cap * 1.2: + return None + return cur, cap + + +def grab_region(region): + import mss + from PIL import Image + left, top, width, height = region + with mss.mss() as sct: + raw = sct.grab({"left": left, "top": top, "width": width, + "height": height}) + return Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") + + +def run_ocr(cp): + import pytesseract + tcmd = cp.get("ocr", "tesseract_cmd", fallback="").strip() + if tcmd: + pytesseract.pytesseract.tesseract_cmd = tcmd + + region_s = cp.get("ocr", "region", fallback="").strip() + if not region_s: + sys.exit("No OCR region set. Run: python eve_orehold_watcher.py --snip") + region = [int(x) for x in region_s.split(",")] + + poll = cp.getint("watcher", "poll_secs", fallback=10) + alert_pct = cp.getfloat("watcher", "alert_pct", fallback=95.0) + reset_pct = cp.getfloat("watcher", "reset_pct", fallback=50.0) + cooldown = cp.getint("watcher", "cooldown_secs", fallback=120) + + print(f"[ocr] watching region={region} every {poll}s; " + f"alert>={alert_pct}% reset<{reset_pct}%") + armed = True + last_alert = 0.0 + while True: + try: + img = grab_region(region) + text = pytesseract.image_to_string(img, config="--psm 7") + parsed = parse_orehold_text(text) + if parsed: + cur, cap = parsed + pct = 100.0 * cur / cap + print(f"[ocr] {cur}/{cap} m3 ({pct:.1f}%) armed={armed}") + if pct < reset_pct: + armed = True + if armed and pct >= alert_pct and \ + time.time() - last_alert > cooldown: + notify(cp, "Retriever ore hold full", + f"Ore hold at {pct:.0f}% ({cur:,}/{cap:,} m3). " + f"Stop mining / unload.") + armed = False + last_alert = time.time() + else: + print(f"[ocr] no reading (text={text!r})") + except Exception as e: + print(f"[ocr] error: {e}") + time.sleep(poll) + + +# --------------------------------------------------------------------------- # +# Timer mode +# --------------------------------------------------------------------------- # +def run_timer(cp): + hold = cp.getfloat("timer", "ore_hold_m3", fallback=22000.0) + yield_pm = cp.getfloat("timer", "yield_m3_per_min", fallback=0.0) + alert_pct = cp.getfloat("watcher", "alert_pct", fallback=95.0) + if yield_pm <= 0: + sys.exit("Set [timer] yield_m3_per_min in config.ini (your m3/min).") + + target = hold * alert_pct / 100.0 + secs = target / yield_pm * 60.0 + while True: + input(f"\nPress Enter when lasers go hot " + f"(will ping in {secs/60:.1f} min at {alert_pct:.0f}% / " + f"{target:,.0f} m3)... ") + start = time.time() + while time.time() - start < secs: + remaining = secs - (time.time() - start) + print(f" [timer] {remaining/60:5.1f} min to full", end="\r") + time.sleep(5) + notify(cp, "Retriever ore hold full (est.)", + f"~{alert_pct:.0f}% full ({target:,.0f} m3 at " + f"{yield_pm:.0f} m3/min). Check & unload.") + print("\n[timer] alert fired. Loop again for the next run.") + + +# --------------------------------------------------------------------------- # +# Region snip helper (Tkinter drag-to-select) +# --------------------------------------------------------------------------- # +def run_snip(cp): + import tkinter as tk + coords = {} + + root = tk.Tk() + root.attributes("-fullscreen", True) + root.attributes("-alpha", 0.25) + root.configure(bg="black") + root.title("Drag a box over the Ore Hold 'cur / cap m3' text") + canvas = tk.Canvas(root, cursor="cross", bg="black", highlightthickness=0) + canvas.pack(fill=tk.BOTH, expand=True) + rect = {"id": None, "x0": 0, "y0": 0} + + def on_press(e): + rect["x0"], rect["y0"] = e.x_root, e.y_root + rect["id"] = canvas.create_rectangle(e.x, e.y, e.x, e.y, + outline="red", width=2) + + def on_drag(e): + canvas.coords(rect["id"], rect["x0"] - root.winfo_rootx(), + rect["y0"] - root.winfo_rooty(), e.x, e.y) + + def on_release(e): + x0, y0 = rect["x0"], rect["y0"] + x1, y1 = e.x_root, e.y_root + coords["region"] = (min(x0, x1), min(y0, y1), + abs(x1 - x0), abs(y1 - y0)) + root.destroy() + + canvas.bind("", on_press) + canvas.bind("", on_drag) + canvas.bind("", on_release) + root.bind("", lambda e: root.destroy()) + print("Drag a tight box around the '12,345 / 22,000 m3' text. Esc to cancel.") + root.mainloop() + + if "region" in coords: + l, t, w, h = coords["region"] + save_region(cp, l, t, w, h) + print(f"Saved region={l},{t},{w},{h} to config.ini") + else: + print("Cancelled, nothing saved.") + + +# --------------------------------------------------------------------------- # +def main(): + cp = load_config() + if "--snip" in sys.argv: + run_snip(cp) + return + if "--test" in sys.argv: + notify(cp, "Eve watcher test", + "If you got this on phone + toast, delivery works.") + return + mode = cp.get("watcher", "mode", fallback="ocr").strip().lower() + print(f"=== eve_orehold_watcher starting (mode={mode}) ===") + if mode == "ocr": + run_ocr(cp) + elif mode == "timer": + run_timer(cp) + else: + sys.exit(f"Unknown mode '{mode}' (use 'ocr' or 'timer')") + + +if __name__ == "__main__": + main() + +'@ | Set-Content -Path (Join-Path $dir 'eve_orehold_watcher.py') -Encoding UTF8 +@' +#!/usr/bin/env python3 +"""Gamelog watcher — everything EVE logs but has no API for. + +Tails EVE's local Gamelogs (Documents/EVE/logs/Gamelogs) and alerts via the same +channels as the ore-hold watcher (ntfy + Windows toast + Discord). Reading the log +file is legit — it never touches the game. Runs on the PC you play on. + +Catches: + • TACKLED / EWAR'd (notify warp-scramble/disrupt/jam/web/neut) — urgent, no cooldown + • Incoming damage (combat 'from') — "rats?" — cooldown'd + • Hold full (notify cargo/ore hold full) + • Capacitor empty (notify) + • Bounty income (bounty lines) — accumulates a session total, milestone pings + +Config: reuses config.ini, optional [combat] section: + gamelogs_dir = ; auto-detects ~/Documents/EVE/logs/Gamelogs + cooldown_secs = 60 + min_damage = 1 + bounty_milestone = 25000000 ; ping every this much ratting ISK + +Run: python eve_combat_watcher.py (--test fires one alert) +""" +import glob +import os +import re +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import eve_orehold_watcher as w # reuse load_config() + notify() + +TAG = re.compile(r"<[^>]+>") +INCOMING = re.compile(r"\(combat\)\s*([\d,]+)\s+from\s+(.+?)\s*[-\n]", re.IGNORECASE) +DANGER = re.compile(r"\(notify\).*(warp (?:scrambl|disrupt)|unable to warp|" + r"jam|target(?:ing)? (?:disrupt|jam)|energy neutraliz|web|stasis)", + re.IGNORECASE) +HOLDFULL = re.compile(r"\(notify\).*(cargo (?:hold )?is full|hold is full|" + r"not enough (?:cargo )?space|deactivat.*full|full.*deactivat)", + re.IGNORECASE) +CAPEMPTY = re.compile(r"\(notify\).*(capacitor is empty|not enough (?:capacitor|energy))", + re.IGNORECASE) +BOUNTY = re.compile(r"\(bounty\)\s*([\d,]+(?:\.\d+)?)\s*ISK", re.IGNORECASE) + + +def find_gamelogs(cp): + d = cp.get("combat", "gamelogs_dir", fallback="").strip() if cp.has_section("combat") else "" + if d and os.path.isdir(d): + return d + for base in (os.path.expanduser("~/Documents/EVE/logs/Gamelogs"), + os.path.expanduser("~/OneDrive/Documents/EVE/logs/Gamelogs")): + if os.path.isdir(base): + return base + return None + + +def newest(d): + files = glob.glob(os.path.join(d, "*.txt")) + return max(files, key=os.path.getmtime) if files else None + + +def main(): + cp = w.load_config() + g = lambda k, d: (cp.getint("combat", k, fallback=d) if cp.has_section("combat") else d) + cooldown, min_dmg, milestone = g("cooldown_secs", 60), g("min_damage", 1), g("bounty_milestone", 25000000) + + if "--test" in sys.argv: + w.notify(cp, "Gamelog watcher test", "Combat/tackle/hold/bounty alerts will reach you.", + priority="high", tags="crossed_swords") + return + + gdir = find_gamelogs(cp) + if not gdir: + sys.exit("Couldn't find Gamelogs dir. Set [combat] gamelogs_dir in config.ini.") + print(f"[gamelog] watching {gdir}") + cur = newest(gdir) + fh = open(cur, "r", encoding="utf-8", errors="ignore") if cur else None + if fh: + fh.seek(0, os.SEEK_END) + last_dmg = 0.0 + bounty_total = 0.0 + bounty_reported = 0.0 + + while True: + nl = newest(gdir) + if nl and nl != cur: + cur = nl + if fh: + fh.close() + fh = open(cur, "r", encoding="utf-8", errors="ignore") + bounty_total = bounty_reported = 0.0 # new session + print(f"[gamelog] -> {os.path.basename(cur)}") + if not fh: + time.sleep(3); continue + line = fh.readline() + if not line: + time.sleep(1); continue + clean = TAG.sub("", line) + + if "(notify)" in line: + if DANGER.search(clean): + w.notify(cp, "⚠ TACKLED / EWAR", + "Scrambled/jammed/webbed — you may not be able to warp. ACT NOW.", + priority="urgent", tags="rotating_light"); continue + if HOLDFULL.search(clean): + w.notify(cp, "Hold full", "Your hold is full — compress / unload / swap.", + priority="high", tags="package"); continue + if CAPEMPTY.search(clean): + w.notify(cp, "Capacitor empty", "Cap's out — modules dropping.", + priority="default", tags="battery"); continue + continue + if "(bounty)" in line: + mb = BOUNTY.search(clean) + if mb: + bounty_total += float(mb.group(1).replace(",", "")) + if bounty_total - bounty_reported >= milestone: + bounty_reported = bounty_total + w.notify(cp, "Ratting income", + f"~{bounty_total/1e6:.0f}M ISK in bounties this session.", + priority="low", tags="moneybag") + continue + if "(combat)" in line: + m = INCOMING.search(clean) + if not m: + continue + dmg = int(m.group(1).replace(",", "")) + if dmg < min_dmg or time.time() - last_dmg < cooldown: + continue + last_dmg = time.time() + w.notify(cp, "Taking damage — rats?", + f"Incoming fire from {m.group(2).strip()} ({dmg}). Engage drones, " + f"watch shield.", priority="urgent", tags="crossed_swords") + print(f"[gamelog] damage {dmg} from {m.group(2).strip()}") + + +if __name__ == "__main__": + main() + +'@ | Set-Content -Path (Join-Path $dir 'eve_combat_watcher.py') -Encoding UTF8 +@' +#!/usr/bin/env python3 +"""Chat-intel watcher — closest thing to a Local early-warning, from the logs. + +Tails EVE's Chatlogs (Documents/EVE/logs/Chatlogs) and pings (ntfy + toast + Discord) +when a configured keyword or hostile name appears in the watched channels. Chatlogs +are UTF-16. Reading them is legit; it never touches the game. + +LIMIT: chatlogs record *messages*, not live enters/leaves — so this catches people +*talking* (intel channels, someone in Local), not a silent new entrant. Watch the +in-game Local window for that. + +Config: reuses config.ini, optional [chat] section: + chatlogs_dir = ; auto-detects ~/Documents/EVE/logs/Chatlogs + channels = Local,Intel ; filename prefixes to watch + keywords = ; comma list; if empty, alerts on EVERY msg in these channels + cooldown_secs = 20 + +Run: python eve_chat_watcher.py (--test fires one alert) +""" +import glob +import os +import re +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import eve_orehold_watcher as w + +LINE = re.compile(r"^\[\s*[\d.]+\s+[\d:]+\s*\]\s*(.+?)\s*>\s*(.*)$") + + +def find_dir(cp): + d = cp.get("chat", "chatlogs_dir", fallback="").strip() if cp.has_section("chat") else "" + if d and os.path.isdir(d): + return d + for base in (os.path.expanduser("~/Documents/EVE/logs/Chatlogs"), + os.path.expanduser("~/OneDrive/Documents/EVE/logs/Chatlogs")): + if os.path.isdir(base): + return base + return None + + +def newest_for(d, prefix): + files = glob.glob(os.path.join(d, f"{prefix}_*.txt")) + return max(files, key=os.path.getmtime) if files else None + + +def main(): + cp = w.load_config() + sec = cp.has_section("chat") + channels = [c.strip() for c in (cp.get("chat", "channels", fallback="Local,Intel") + if sec else "Local,Intel").split(",") if c.strip()] + keywords = [k.strip().lower() for k in (cp.get("chat", "keywords", fallback="") + if sec else "").split(",") if k.strip()] + cooldown = cp.getint("chat", "cooldown_secs", fallback=20) if sec else 20 + + if "--test" in sys.argv: + w.notify(cp, "Chat watcher test", "Chat-intel alerts will reach you.", + priority="high", tags="speech_balloon") + return + + d = find_dir(cp) + if not d: + sys.exit("No Chatlogs dir. Set [chat] chatlogs_dir in config.ini.") + print(f"[chat] watching {channels} in {d}; keywords={keywords or 'ALL'}") + + handles = {} # channel -> (path, fh) + last = {} # channel -> last alert time + while True: + for ch in channels: + nl = newest_for(d, ch) + if not nl: + continue + path, fh = handles.get(ch, (None, None)) + if nl != path: + if fh: + fh.close() + fh = open(nl, "r", encoding="utf-16", errors="ignore") + fh.seek(0, os.SEEK_END) + handles[ch] = (nl, fh) + continue + line = fh.readline() + if not line: + continue + m = LINE.match(line.strip()) + if not m: + continue + speaker, msg = m.group(1), m.group(2) + if speaker in ("EVE System",): + continue + if keywords and not any(k in msg.lower() or k in speaker.lower() + for k in keywords): + continue + if time.time() - last.get(ch, 0) < cooldown: + continue + last[ch] = time.time() + w.notify(cp, f"{ch}: {speaker}", msg[:180], + priority="high", tags="speech_balloon,eyes") + print(f"[chat] {ch} {speaker}: {msg[:80]}") + time.sleep(1) + + +if __name__ == "__main__": + main() + +'@ | Set-Content -Path (Join-Path $dir 'eve_chat_watcher.py') -Encoding UTF8 +$topic = "eve-" + (-join ((48..57)+(97..122) | Get-Random -Count 8 | ForEach-Object {[char]$_})) +if (-not (Test-Path (Join-Path $dir "config.ini"))) { +@" +[watcher] +mode = ocr +poll_secs = 10 +alert_pct = 95 +reset_pct = 50 +cooldown_secs = 120 + +[ntfy] +server = https://ntfy.sh +topic = $topic + +[discord] +webhook = https://discord.com/api/webhooks/1515603432583598172/7g2A9Lfg1afbZGoxBENu9TpxSxE4zfpg16nRqE08qzyI3a0uttADL6wyJ2ERHRfsHlK9 +mention = + +[coordination] +bot_state_url = https://git.armoredarmadillo.com/brockdarnold/eve-watcher/raw/branch/main/alert_state.json + +[ocr] +region = +tesseract_cmd = + +[combat] +gamelogs_dir = +cooldown_secs = 60 +min_damage = 1 +bounty_milestone = 25000000 + +[chat] +chatlogs_dir = +channels = Local,Intel +keywords = +cooldown_secs = 20 + +[timer] +ore_hold_m3 = 50000 +yield_m3_per_min = 0 + +"@ | Set-Content -Path (Join-Path $dir "config.ini") -Encoding UTF8 + Write-Host "config.ini created. ntfy topic: $topic" } else { Write-Host "config.ini kept." } +if (-not (Get-Command python -ErrorAction SilentlyContinue)) { winget install --id Python.Python.3.12 -e --silent --accept-package-agreements --accept-source-agreements; $env:Path=[Environment]::GetEnvironmentVariable("Path","Machine")+";"+[Environment]::GetEnvironmentVariable("Path","User") } +python -m pip install --quiet windows-toasts 2>$null +Get-Process pythonw -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue +foreach ($w in "eve_combat_watcher.py","eve_chat_watcher.py") { Start-Process pythonw -ArgumentList "`"$dir\$w`"" -WorkingDirectory $dir -WindowStyle Hidden; Write-Host "started $w" } +Write-Host ""; Write-Host "Done. Watchers running -> phone + Discord. ntfy topic: $topic" \ No newline at end of file