publish install.ps1
This commit is contained in:
parent
e69a65c816
commit
1828b6ff24
1 changed files with 38 additions and 642 deletions
680
install.ps1
680
install.ps1
|
|
@ -1,644 +1,40 @@
|
||||||
# eve-watcher-install.ps1 — ONE file: writes, installs, starts all watchers.
|
<#
|
||||||
$dir = Join-Path $HOME "eve-watcher"
|
install.ps1 — first-time bootstrap for the Eve watchers (Goliath + Adam's PC).
|
||||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
||||||
|
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
|
Set-Location $dir
|
||||||
Write-Host "Installing to $dir"
|
python update.py
|
||||||
@'
|
|
||||||
#!/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):
|
Write-Host ""
|
||||||
|
Write-Host "Installed at $dir."
|
||||||
mode = ocr Reads the in-game "Ore Hold" inventory window every POLL_SECS
|
Write-Host "From now on, update with: cd $dir ; python update.py"
|
||||||
via screen capture + OCR, parses "<current> / <capacity> m3",
|
Write-Host "One-time (per PC) for OCR: python eve_orehold_watcher.py --snip"
|
||||||
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("<ButtonPress-1>", on_press)
|
|
||||||
canvas.bind("<B1-Motion>", on_drag)
|
|
||||||
canvas.bind("<ButtonRelease-1>", on_release)
|
|
||||||
root.bind("<Escape>", 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."
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue