diff --git a/install.ps1 b/install.ps1 index 93b1e5b..a64f9b2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,644 +1,40 @@ -# 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 +<# + install.ps1 — first-time bootstrap for the Eve watchers (Goliath + Adam's PC). + + One-liner (PowerShell): + irm https://git.armoredarmadillo.com/brockdarnold/eve-watcher/raw/branch/main/install.ps1 | iex + + This is now just a thin bootstrap: it git-clones the watcher repo into + %USERPROFILE%\eve-watcher (or pulls if already there) and runs update.py. + After this, the ONLY command you ever need to update is: + cd ~\eve-watcher ; python update.py + Your config.ini (webhook + OCR snip) is gitignored, so updates never touch it. +#> +$ErrorActionPreference = "Stop" +$repo = "https://git.armoredarmadillo.com/brockdarnold/eve-watcher.git" +$dir = Join-Path $env:USERPROFILE "eve-watcher" + +function Need($cmd, $hint) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + Write-Warning "$cmd not found. $hint" + exit 1 + } +} +Need git "Install it: winget install --id Git.Git -e" +Need python "Install Python 3 from https://www.python.org (check 'Add to PATH'), then re-run." + +if (Test-Path (Join-Path $dir ".git")) { + Write-Host "eve-watcher already cloned at $dir — pulling latest." + git -C $dir pull --ff-only +} else { + Write-Host "Cloning eve-watcher into $dir ..." + git clone $repo $dir +} + 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. +python update.py -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) - # stall = hold not growing -> lasers/drones stopped (depleted rock, idle drones) - stall_secs = cp.getint("watcher", "stall_secs", fallback=150) - - print(f"[ocr] watching region={region} every {poll}s; " - f"alert>={alert_pct}% reset<{reset_pct}% stall>{stall_secs}s") - armed = True - last_alert = 0.0 - last_cur = -1 - last_grow = time.time() - last_stall_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 - # --- still mining? (hold should be growing) --- - if cur > last_cur: - last_grow = time.time() - last_cur = cur - if pct < alert_pct - 1 and time.time() - last_grow > stall_secs \ - and time.time() - last_stall_alert > cooldown: - notify(cp, "Mining stopped?", - f"Hold hasn't grown in {stall_secs}s at {pct:.0f}% — rock " - f"depleted, drones idle, or lasers offlined? Check.", - priority="high", tags="warning") - last_stall_alert = time.time() - # --- hold full --- - if armed and pct >= alert_pct and \ - time.time() - last_alert > cooldown: - notify(cp, "Hold full — compress", - f"Hold at {pct:.0f}% ({cur:,}/{cap:,} m3). " - f"Compress / unload / swap.") - 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 = 90 -reset_pct = 50 -cooldown_secs = 120 -stall_secs = 150 - -[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 (existing)." } -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 mss pillow pytesseract 2>$null -Get-Process pythonw -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue -$ws = @("eve_combat_watcher.py","eve_chat_watcher.py") -if ((Get-Content (Join-Path $dir "config.ini")) -match "^region\s*=\s*\S") { $ws += "eve_orehold_watcher.py" } -foreach ($w in $ws) { 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" -Write-Host "For live compress + stall alerts, run once: python eve_orehold_watcher.py --snip (box your hold m3), then re-run this." \ No newline at end of file +Write-Host "" +Write-Host "Installed at $dir." +Write-Host "From now on, update with: cd $dir ; python update.py" +Write-Host "One-time (per PC) for OCR: python eve_orehold_watcher.py --snip"