chore: archive mac mini automation baseline
This commit is contained in:
@@ -19,6 +19,7 @@ from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
|
||||
from reyna_cli.immich import ImmichClient
|
||||
from reyna_cli.mcp import MCPClient
|
||||
from reyna_cli.mongo_direct import MongoDirectClient
|
||||
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
|
||||
from reyna_cli.tts import TTSError, synthesize_wav
|
||||
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
||||
from reyna_cli.zoom_direct import ZoomClient
|
||||
@@ -39,6 +40,7 @@ zoom_app = typer.Typer(help="Zoom direct REST API commands.")
|
||||
email_app = typer.Typer(help="Read-only local Thunderbird email commands.")
|
||||
deco_app = typer.Typer(help="TP-Link Deco direct router commands.")
|
||||
macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Notes, Reminders, Deco).")
|
||||
remarkable_app = typer.Typer(help="Paper Pro discovery, local cache, and macOS listener service.")
|
||||
macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
|
||||
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
|
||||
macmini_notes_app = typer.Typer(help="Mac mini Notes tools.")
|
||||
@@ -69,6 +71,7 @@ macmini_app.add_typer(macmini_notes_app, name="notes")
|
||||
macmini_app.add_typer(macmini_reminders_app, name="reminders")
|
||||
macmini_app.add_typer(macmini_deco_app, name="deco")
|
||||
app.add_typer(macmini_app, name="macmini")
|
||||
app.add_typer(remarkable_app, name="remarkable")
|
||||
|
||||
|
||||
def scrub_sensitive(value: Any) -> Any:
|
||||
@@ -692,6 +695,49 @@ def computer_service_logs(lines: int = typer.Option(80, "--lines")):
|
||||
subprocess.run(["journalctl", "--user", "-u", SERVICE_NAME, "-n", str(lines), "--no-pager"], check=False)
|
||||
|
||||
|
||||
@remarkable_app.command("listen")
|
||||
def remarkable_listen():
|
||||
"""Run the UDP receiver in the foreground (used by the LaunchAgent)."""
|
||||
listen_forever()
|
||||
|
||||
|
||||
@remarkable_app.command("sync")
|
||||
def remarkable_sync(json_output: bool = typer.Option(False, "--json")):
|
||||
"""Snapshot tracked notes and cache only fresh Paper Pro page versions."""
|
||||
result = sync_once()
|
||||
emit({"ok": bool(result.get("online")), "result": result}, json_output)
|
||||
|
||||
|
||||
@remarkable_app.command("service-install")
|
||||
def remarkable_service_install(json_output: bool = typer.Option(False, "--json")):
|
||||
"""Install and start the macOS UDP-listener LaunchAgent."""
|
||||
result = listener_service_action("install")
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@remarkable_app.command("service-status")
|
||||
def remarkable_service_status(json_output: bool = typer.Option(False, "--json")):
|
||||
emit({"ok": True, "service": LISTENER_LABEL, "status": listener_service_status()}, json_output)
|
||||
|
||||
|
||||
@remarkable_app.command("service-start")
|
||||
def remarkable_service_start(json_output: bool = typer.Option(False, "--json")):
|
||||
result = listener_service_action("start")
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@remarkable_app.command("service-stop")
|
||||
def remarkable_service_stop(json_output: bool = typer.Option(False, "--json")):
|
||||
result = listener_service_action("stop")
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@iphone_app.command("tools")
|
||||
def iphone_tools(json_output: bool = typer.Option(False, "--json"), live_only: bool = False, cache_only: bool = False, refresh: bool = False):
|
||||
wrapper_tools("iphone_mcp", json_output, live_only, cache_only, refresh)
|
||||
|
||||
Executable
+515
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
import datetime as dt
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import uuid as uuidlib
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_TABLET = os.environ.get("REMARKABLE_HOST", "root@192.168.68.128")
|
||||
BASE = "/home/root/.local/share/remarkable/xochitl"
|
||||
LISTENER_LABEL = "com.reynafamily.reyna-cli.remarkable-listener"
|
||||
SYNC_LABEL = "com.reynafamily.reyna-cli.remarkable-sync"
|
||||
LISTENER_PORT = 49321
|
||||
|
||||
|
||||
def data_root() -> Path:
|
||||
"""Return the Mac-local Paper Pro cache, with a testable override."""
|
||||
return Path(os.environ.get("REYNA_REMARKABLE_ROOT", Path.home() / "Library" / "Application Support" / "reyna-cli" / "remarkable"))
|
||||
|
||||
|
||||
def parse_udp_message(message: str) -> dict[str, str]:
|
||||
"""Parse the sidecar's key=value prefix while preserving its freeform details."""
|
||||
fields: dict[str, str] = {}
|
||||
prefix, marker, details = message.partition(" details=")
|
||||
for part in prefix.split():
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
fields[key] = value
|
||||
if marker:
|
||||
fields["details"] = details
|
||||
return fields
|
||||
|
||||
|
||||
LOGDIR = data_root()
|
||||
LOGDIR.mkdir(parents=True, exist_ok=True)
|
||||
KNOWN_HOSTS = LOGDIR / "known_hosts"
|
||||
STATE_PATH = LOGDIR / "state.json"
|
||||
EVENTS_PATH = LOGDIR / "events.jsonl"
|
||||
LATEST_PATH = LOGDIR / "latest.json"
|
||||
CACHE_DIR = LOGDIR / "cache"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LATEST_CHANGED_PAGES = LOGDIR / "latest_changed_pages.json"
|
||||
CACHE_STATE_PATH = LOGDIR / "cache_state.json"
|
||||
TRACKED_CONFIG_PATH = LOGDIR / "tracked_documents.json"
|
||||
RENDERER = LOGDIR / "tools" / "render_rm_fast.py"
|
||||
RENDER_PYTHON = Path(sys.executable)
|
||||
WATCH_TERMS = [t.strip().lower() for t in os.environ.get("REMARKABLE_WATCH_TERMS", "diary,dairy,journal").split(",") if t.strip()]
|
||||
|
||||
def resolve_tablet():
|
||||
# If REMARKABLE_HOST is explicitly set, honor it. Otherwise use latest UDP discovery.
|
||||
if os.environ.get("REMARKABLE_HOST"):
|
||||
return DEFAULT_TABLET
|
||||
latest = LOGDIR / "latest_device.json"
|
||||
try:
|
||||
data = json.loads(latest.read_text())
|
||||
ip = (data.get("fields") or {}).get("ip") or data.get("source_ip")
|
||||
if ip:
|
||||
return f"root@{ip}"
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_TABLET
|
||||
|
||||
def ssh_args():
|
||||
return [
|
||||
"ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=8",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", f"UserKnownHostsFile={KNOWN_HOSTS}",
|
||||
resolve_tablet(),
|
||||
]
|
||||
|
||||
def now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
def run_ssh(remote, timeout=20):
|
||||
return subprocess.run(ssh_args() + [remote], text=True, capture_output=True, timeout=timeout)
|
||||
|
||||
def load_state():
|
||||
try:
|
||||
return json.loads(STATE_PATH.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def load_cache_state():
|
||||
try:
|
||||
data = json.loads(CACHE_STATE_PATH.read_text())
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return {"seen_page_versions": []}
|
||||
|
||||
def save_cache_state(state):
|
||||
# Keep the state small; it only prevents stale page batches from being
|
||||
# re-announced after the tablet sleeps or reconnects.
|
||||
seen = list(dict.fromkeys(state.get("seen_page_versions", [])))[-1000:]
|
||||
tmp = CACHE_STATE_PATH.with_name(f"{CACHE_STATE_PATH.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
|
||||
tmp.write_text(json.dumps({"seen_page_versions": seen}, indent=2, sort_keys=True) + "\n")
|
||||
tmp.replace(CACHE_STATE_PATH)
|
||||
|
||||
def save_state(state):
|
||||
tmp = STATE_PATH.with_name(f"{STATE_PATH.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
|
||||
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
||||
tmp.replace(STATE_PATH)
|
||||
|
||||
def append_event(event):
|
||||
with EVENTS_PATH.open("a") as f:
|
||||
f.write(json.dumps(event, sort_keys=True, ensure_ascii=False) + "\n")
|
||||
LATEST_PATH.write_text(json.dumps(event, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
|
||||
|
||||
def load_tracked_uuids():
|
||||
try:
|
||||
data = json.loads(TRACKED_CONFIG_PATH.read_text())
|
||||
except Exception:
|
||||
return set()
|
||||
return {d.get("uuid") for d in data.get("tracked_documents", []) if d.get("uuid")}
|
||||
|
||||
def is_watched_doc(doc, tracked_uuids=None):
|
||||
tracked_uuids = tracked_uuids or set()
|
||||
name = (doc.get("visibleName") or "").lower()
|
||||
return doc.get("uuid") in tracked_uuids or any(term in name for term in WATCH_TERMS)
|
||||
|
||||
def ssh_read_file(remote_relpath, timeout=20):
|
||||
remote_path = f"{BASE}/{remote_relpath.lstrip('/')}"
|
||||
proc = subprocess.run(
|
||||
ssh_args() + ["cat " + shlex.quote(remote_path)],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout
|
||||
|
||||
def cache_remote_file(remote_relpath, local_relpath=None, timeout=25):
|
||||
data = ssh_read_file(remote_relpath, timeout=timeout)
|
||||
if data is None:
|
||||
return None
|
||||
local = CACHE_DIR / (local_relpath or remote_relpath)
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = local.with_name(f"{local.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(local)
|
||||
return local
|
||||
|
||||
def recent_udp_document_changes(limit=500):
|
||||
changes = []
|
||||
udp_dir = LOGDIR / "udp"
|
||||
files = sorted(udp_dir.glob("*.jsonl"), reverse=True)[:3]
|
||||
for path in files:
|
||||
try:
|
||||
lines = path.read_text(errors="replace").splitlines()[-limit:]
|
||||
except Exception:
|
||||
continue
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
fields = ev.get("fields") or {}
|
||||
if fields.get("event") == "document_change" and fields.get("file"):
|
||||
changes.append(ev)
|
||||
return changes
|
||||
|
||||
def render_cached_rm(rm_path):
|
||||
if not rm_path or rm_path.suffix != ".rm" or not rm_path.exists():
|
||||
return None
|
||||
if not RENDERER.exists():
|
||||
return None
|
||||
py = RENDER_PYTHON if RENDER_PYTHON.exists() else Path("python3")
|
||||
out = rm_path.with_suffix(".png")
|
||||
try:
|
||||
proc = subprocess.run([str(py), str(RENDERER), str(rm_path), str(out)], text=True, capture_output=True, timeout=45)
|
||||
except Exception:
|
||||
return None
|
||||
return out if proc.returncode == 0 and out.exists() else None
|
||||
|
||||
def extract_page_ids(content):
|
||||
"""Return only likely-current page IDs, not every page in the document.
|
||||
|
||||
Reprocessing every page whenever a document-level mtime changes caused the
|
||||
reviewer to loop over old handwriting. Exact changed pages come from the UDP
|
||||
sidecar below; this fallback should be narrow.
|
||||
"""
|
||||
ids = []
|
||||
current = (
|
||||
content.get("lastOpenedPage")
|
||||
or content.get("currentPage")
|
||||
or (content.get("cPages") or {}).get("lastOpened")
|
||||
)
|
||||
if isinstance(current, dict):
|
||||
current = current.get("value")
|
||||
if isinstance(current, str) and current:
|
||||
ids.append(current)
|
||||
seen = set()
|
||||
return [p for p in ids if not (p in seen or seen.add(p))]
|
||||
|
||||
def cache_changed_pages(snapshot, prev):
|
||||
"""Cache local copies of changed tracked pages so Hermes can read them later.
|
||||
|
||||
This intentionally limits itself to tracked/watched documents and recently
|
||||
changed/current pages. It does not mirror the whole tablet.
|
||||
"""
|
||||
if not snapshot.get("online"):
|
||||
return []
|
||||
tracked_uuids = load_tracked_uuids()
|
||||
prev_by_uuid = {d.get("uuid"): d for d in prev.get("watched", [])}
|
||||
watched = [d for d in snapshot.get("watched", []) if is_watched_doc(d, tracked_uuids)]
|
||||
changed_docs = []
|
||||
for doc in watched:
|
||||
old = prev_by_uuid.get(doc.get("uuid"))
|
||||
if not old or old.get("lastModified") != doc.get("lastModified"):
|
||||
changed_docs.append(doc)
|
||||
if not changed_docs:
|
||||
return []
|
||||
|
||||
udp_changes = recent_udp_document_changes()
|
||||
cache_state = load_cache_state()
|
||||
seen_versions = set(cache_state.get("seen_page_versions", []))
|
||||
cached = []
|
||||
for doc in changed_docs:
|
||||
uuid = doc.get("uuid")
|
||||
if not uuid:
|
||||
continue
|
||||
# Cache document metadata needed to map page IDs later.
|
||||
for ext in ("metadata", "content", "pagedata"):
|
||||
cache_remote_file(f"{uuid}.{ext}", f"{uuid}/{uuid}.{ext}", timeout=20)
|
||||
|
||||
content = {}
|
||||
content_path = CACHE_DIR / uuid / f"{uuid}.content"
|
||||
try:
|
||||
content = json.loads(content_path.read_text())
|
||||
except Exception:
|
||||
content = {}
|
||||
page_ids = set(extract_page_ids(content))
|
||||
|
||||
# UDP sidecar reports exact changed files; prefer those for changed pages.
|
||||
for ev in udp_changes:
|
||||
fields = ev.get("fields") or {}
|
||||
rel = fields.get("file") or ""
|
||||
if not rel.startswith(uuid + "/") and rel not in {f"{uuid}.content", f"{uuid}.metadata", f"{uuid}.pagedata"}:
|
||||
continue
|
||||
local = cache_remote_file(rel, rel, timeout=30)
|
||||
if local and local.suffix == ".rm":
|
||||
page_ids.add(local.stem)
|
||||
|
||||
# Also cache the last/current page from content because it is what the user most often just wrote on.
|
||||
for page_id in sorted(page_ids):
|
||||
version_key = f"{uuid}:{page_id}:{doc.get('lastModified') or 0}"
|
||||
if version_key in seen_versions:
|
||||
continue
|
||||
rel = f"{uuid}/{page_id}.rm"
|
||||
local = cache_remote_file(rel, rel, timeout=30)
|
||||
if not local:
|
||||
continue
|
||||
png = render_cached_rm(local)
|
||||
seen_versions.add(version_key)
|
||||
cached.append({
|
||||
"cached_at": now_iso(),
|
||||
"document_uuid": uuid,
|
||||
"document_name": doc.get("visibleName"),
|
||||
"page_uuid": page_id,
|
||||
"rm_path": str(local),
|
||||
"png_path": str(png) if png else None,
|
||||
"lastModified": doc.get("lastModified"),
|
||||
"lastModifiedIso": doc.get("lastModifiedIso"),
|
||||
"version_key": version_key,
|
||||
})
|
||||
|
||||
if seen_versions != set(cache_state.get("seen_page_versions", [])):
|
||||
save_cache_state({"seen_page_versions": sorted(seen_versions)})
|
||||
if cached:
|
||||
tmp = LATEST_CHANGED_PAGES.with_name(f"{LATEST_CHANGED_PAGES.name}.{os.getpid()}.{uuidlib.uuid4().hex}.tmp")
|
||||
tmp.write_text(json.dumps({"ts": now_iso(), "pages": cached}, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
|
||||
tmp.replace(LATEST_CHANGED_PAGES)
|
||||
else:
|
||||
# No fresh page versions. Remove the manifest so the cron reviewer does
|
||||
# not keep reprocessing an old batch.
|
||||
try:
|
||||
LATEST_CHANGED_PAGES.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return cached
|
||||
|
||||
def parse_json_member(tf, name):
|
||||
try:
|
||||
m = tf.extractfile(name)
|
||||
if not m:
|
||||
return None
|
||||
return json.loads(m.read().decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def collect_snapshot():
|
||||
ping = run_ssh("echo online; date -u +%Y-%m-%dT%H:%M:%SZ; systemctl is-active xochitl 2>/dev/null || true", timeout=12)
|
||||
if ping.returncode != 0:
|
||||
return {
|
||||
"ts": now_iso(),
|
||||
"event": "tablet_unreachable",
|
||||
"online": False,
|
||||
"error": (ping.stderr or ping.stdout).strip()[-500:],
|
||||
}
|
||||
|
||||
lines = [l.strip() for l in ping.stdout.splitlines() if l.strip()]
|
||||
tablet_time = lines[1] if len(lines) > 1 else None
|
||||
xochitl = lines[2] if len(lines) > 2 else "unknown"
|
||||
|
||||
tar_cmd = f"cd {BASE} && tar -cf - -- *.metadata *.content *.pagedata 2>/dev/null"
|
||||
tar_proc = subprocess.run(ssh_args() + [tar_cmd], capture_output=True, timeout=45)
|
||||
docs = []
|
||||
if tar_proc.returncode == 0 and tar_proc.stdout:
|
||||
tf = tarfile.open(fileobj=io.BytesIO(tar_proc.stdout), mode="r:")
|
||||
infos = {i.name: i for i in tf.getmembers() if i.isfile()}
|
||||
uuids = sorted({n[:-9] for n in infos if n.endswith(".metadata")})
|
||||
for uuid in uuids:
|
||||
meta_name = uuid + ".metadata"
|
||||
content_name = uuid + ".content"
|
||||
page_name = uuid + ".pagedata"
|
||||
meta = parse_json_member(tf, meta_name) or {}
|
||||
content = parse_json_member(tf, content_name) or {}
|
||||
mt = max(
|
||||
infos.get(meta_name).mtime if infos.get(meta_name) else 0,
|
||||
infos.get(content_name).mtime if infos.get(content_name) else 0,
|
||||
infos.get(page_name).mtime if infos.get(page_name) else 0,
|
||||
)
|
||||
visible = meta.get("visibleName") or meta.get("name") or ""
|
||||
docs.append({
|
||||
"uuid": uuid,
|
||||
"visibleName": visible,
|
||||
"type": meta.get("type"),
|
||||
"parent": meta.get("parent"),
|
||||
"lastModified": mt,
|
||||
"lastModifiedIso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mt)) if mt else None,
|
||||
"currentPage": content.get("lastOpenedPage") or content.get("cPages", {}).get("lastOpened") if isinstance(content.get("cPages"), dict) else content.get("lastOpenedPage"),
|
||||
"pageCount": content.get("pageCount") or len(content.get("pages", [])) if isinstance(content.get("pages"), list) else content.get("pageCount"),
|
||||
})
|
||||
docs.sort(key=lambda d: d.get("lastModified") or 0, reverse=True)
|
||||
tracked_uuids = load_tracked_uuids()
|
||||
watched = [d for d in docs if is_watched_doc(d, tracked_uuids)]
|
||||
return {
|
||||
"ts": now_iso(),
|
||||
"event": "snapshot",
|
||||
"online": True,
|
||||
"tablet_time": tablet_time,
|
||||
"xochitl": xochitl,
|
||||
"document_count": len(docs),
|
||||
"most_recent": docs[:5],
|
||||
"watched_terms": WATCH_TERMS,
|
||||
"watched": watched[:10],
|
||||
}
|
||||
|
||||
def summarize_changes(snapshot, prev):
|
||||
events = [snapshot]
|
||||
if not snapshot.get("online"):
|
||||
if prev.get("online") is not False:
|
||||
events.append({"ts": snapshot["ts"], "event": "tablet_offline_transition", "online": False})
|
||||
return events
|
||||
if prev.get("online") is False:
|
||||
events.append({"ts": snapshot["ts"], "event": "tablet_online_transition", "online": True})
|
||||
if prev.get("xochitl") and prev.get("xochitl") != snapshot.get("xochitl"):
|
||||
events.append({"ts": snapshot["ts"], "event": "xochitl_state_changed", "from": prev.get("xochitl"), "to": snapshot.get("xochitl")})
|
||||
cur = snapshot.get("most_recent", [{}])[0] if snapshot.get("most_recent") else {}
|
||||
prev_cur = prev.get("current_guess") or {}
|
||||
if cur and cur.get("uuid") != prev_cur.get("uuid"):
|
||||
events.append({"ts": snapshot["ts"], "event": "current_document_guess_changed", "document": cur})
|
||||
watched_prev = {d.get("uuid"): d for d in prev.get("watched", [])}
|
||||
for d in snapshot.get("watched", []):
|
||||
old = watched_prev.get(d.get("uuid"))
|
||||
if old and old.get("lastModified") != d.get("lastModified"):
|
||||
events.append({"ts": snapshot["ts"], "event": "watched_document_updated", "document": d})
|
||||
elif not old:
|
||||
events.append({"ts": snapshot["ts"], "event": "watched_document_seen", "document": d})
|
||||
return events
|
||||
|
||||
def sync_once() -> dict:
|
||||
"""Collect one tracked-note snapshot and cache only fresh changed pages."""
|
||||
prev = load_state()
|
||||
snap = collect_snapshot()
|
||||
cached_pages = cache_changed_pages(snap, prev)
|
||||
if cached_pages:
|
||||
snap["cached_changed_pages"] = cached_pages
|
||||
for ev in summarize_changes(snap, prev):
|
||||
append_event(ev)
|
||||
state = {
|
||||
"online": snap.get("online"),
|
||||
"xochitl": snap.get("xochitl"),
|
||||
"current_guess": (snap.get("most_recent") or [{}])[0] if snap.get("online") else prev.get("current_guess", {}),
|
||||
"watched": snap.get("watched", []) if snap.get("online") else prev.get("watched", []),
|
||||
"last_snapshot_ts": snap.get("ts"),
|
||||
}
|
||||
save_state(state)
|
||||
return snap
|
||||
|
||||
|
||||
def listen_forever(port: int = LISTENER_PORT) -> None:
|
||||
"""Persist Paper Pro UDP heartbeats and document_change events locally."""
|
||||
udp_dir = LOGDIR / "udp"
|
||||
udp_dir.mkdir(parents=True, exist_ok=True)
|
||||
latest = LOGDIR / "latest_device.json"
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", port))
|
||||
print(f"listening on UDP 0.0.0.0:{port}", flush=True)
|
||||
last_seen: dict[tuple[str, str], float] = {}
|
||||
while True:
|
||||
data, addr = sock.recvfrom(8192)
|
||||
now = dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
text = data.decode("utf-8", "replace").strip()
|
||||
key = (addr[0], text)
|
||||
now_epoch = time.time()
|
||||
if now_epoch - last_seen.get(key, 0) < 2:
|
||||
continue
|
||||
last_seen[key] = now_epoch
|
||||
if len(last_seen) > 200:
|
||||
last_seen = {item: seen_at for item, seen_at in last_seen.items() if seen_at >= now_epoch - 30}
|
||||
event = {
|
||||
"received_ts": now,
|
||||
"source_ip": addr[0],
|
||||
"source_port": addr[1],
|
||||
"message": text,
|
||||
"fields": parse_udp_message(text),
|
||||
}
|
||||
day = dt.datetime.now(dt.UTC).strftime("%Y-%m-%d")
|
||||
with (udp_dir / f"{day}.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True, ensure_ascii=False) + "\n")
|
||||
latest.write_text(json.dumps(event, indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps(event, sort_keys=True, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def launch_agent_path() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{LISTENER_LABEL}.plist"
|
||||
|
||||
|
||||
def build_launch_agent(python_bin: Path, cli_module: str, root: Path) -> dict:
|
||||
return {
|
||||
"Label": LISTENER_LABEL,
|
||||
"ProgramArguments": [str(python_bin), "-m", cli_module, "remarkable", "listen"],
|
||||
"EnvironmentVariables": {"REYNA_REMARKABLE_ROOT": str(root)},
|
||||
"RunAtLoad": True,
|
||||
"KeepAlive": True,
|
||||
"StandardOutPath": str(root / "listener.log"),
|
||||
"StandardErrorPath": str(root / "listener.error.log"),
|
||||
}
|
||||
|
||||
|
||||
def build_sync_agent(python_bin: Path, cli_module: str, root: Path) -> dict:
|
||||
return {
|
||||
"Label": SYNC_LABEL,
|
||||
"ProgramArguments": [str(python_bin), "-m", cli_module, "remarkable", "sync"],
|
||||
"EnvironmentVariables": {"REYNA_REMARKABLE_ROOT": str(root)},
|
||||
"RunAtLoad": True,
|
||||
"StartInterval": 30,
|
||||
"StandardOutPath": str(root / "sync.log"),
|
||||
"StandardErrorPath": str(root / "sync.error.log"),
|
||||
}
|
||||
|
||||
|
||||
def sync_agent_path() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{SYNC_LABEL}.plist"
|
||||
|
||||
|
||||
def install_listener_service(python_bin: Path | None = None) -> Path:
|
||||
root = data_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
path = launch_agent_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
interpreter = python_bin or Path(sys.executable)
|
||||
with path.open("wb") as handle:
|
||||
plistlib.dump(build_launch_agent(interpreter, "reyna_cli.cli", root), handle)
|
||||
with sync_agent_path().open("wb") as handle:
|
||||
plistlib.dump(build_sync_agent(interpreter, "reyna_cli.cli", root), handle)
|
||||
return path
|
||||
|
||||
|
||||
def listener_service_status() -> dict:
|
||||
uid = str(os.getuid())
|
||||
services = {}
|
||||
for label, path in ((LISTENER_LABEL, launch_agent_path()), (SYNC_LABEL, sync_agent_path())):
|
||||
proc = subprocess.run(["launchctl", "print", f"gui/{uid}/{label}"], capture_output=True, text=True, check=False)
|
||||
services[label] = {"installed": path.exists(), "path": str(path), "active": proc.returncode == 0, "detail": (proc.stdout or proc.stderr)[-1000:]}
|
||||
return {"listener": services[LISTENER_LABEL], "sync": services[SYNC_LABEL]}
|
||||
|
||||
|
||||
def listener_service_action(action: str) -> dict:
|
||||
uid = str(os.getuid())
|
||||
if action == "install":
|
||||
install_listener_service()
|
||||
action = "start"
|
||||
commands: list[list[str]] = []
|
||||
if action == "start":
|
||||
for label, path in ((LISTENER_LABEL, launch_agent_path()), (SYNC_LABEL, sync_agent_path())):
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True, text=True, check=False)
|
||||
commands.append(["launchctl", "bootstrap", f"gui/{uid}", str(path)])
|
||||
elif action == "stop":
|
||||
commands = [["launchctl", "bootout", f"gui/{uid}/{label}"] for label in (LISTENER_LABEL, SYNC_LABEL)]
|
||||
else:
|
||||
raise ValueError(f"unsupported service action: {action}")
|
||||
results = [subprocess.run(command, capture_output=True, text=True, check=False) for command in commands]
|
||||
return {
|
||||
"ok": all(proc.returncode == 0 for proc in results),
|
||||
"action": action,
|
||||
"results": [{"command": " ".join(command), "stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()} for command, proc in zip(commands, results)],
|
||||
"status": listener_service_status(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(sync_once(), indent=2, ensure_ascii=False, sort_keys=True))
|
||||
Reference in New Issue
Block a user