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.
- vigia_eew/__init__.py +16 -0
- vigia_eew/agent_state.py +42 -0
- vigia_eew/app.py +426 -0
- vigia_eew/assets/countries.geojson +1 -0
- vigia_eew/assets/critical.wav +0 -0
- vigia_eew/assets/info.wav +0 -0
- vigia_eew/assets/tray_icon.png +0 -0
- vigia_eew/assets/warning.wav +0 -0
- vigia_eew/autostart/__init__.py +82 -0
- vigia_eew/autostart/linux_systemd.py +98 -0
- vigia_eew/autostart/macos_launchagent.py +84 -0
- vigia_eew/autostart/windows_task.py +82 -0
- vigia_eew/backoff.py +49 -0
- vigia_eew/cli.py +115 -0
- vigia_eew/config.py +246 -0
- vigia_eew/config.toml.example +71 -0
- vigia_eew/geo.py +35 -0
- vigia_eew/geocode.py +103 -0
- vigia_eew/geoloc.py +68 -0
- vigia_eew/i18n.py +70 -0
- vigia_eew/ingest/__init__.py +33 -0
- vigia_eew/ingest/rest_usgs.py +162 -0
- vigia_eew/ingest/ws_emsc.py +118 -0
- vigia_eew/logging_conf.py +69 -0
- vigia_eew/models.py +140 -0
- vigia_eew/notify/__init__.py +11 -0
- vigia_eew/notify/alert_window.py +172 -0
- vigia_eew/notify/controller.py +109 -0
- vigia_eew/notify/presentation.py +86 -0
- vigia_eew/notify/queue.py +130 -0
- vigia_eew/notify/sound.py +129 -0
- vigia_eew/notify/toast.py +80 -0
- vigia_eew/pipeline/__init__.py +8 -0
- vigia_eew/pipeline/dedup.py +72 -0
- vigia_eew/pipeline/filter.py +55 -0
- vigia_eew/pipeline/normalize.py +140 -0
- vigia_eew/pipeline/processor.py +80 -0
- vigia_eew/simulation.py +48 -0
- vigia_eew/state.py +130 -0
- vigia_eew/subprocess_env.py +40 -0
- vigia_eew/supervisor.py +134 -0
- vigia_eew/tray.py +130 -0
- vigia_eew/tui.py +174 -0
- vigia_eew-0.3.1.dist-info/METADATA +935 -0
- vigia_eew-0.3.1.dist-info/RECORD +48 -0
- vigia_eew-0.3.1.dist-info/WHEEL +4 -0
- vigia_eew-0.3.1.dist-info/entry_points.txt +2 -0
- 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__"]
|
vigia_eew/agent_state.py
ADDED
|
@@ -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
|