vigia-eew 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. vigia_eew/__init__.py +16 -0
  2. vigia_eew/agent_state.py +42 -0
  3. vigia_eew/app.py +426 -0
  4. vigia_eew/assets/countries.geojson +1 -0
  5. vigia_eew/assets/critical.wav +0 -0
  6. vigia_eew/assets/info.wav +0 -0
  7. vigia_eew/assets/tray_icon.png +0 -0
  8. vigia_eew/assets/warning.wav +0 -0
  9. vigia_eew/autostart/__init__.py +82 -0
  10. vigia_eew/autostart/linux_systemd.py +98 -0
  11. vigia_eew/autostart/macos_launchagent.py +84 -0
  12. vigia_eew/autostart/windows_task.py +82 -0
  13. vigia_eew/backoff.py +49 -0
  14. vigia_eew/cli.py +115 -0
  15. vigia_eew/config.py +246 -0
  16. vigia_eew/config.toml.example +71 -0
  17. vigia_eew/geo.py +35 -0
  18. vigia_eew/geocode.py +103 -0
  19. vigia_eew/geoloc.py +68 -0
  20. vigia_eew/i18n.py +70 -0
  21. vigia_eew/ingest/__init__.py +33 -0
  22. vigia_eew/ingest/rest_usgs.py +162 -0
  23. vigia_eew/ingest/ws_emsc.py +118 -0
  24. vigia_eew/logging_conf.py +69 -0
  25. vigia_eew/models.py +140 -0
  26. vigia_eew/notify/__init__.py +11 -0
  27. vigia_eew/notify/alert_window.py +172 -0
  28. vigia_eew/notify/controller.py +109 -0
  29. vigia_eew/notify/presentation.py +86 -0
  30. vigia_eew/notify/queue.py +130 -0
  31. vigia_eew/notify/sound.py +129 -0
  32. vigia_eew/notify/toast.py +80 -0
  33. vigia_eew/pipeline/__init__.py +8 -0
  34. vigia_eew/pipeline/dedup.py +72 -0
  35. vigia_eew/pipeline/filter.py +55 -0
  36. vigia_eew/pipeline/normalize.py +140 -0
  37. vigia_eew/pipeline/processor.py +80 -0
  38. vigia_eew/simulation.py +48 -0
  39. vigia_eew/state.py +130 -0
  40. vigia_eew/subprocess_env.py +40 -0
  41. vigia_eew/supervisor.py +134 -0
  42. vigia_eew/tray.py +130 -0
  43. vigia_eew/tui.py +174 -0
  44. vigia_eew-0.3.1.dist-info/METADATA +935 -0
  45. vigia_eew-0.3.1.dist-info/RECORD +48 -0
  46. vigia_eew-0.3.1.dist-info/WHEEL +4 -0
  47. vigia_eew-0.3.1.dist-info/entry_points.txt +2 -0
  48. vigia_eew-0.3.1.dist-info/licenses/LICENSE +674 -0
vigia_eew/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Vigía-eew — real-time earthquake alert agent, impossible to ignore.
2
+
3
+ Main package. Exposes the version and the core data models.
4
+ See `docs/` for the Spec-Driven Development artifacts.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ # Single source of truth: the installed distribution's version (set from
11
+ # pyproject.toml at build time), so `--version` never drifts from the release.
12
+ __version__ = version("vigia-eew")
13
+ except PackageNotFoundError: # not installed (e.g. running from a bare checkout)
14
+ __version__ = "0.0.0"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,42 @@
1
+ """In-memory state shared across threads, for the tray icon (RF-34).
2
+
3
+ `AgentState` is a small, lock-protected snapshot: `WSIngestor` updates it on
4
+ connect/reconnect (asyncio thread) and `AlertController` on showing an alert
5
+ (Tk thread); `tray.py` reads it from its own thread (pystray) for the menu text.
6
+ Not persisted — it only lives while the process is running.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+
13
+
14
+ class AgentState:
15
+ """Thread-safe snapshot of connection status and last alert, for the tray menu."""
16
+
17
+ def __init__(self) -> None:
18
+ self._lock = threading.Lock()
19
+ self._ws_connected = False
20
+ self._last_alert: str | None = None
21
+
22
+ @property
23
+ def ws_connected(self) -> bool:
24
+ with self._lock:
25
+ return self._ws_connected
26
+
27
+ @property
28
+ def last_alert(self) -> str | None:
29
+ with self._lock:
30
+ return self._last_alert
31
+
32
+ def mark_connected(self) -> None:
33
+ with self._lock:
34
+ self._ws_connected = True
35
+
36
+ def mark_reconnecting(self) -> None:
37
+ with self._lock:
38
+ self._ws_connected = False
39
+
40
+ def mark_last_alert(self, summary: str) -> None:
41
+ with self._lock:
42
+ self._last_alert = summary
vigia_eew/app.py ADDED
@@ -0,0 +1,426 @@
1
+ """Application assembly (RF-26, RF-21; concurrency ADR-006).
2
+
3
+ `Application` wires all layers into a runnable agent:
4
+
5
+ ingestion (WS + REST) -> asyncio queue -> processor (normalize/filter/dedup)
6
+ -> asyncio<->Tk bridge -> alert controller (window + sound + toast).
7
+
8
+ Follows the ADR-006 concurrency model: **Tkinter on the main thread** and the
9
+ **asyncio loop on a worker thread**; events cross via `AsyncioTkBridge`.
10
+
11
+ - `execute()`: full agent (real ingestion + notification).
12
+ - `simulate()`: injects the simulated event into the notification layer, no network (RF-21).
13
+
14
+ The parts with logic (supervisor task selection, controller construction) are
15
+ isolated into testable methods; thread/GUI startup is integration glue.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import threading
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from vigia_eew import geocode, geoloc, tray
28
+ from vigia_eew.agent_state import AgentState
29
+ from vigia_eew.config import ReferencePoint, Settings, default_config_path
30
+ from vigia_eew.i18n import resolve_locale
31
+ from vigia_eew.ingest import RawMessage
32
+ from vigia_eew.ingest.rest_usgs import RESTReconciler
33
+ from vigia_eew.ingest.ws_emsc import WSIngestor
34
+ from vigia_eew.logging_conf import configure_logging
35
+ from vigia_eew.models import SeismicEvent, SeverityLevel
36
+ from vigia_eew.notify.controller import AlertController
37
+ from vigia_eew.notify.presentation import AlertData
38
+ from vigia_eew.notify.queue import AsyncioTkBridge
39
+ from vigia_eew.notify.sound import SoundPlayer
40
+ from vigia_eew.notify.toast import Toaster
41
+ from vigia_eew.pipeline.dedup import Deduplicator
42
+ from vigia_eew.pipeline.filter import GeoFilter
43
+ from vigia_eew.pipeline.normalize import Normalizer
44
+ from vigia_eew.pipeline.processor import Processor
45
+ from vigia_eew.simulation import simulated_event
46
+ from vigia_eew.state import StateStore
47
+ from vigia_eew.supervisor import Supervisor
48
+
49
+ _WindowFactory = Callable[[AlertData, SeverityLevel, Callable[[], None]], Any]
50
+
51
+
52
+ class Application:
53
+ """Builds and runs the Vigía agent (full mode or `--simulate`)."""
54
+
55
+ def __init__(
56
+ self,
57
+ cfg: Settings,
58
+ *,
59
+ state: StateStore | None = None,
60
+ logger: logging.Logger | None = None,
61
+ manual_reference: bool = True,
62
+ detect_location: Callable[[], ReferencePoint | None] | None = None,
63
+ config_path: Path | str | None = None,
64
+ ) -> None:
65
+ self.cfg = cfg
66
+ self.state = state or StateStore()
67
+ self._log = logger or logging.getLogger("vigia_eew.app")
68
+ self._loop: asyncio.AbstractEventLoop | None = None
69
+ self._sup: Supervisor | None = None
70
+ self._root: Any = None
71
+ self._ctrl: AlertController | None = None
72
+ self._exit_on_drain = False
73
+ self._manual_reference = manual_reference
74
+ self._detect_location = detect_location or geoloc.detect_ip_location
75
+ self._config_path = config_path
76
+ self._agent_state = AgentState()
77
+ self._tray_icon: tray.TrayIcon | None = None
78
+ self._tui_app: Any = None
79
+ self._locale = resolve_locale(cfg.notification.language)
80
+
81
+ # --- Testable wiring ---
82
+
83
+ def _build_supervisor(
84
+ self, raw_queue: asyncio.Queue[RawMessage], processor: Any
85
+ ) -> Supervisor:
86
+ """Registers the agent's tasks based on the enabled sources (RNF-04)."""
87
+ sup = Supervisor(handle_signals=False) # signals are handled by the main thread
88
+ if self.cfg.sources_emsc.enabled:
89
+ sup.add(
90
+ "ws",
91
+ lambda: WSIngestor(
92
+ self.cfg.sources_emsc, raw_queue, state=self._agent_state
93
+ ).run(),
94
+ )
95
+ if self.cfg.sources_usgs.enabled:
96
+ sup.add(
97
+ "rest",
98
+ lambda: RESTReconciler(
99
+ self.cfg.sources_usgs,
100
+ self.cfg.reference,
101
+ self.cfg.filter,
102
+ self.state,
103
+ raw_queue,
104
+ ).run(),
105
+ )
106
+ sup.add("pipeline", lambda: processor.run())
107
+ return sup
108
+
109
+ def _resolve_user_country(self) -> str | None:
110
+ """ISO-A2 code of the user's country: config override, or derived by
111
+ reverse-geocoding the (already resolved) reference point (RF-37)."""
112
+ configured = self.cfg.filter.country
113
+ if configured != "auto":
114
+ return configured.upper()
115
+ return geocode.country_of(self.cfg.reference.lat, self.cfg.reference.lon)
116
+
117
+ def _build_geo_filter(self) -> GeoFilter:
118
+ """Builds the geo/magnitude filter, wiring the country filter when enabled (RF-37).
119
+
120
+ Fail-safe: if the filter is enabled but the user's country can't be determined,
121
+ the filter stays inert (never suppresses alerts on a detection gap).
122
+ """
123
+ if not self.cfg.filter.country_filter:
124
+ return GeoFilter(self.cfg.filter)
125
+ user_country = self._resolve_user_country()
126
+ if user_country is None:
127
+ self._log.warning("country_filter_no_country_using_none")
128
+ return GeoFilter(self.cfg.filter)
129
+ self._log.info("country_filter_active country=%s", user_country)
130
+ return GeoFilter(
131
+ self.cfg.filter, user_country=user_country, country_of=geocode.country_of
132
+ )
133
+
134
+ def _build_controller(
135
+ self,
136
+ create_window: _WindowFactory,
137
+ *,
138
+ play_sound: Callable[[SeverityLevel], None] | None = None,
139
+ publish_toast: Callable[[SeismicEvent], None] | None = None,
140
+ ) -> AlertController:
141
+ """Creates the alert controller with the injected effects."""
142
+ ctrl = AlertController(
143
+ create_window=create_window,
144
+ play_sound=play_sound,
145
+ send_toast=publish_toast,
146
+ on_acknowledge=self._after_acknowledge,
147
+ reference_name=self.cfg.reference.name,
148
+ state=self._agent_state,
149
+ locale_code=self._locale,
150
+ )
151
+ self._ctrl = ctrl
152
+ return ctrl
153
+
154
+ def _build_tray(self) -> tray.TrayIcon | None:
155
+ """Builds the tray icon if enabled (RF-34); best-effort."""
156
+ if not self.cfg.notification.tray_icon:
157
+ return None
158
+ try:
159
+ icon = tray.build_icon(
160
+ state=self._agent_state,
161
+ paused=lambda: self._ctrl.paused if self._ctrl is not None else False,
162
+ toggle_pause=self._toggle_pause,
163
+ edit_config=self._edit_config,
164
+ exit=self._exit_from_tray,
165
+ locale_code=self._locale,
166
+ )
167
+ return tray.TrayIcon(icon)
168
+ except Exception as exc: # noqa: BLE001 - deliberate best-effort (RF-34)
169
+ self._log.warning("tray_unavailable type=%s detail=%s", type(exc).__name__, exc)
170
+ return None
171
+
172
+ def _toggle_pause(self) -> None:
173
+ """Tray icon callback: pause/resume (RF-34).
174
+
175
+ Scheduled via `root.after(0, ...)` because `resume()` can trigger the
176
+ creation of a Tk window, and Tkinter is not thread-safe (ADR-006) — the
177
+ tray icon runs on its own thread, not on the Tk thread.
178
+ """
179
+ if self._root is None or self._ctrl is None:
180
+ return
181
+
182
+ def do_toggle() -> None:
183
+ if self._ctrl is None:
184
+ return
185
+ if self._ctrl.paused:
186
+ self._ctrl.resume()
187
+ else:
188
+ self._ctrl.pause()
189
+
190
+ self._root.after(0, do_toggle)
191
+
192
+ def _exit_from_tray(self) -> None:
193
+ """Tray icon callback: exits the agent (RF-34)."""
194
+ if self._root is not None:
195
+ self._root.after(0, self._root.quit)
196
+
197
+ def _edit_config(self) -> None:
198
+ """Tray icon callback: opens `config.toml` with the OS's associated app (RF-34)."""
199
+ path = (
200
+ Path(self._config_path)
201
+ if self._config_path is not None
202
+ else default_config_path()
203
+ )
204
+ tray.open_config(path)
205
+
206
+ def _after_acknowledge(self, _ev: SeismicEvent) -> None:
207
+ """After acknowledge: closes the app if the queue is now empty
208
+ and applicable (simulate).
209
+ """
210
+ if (
211
+ self._exit_on_drain
212
+ and self._ctrl is not None
213
+ and self._ctrl.alert_queue.current is None
214
+ and self._ctrl.alert_queue.pending == 0
215
+ and self._root is not None
216
+ ):
217
+ self._root.after(150, self._root.quit)
218
+
219
+ # --- Real GUI construction ---
220
+
221
+ def _prepare(self, *, resolve_location: bool = False) -> None:
222
+ configure_logging(self.cfg.logging)
223
+ self.state.load()
224
+ if resolve_location and not self._manual_reference:
225
+ self._resolve_automatic_reference()
226
+
227
+ def _resolve_automatic_reference(self) -> None:
228
+ """Resolves the reference point by IP when there's no manual `[reference]` (RF-33)."""
229
+ cached = self.state.cached_location()
230
+ if cached is not None:
231
+ self.cfg.reference = cached
232
+ self._log.info("ip_location_cache name=%s", cached.name)
233
+ return
234
+ detected = self._detect_location()
235
+ if detected is None:
236
+ self._log.warning("ip_location_fallback_default")
237
+ return
238
+ self.cfg.reference = detected
239
+ self.state.cache_location(detected)
240
+ self.state.save()
241
+ self._log.info("ip_location_detected name=%s", detected.name)
242
+
243
+ def _new_root(self) -> Any:
244
+ import tkinter as tk
245
+
246
+ root = tk.Tk()
247
+ root.withdraw() # hidden root; each alert is a Toplevel
248
+ self._root = root
249
+ return root
250
+
251
+ def _controller_for_gui(self, root: Any, *, loop_mode: bool) -> AlertController:
252
+ import tkinter as tk
253
+
254
+ from vigia_eew.notify.alert_window import AlertWindow
255
+
256
+ sound = SoundPlayer(enabled=self.cfg.notification.sound)
257
+ toaster = Toaster(reference_name=self.cfg.reference.name, locale_code=self._locale)
258
+
259
+ def create_window(
260
+ data: AlertData, severity: SeverityLevel, on_acknowledge: Callable[[], None]
261
+ ) -> AlertWindow:
262
+ return AlertWindow(
263
+ data,
264
+ on_acknowledge=on_acknowledge,
265
+ root=tk.Toplevel(root),
266
+ fullscreen=self.cfg.notification.fullscreen,
267
+ locale=self._locale,
268
+ )
269
+
270
+ def play_sound(severity: SeverityLevel) -> None:
271
+ threading.Thread(target=sound.play, args=(severity,), daemon=True).start()
272
+
273
+ def publish_toast(ev: SeismicEvent) -> None:
274
+ if loop_mode and self._loop is not None:
275
+ asyncio.run_coroutine_threadsafe(toaster.notify(ev), self._loop)
276
+ else:
277
+ threading.Thread(
278
+ target=lambda: asyncio.run(toaster.notify(ev)), daemon=True
279
+ ).start()
280
+
281
+ play_sound_fn = play_sound if self.cfg.notification.sound else None
282
+ return self._build_controller(
283
+ create_window, play_sound=play_sound_fn, publish_toast=publish_toast
284
+ )
285
+
286
+ # --- Headless TUI dashboard construction (RF-36, ADR-013) ---
287
+
288
+ def _controller_for_tui(self, tui_app: Any) -> AlertController:
289
+ """Builds the alert controller wired to the TUI (RF-36).
290
+
291
+ There is no toast in this mode (there's no desktop session on a
292
+ headless server); sound stays best-effort like in the Tk path.
293
+ """
294
+ sound = SoundPlayer(enabled=self.cfg.notification.sound)
295
+
296
+ def create_window(
297
+ data: AlertData, severity: SeverityLevel, on_acknowledge: Callable[[], None]
298
+ ) -> Any:
299
+ return tui_app.push_alert(data, severity, on_acknowledge)
300
+
301
+ def play_sound(severity: SeverityLevel) -> None:
302
+ threading.Thread(target=sound.play, args=(severity,), daemon=True).start()
303
+
304
+ play_sound_fn = play_sound if self.cfg.notification.sound else None
305
+ ctrl = self._build_controller(
306
+ create_window, play_sound=play_sound_fn, publish_toast=None
307
+ )
308
+ tui_app.bind_controller(ctrl)
309
+ return ctrl
310
+
311
+ def _wire_tui(self, tui_app: Any) -> AlertController:
312
+ """Wires the controller + supervisor for the TUI dashboard (RF-36).
313
+
314
+ Unlike `execute()`, no asyncio<->Tk bridge is needed: Textual runs on
315
+ the same asyncio loop as the supervisor worker, so the processor calls
316
+ `ctrl.enqueue` directly.
317
+ """
318
+ ctrl = self._controller_for_tui(tui_app)
319
+ raw_queue: asyncio.Queue[RawMessage] = asyncio.Queue()
320
+ processor = Processor(
321
+ raw_queue,
322
+ Normalizer(self.cfg.reference, self.cfg.severity),
323
+ self._build_geo_filter(),
324
+ Deduplicator(self.cfg.dedup, self.state),
325
+ on_alert=ctrl.enqueue,
326
+ on_update=ctrl.enqueue,
327
+ )
328
+ sup = self._build_supervisor(raw_queue, processor)
329
+ self._sup = sup
330
+ tui_app.bind_supervisor(sup)
331
+ return ctrl
332
+
333
+ # --- Run modes ---
334
+
335
+ def simulate(self) -> None:
336
+ """Injects the simulated event and shows the alert until acknowledged (RF-21)."""
337
+ self._prepare()
338
+ self._exit_on_drain = True
339
+ root = self._new_root()
340
+ ctrl = self._controller_for_gui(root, loop_mode=False)
341
+ ctrl.enqueue(simulated_event(self.cfg.reference, self.cfg.severity))
342
+ self._log.info("simulation_started")
343
+ root.mainloop()
344
+
345
+ def run_tui(self, *, simulate: bool = False) -> None:
346
+ """Starts the agent with the headless TUI dashboard (RF-36, ADR-013).
347
+
348
+ With `simulate=True` (`--simulate --tui`), injects the simulated event
349
+ into the running TUI without starting the real ingestion, analogous to
350
+ `simulate()` for the Tk path.
351
+ """
352
+ from vigia_eew.tui import VigiaTuiApp
353
+
354
+ self._prepare(resolve_location=not simulate)
355
+ if simulate:
356
+ self._tui_app = VigiaTuiApp(
357
+ state=self._agent_state,
358
+ locale_code=self._locale,
359
+ on_start=self._inject_simulated_alert,
360
+ )
361
+ self._controller_for_tui(self._tui_app)
362
+ else:
363
+ self._tui_app = VigiaTuiApp(state=self._agent_state, locale_code=self._locale)
364
+ self._wire_tui(self._tui_app)
365
+ self._log.info("tui_simulation_started" if simulate else "tui_started")
366
+ self._tui_app.run()
367
+
368
+ def _inject_simulated_alert(self) -> None:
369
+ """Enqueues the simulated event into the running TUI (`--simulate --tui`)."""
370
+ if self._ctrl is not None:
371
+ self._ctrl.enqueue(simulated_event(self.cfg.reference, self.cfg.severity))
372
+
373
+ def execute(self) -> None:
374
+ """Starts the full agent: ingestion + pipeline + notification (CU-1, CU-2)."""
375
+ self._prepare(resolve_location=True)
376
+ root = self._new_root()
377
+ ctrl = self._controller_for_gui(root, loop_mode=True)
378
+ self._tray_icon = self._build_tray()
379
+ if self._tray_icon is not None:
380
+ self._tray_icon.start()
381
+ bridge = AsyncioTkBridge(sink=ctrl.enqueue)
382
+ bridge.start_polling(root, interval_ms=100)
383
+ thread = threading.Thread(target=self._run_loop, args=(bridge,), daemon=True)
384
+ thread.start()
385
+ self._log.info("agent_started")
386
+ try:
387
+ root.mainloop()
388
+ except KeyboardInterrupt:
389
+ self._log.info("keyboard_interrupt")
390
+ finally:
391
+ self._stop(thread)
392
+
393
+ def _run_loop(self, bridge: AsyncioTkBridge) -> None:
394
+ loop = asyncio.new_event_loop()
395
+ asyncio.set_event_loop(loop)
396
+ self._loop = loop
397
+ raw_queue: asyncio.Queue[RawMessage] = asyncio.Queue()
398
+ processor = Processor(
399
+ raw_queue,
400
+ Normalizer(self.cfg.reference, self.cfg.severity),
401
+ self._build_geo_filter(),
402
+ Deduplicator(self.cfg.dedup, self.state),
403
+ on_alert=bridge.publish,
404
+ on_update=bridge.publish,
405
+ )
406
+ sup = self._build_supervisor(raw_queue, processor)
407
+ self._sup = sup
408
+ try:
409
+ loop.run_until_complete(sup.run())
410
+ except Exception as exc: # noqa: BLE001 - log any loop failure
411
+ self._log.warning("loop_error type=%s detail=%s", type(exc).__name__, exc)
412
+ finally:
413
+ loop.close()
414
+
415
+ def _stop(self, thread: threading.Thread) -> None:
416
+ """Coordinated shutdown: stops the tray icon, the supervisor, and the asyncio thread."""
417
+ if self._tray_icon is not None:
418
+ self._tray_icon.stop()
419
+ if self._loop is not None and self._sup is not None:
420
+ self._loop.call_soon_threadsafe(self._sup.request_stop)
421
+ thread.join(timeout=5.0)
422
+ if self._root is not None:
423
+ try:
424
+ self._root.destroy()
425
+ except Exception: # noqa: BLE001 - root may already be destroyed
426
+ pass