Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,23 @@ The application requires an INI configuration file to set up the Teams webhook U

## Usage

Run the monitor using the main script:
Run the daemon (long-running monitor, notifications, persistence, IPC server):

```bash
python main.py path/to/config.ini [OPTIONS]
python main.py daemon path/to/config.ini [OPTIONS]
```

### Options
Run the TUI client (attach/detach as needed, same host via SSH):

```bash
python main.py tui path/to/config.ini
```

In TUI mode, operator commands are available from stdin:
- `r` + Enter: force reconnect (beam + MCR) on daemon
- `q` + Enter: quit TUI client

### Daemon options

- `config`: (Required) Path to the `.ini` configuration file.
- `-nc`, `--notify_counts`: Counts threshold at which a "run about to finish" notification is sent (default: 130).
Expand All @@ -66,8 +76,48 @@ python main.py path/to/config.ini [OPTIONS]

### Example

To run the monitor with a custom configuration file and enabling dummy notifications for testing:
To run the daemon with a custom configuration file and dummy notifications for testing:

```bash
python main.py daemon config.ini --dummy
```

To run TUI from an SSH session on the same host:

```bash
python main.py tui config.ini
```

### Linux service example (systemd)

Create `/etc/systemd/system/isis-beam-monitor.service`:

```ini
[Unit]
Description=ISIS Beam Monitor Daemon
After=network-online.target

[Service]
Type=simple
WorkingDirectory=/path/to/ISIS_Beam_Monitor
ExecStart=/usr/bin/python /path/to/ISIS_Beam_Monitor/main.py daemon /path/to/ISIS_Beam_Monitor/config.ini
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

Then run:

```bash
python main.py config.ini --dummy
sudo systemctl daemon-reload
sudo systemctl enable --now isis-beam-monitor.service
sudo systemctl status isis-beam-monitor.service
```

### Troubleshooting

- **`Lock file already held`**: another daemon instance is running (or stale lock path configured).
- **TUI cannot connect**: ensure daemon is running and `[DAEMON].socket_path` matches `[TUI_CLIENT].socket_path`.
- **No live updates**: check `monitor.log` for websocket/news source errors; use `r` in TUI to force reconnect.
19 changes: 19 additions & 0 deletions config.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ experiment_teams_url =
# Maximum number of log lines to show in the TUI (default = 50).
# logs_maxlen = 50

[DAEMON]
# SQLite database path for persisted history/state.
# db_path = beam_monitor.db
# Unix domain socket path for local IPC.
# socket_path = /tmp/isis_beam_monitor.sock
# Single-instance lock file for daemon mode.
# lock_file = /tmp/isis_beam_monitor.lock
# Beam history retention window in days.
# retention_days = 7
# Heartbeat update interval in seconds.
# heartbeat_interval = 30

[TUI_CLIENT]
# Socket path to connect to daemon (same host).
# socket_path = /tmp/isis_beam_monitor.sock
# Reconnect backoff start and max in seconds.
# reconnect_initial = 1
# reconnect_max = 15

[LOGGING]
# log_file = monitor.log
# log_level = INFO
Expand Down
12 changes: 11 additions & 1 deletion isis_monitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,15 @@
from isis_monitor.beam import BeamMonitor
from isis_monitor.mcr import MCRNewsMonitor
from isis_monitor.config import AppConfig, load_config, ConfigError
from isis_monitor.daemon_state import DaemonState
from isis_monitor.storage import SQLiteStateStore

__all__ = ["BeamMonitor", "MCRNewsMonitor", "AppConfig", "load_config", "ConfigError"]
__all__ = [
"BeamMonitor",
"MCRNewsMonitor",
"AppConfig",
"load_config",
"ConfigError",
"DaemonState",
"SQLiteStateStore",
]
35 changes: 34 additions & 1 deletion isis_monitor/beam.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from isis_monitor.config import AppConfig
from isis_monitor.notifiers import NotificationChannel
from isis_monitor.protocols import TUIProtocol
from isis_monitor.protocols import TUIProtocol, MonitorSinkProtocol

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -55,6 +55,7 @@ def __init__(
experiment_channel: NotificationChannel,
counts_target: float,
tui: Optional[TUIProtocol] = None,
sink: Optional[MonitorSinkProtocol] = None,
):
self.config = config
self.data_url = config.isis_websocket_url
Expand All @@ -64,7 +65,10 @@ def __init__(
self.experiment_channel = experiment_channel
self.counts_target = counts_target
self.tui = tui
self.sink = sink
self.state = MonitorState()
self._force_reconnect = asyncio.Event()
self._current_ws = None

# Build dynamic lookups from Config
self.pv_to_beam: Dict[str, BeamTarget] = {
Expand Down Expand Up @@ -118,6 +122,8 @@ async def _handle_beam_current(

self.state.beams[bt.state_key].current = beam_val
self.state.beams[bt.state_key].power = new_state
if self.sink:
self.sink.update_beam_state(bt.channel_label, beam_val, new_state)

async def _handle_update(self, message: Dict[str, Any]):
"""Dispatch WebSocket update messages."""
Expand Down Expand Up @@ -148,6 +154,8 @@ async def _handle_update(self, message: Dict[str, Any]):
self.state.current_counts = 0

self.state.run_name = name
if self.sink:
self.sink.update_run_name(name)

case {"pv": pv, "text": text_val} if pv == self.counts_pv:
if not text_val or (
Expand All @@ -161,6 +169,8 @@ async def _handle_update(self, message: Dict[str, Any]):
return

self.state.current_counts = counts
if self.sink:
self.sink.update_counts(counts)

if self.state.end_notified and counts < (self.counts_target - 25):
self.state.end_notified = False
Expand All @@ -176,6 +186,14 @@ async def _handle_update(self, message: Dict[str, Any]):
state = self.state.beams[bt.state_key]
self.tui.update_beam_state(bt.channel_label, state.current, state.power)

def request_reconnect(self) -> bool:
if self._force_reconnect.is_set():
return False
self._force_reconnect.set()
if self._current_ws is not None:
asyncio.create_task(self._current_ws.close())
return True

async def run(self, stop_event: Optional[asyncio.Event] = None):
subscribe_msg = json.dumps({
"type": "subscribe",
Expand All @@ -192,11 +210,20 @@ async def run(self, stop_event: Optional[asyncio.Event] = None):
try:
async with websockets.connect(self.data_url) as ws:
logger.info("WebSocket connected.")
if self.sink:
self.sink.update_health("beam", "connected")
await ws.send(subscribe_msg)
self._current_ws = ws

async for raw_msg in ws:
if stop_event and stop_event.is_set():
return
if self._force_reconnect.is_set():
self._force_reconnect.clear()
logger.info("Beam reconnect requested by operator.")
if self.sink:
self.sink.update_health("beam", "reconnecting")
break
try:
data = json.loads(raw_msg)
if data.get("type") == "update":
Expand All @@ -209,10 +236,16 @@ async def run(self, stop_event: Optional[asyncio.Event] = None):
except (websockets.exceptions.ConnectionClosed, OSError):
if stop_event and stop_event.is_set():
return
if self.sink:
self.sink.update_health("beam", "disconnected")
logger.warning(f"WebSocket Connection lost. Reconnecting in {self.config.beam_reconnect_interval}s...")
await asyncio.sleep(self.config.beam_reconnect_interval)
except Exception as e:
if stop_event and stop_event.is_set():
return
if self.sink:
self.sink.update_health("beam", "error")
logger.error(f"Unexpected error in BeamMonitor: {e}. Reconnecting in {self.config.beam_reconnect_interval}s...")
await asyncio.sleep(self.config.beam_reconnect_interval)
finally:
self._current_ws = None
38 changes: 38 additions & 0 deletions isis_monitor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ class AppConfig:
refresh_per_second: int = 4
logs_maxlen: int = 50

# DAEMON
daemon_db_path: str = "beam_monitor.db"
daemon_socket_path: str = "/tmp/isis_beam_monitor.sock"
daemon_lock_file: str = "/tmp/isis_beam_monitor.lock"
retention_days: int = 7
heartbeat_interval: float = 30.0

# TUI_CLIENT
tui_socket_path: str = "/tmp/isis_beam_monitor.sock"
tui_reconnect_initial: float = 1.0
tui_reconnect_max: float = 15.0

# LOGGING
log_file: str = "monitor.log"
log_level: str = "INFO"
Expand Down Expand Up @@ -124,6 +136,24 @@ def _parse_tuple(section, key, default):
except (ValueError, configparser.Error) as exc:
raise ConfigError(f"[TUI] section contains invalid values: {exc}") from exc

# DAEMON (optional section)
daemon_db_path = config.get("DAEMON", "db_path", fallback="beam_monitor.db")
daemon_socket_path = config.get("DAEMON", "socket_path", fallback="/tmp/isis_beam_monitor.sock")
daemon_lock_file = config.get("DAEMON", "lock_file", fallback="/tmp/isis_beam_monitor.lock")
retention_days = config.getint("DAEMON", "retention_days", fallback=7)
heartbeat_interval = config.getfloat("DAEMON", "heartbeat_interval", fallback=30.0)
if retention_days <= 0:
raise ConfigError("[DAEMON] retention_days must be a positive integer")

# TUI_CLIENT (optional section)
tui_socket_path = config.get("TUI_CLIENT", "socket_path", fallback=daemon_socket_path)
tui_reconnect_initial = config.getfloat("TUI_CLIENT", "reconnect_initial", fallback=1.0)
tui_reconnect_max = config.getfloat("TUI_CLIENT", "reconnect_max", fallback=15.0)
if tui_reconnect_initial <= 0 or tui_reconnect_max <= 0:
raise ConfigError("[TUI_CLIENT] reconnect values must be positive")
if tui_reconnect_initial > tui_reconnect_max:
raise ConfigError("[TUI_CLIENT] reconnect_initial cannot be greater than reconnect_max")

return AppConfig(
mcr_news_url=mcr_news_url,
isis_websocket_url=isis_websocket_url,
Expand All @@ -145,6 +175,14 @@ def _parse_tuple(section, key, default):
sample_interval=sample_interval,
refresh_per_second=refresh_per_second,
logs_maxlen=logs_maxlen,
daemon_db_path=daemon_db_path,
daemon_socket_path=daemon_socket_path,
daemon_lock_file=daemon_lock_file,
retention_days=retention_days,
heartbeat_interval=heartbeat_interval,
tui_socket_path=tui_socket_path,
tui_reconnect_initial=tui_reconnect_initial,
tui_reconnect_max=tui_reconnect_max,
log_file=log_file,
log_level=log_level,
log_max_bytes=log_max_bytes,
Expand Down
Loading
Loading