devman-runtime 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devman-runtime
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges
5
5
  Author: Kenji Shu
6
6
  License-Expression: MIT
@@ -30,20 +30,21 @@ a normal dependency upgrade instead of regeneration.
30
30
  (`set_link_groups` / `list_link_groups`).
31
31
  - `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
32
32
  dispatch with ownership enforcement, device lifecycle/periodic hooks,
33
- `TripWatchdog` (powers off linked partners when a channel trips, with
34
- lease-based stale-group janitoring).
33
+ client leases. Hook files receive `core` in their context, so
34
+ device-specific safety monitors (e.g. the CAEN trip watchdog in
35
+ caenhv-devman's server_hooks.py) can use the registry and leases.
35
36
  - `devman_runtime.db` — SQLite-backed ownership and link-group registry.
36
37
 
37
38
  ## Server features
38
39
 
39
- - `trip_watchdog_interval` / `--trip-watchdog-interval`: poll registered
40
- link groups and power off partners of a tripped channel, even when the
41
- registering client is offline.
42
40
  - `client_lease_sec` / `--client-lease-sec`: any authenticated request
43
- renews a client lease; groups of lease-expired clients are janitored when
44
- all their channels are off, and kept protected while energized.
41
+ renews a client lease; `core.is_client_live(name)` exposes it to hooks
42
+ (used e.g. for janitoring stale link groups).
45
43
  - Periodic hook (`periodic_file` / `periodic_function` +
46
44
  `periodic_interval_sec`): run a user callback on an interval with access
47
- to the device singleton.
45
+ to the device singleton and the core.
46
+
47
+ Device-specific policies (what a "trip" means, when to power off partners)
48
+ belong in per-bridge hook files, not in this package.
48
49
 
49
50
  This package is used by, e.g., `caenhv-devman-client`.
@@ -16,20 +16,21 @@ a normal dependency upgrade instead of regeneration.
16
16
  (`set_link_groups` / `list_link_groups`).
17
17
  - `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
18
18
  dispatch with ownership enforcement, device lifecycle/periodic hooks,
19
- `TripWatchdog` (powers off linked partners when a channel trips, with
20
- lease-based stale-group janitoring).
19
+ client leases. Hook files receive `core` in their context, so
20
+ device-specific safety monitors (e.g. the CAEN trip watchdog in
21
+ caenhv-devman's server_hooks.py) can use the registry and leases.
21
22
  - `devman_runtime.db` — SQLite-backed ownership and link-group registry.
22
23
 
23
24
  ## Server features
24
25
 
25
- - `trip_watchdog_interval` / `--trip-watchdog-interval`: poll registered
26
- link groups and power off partners of a tripped channel, even when the
27
- registering client is offline.
28
26
  - `client_lease_sec` / `--client-lease-sec`: any authenticated request
29
- renews a client lease; groups of lease-expired clients are janitored when
30
- all their channels are off, and kept protected while energized.
27
+ renews a client lease; `core.is_client_live(name)` exposes it to hooks
28
+ (used e.g. for janitoring stale link groups).
31
29
  - Periodic hook (`periodic_file` / `periodic_function` +
32
30
  `periodic_interval_sec`): run a user callback on an interval with access
33
- to the device singleton.
31
+ to the device singleton and the core.
32
+
33
+ Device-specific policies (what a "trip" means, when to power off partners)
34
+ belong in per-bridge hook files, not in this package.
34
35
 
35
36
  This package is used by, e.g., `caenhv-devman-client`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devman-runtime"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -7,7 +7,7 @@ agnostic and is not required at runtime.
7
7
 
8
8
  from .client import ManagerClient, ManagerError
9
9
  from .db import OwnershipDB
10
- from .server import ManagerCore, RuntimeFunctionSpec, TripWatchdog, serve_manager
10
+ from .server import ManagerCore, RuntimeFunctionSpec, serve_manager
11
11
  from .telegraf import TelegrafSender, build_line
12
12
 
13
13
  __all__ = [
@@ -17,7 +17,6 @@ __all__ = [
17
17
  "OwnershipDB",
18
18
  "RuntimeFunctionSpec",
19
19
  "TelegrafSender",
20
- "TripWatchdog",
21
20
  "build_line",
22
21
  "serve_manager",
23
22
  ]
@@ -21,207 +21,6 @@ from .protocol import recv_message, send_message
21
21
 
22
22
  _EXPAND_FIELD_RE = re.compile(r"\{([A-Za-z_]\w*)\[\]\}")
23
23
 
24
- _CHANNEL_RESOURCE_RE = re.compile(r"^slot:(\d+):ch:(\d+)$")
25
-
26
-
27
- class TripWatchdog:
28
- """Monitor registered link groups and power off partners when one trips.
29
-
30
- Runs server-side so linked channels stay protected even when the client
31
- application that registered the groups is closed. Status is read and Pw
32
- is written directly on the device singleton, bypassing ownership checks:
33
- powering a channel off is the inherently safe direction.
34
- """
35
-
36
- # Status bits per CAEN convention: 0 = ON, 6 = external trip, 8 = internal trip.
37
- ON_MASK = 1 << 0
38
- TRIP_MASK = (1 << 6) | (1 << 8)
39
-
40
- def __init__(
41
- self,
42
- device: Any,
43
- db: OwnershipDB,
44
- interval_sec: float,
45
- is_client_live: Any | None = None,
46
- device_lock: Any | None = None,
47
- ) -> None:
48
- self._device = device
49
- self._db = db
50
- self._interval_sec = float(interval_sec)
51
- self._is_client_live = is_client_live
52
- self._device_lock = device_lock if device_lock is not None else Lock()
53
- self._stop = Event()
54
- self._thread: Thread | None = None
55
- self._latched_groups: set[frozenset[tuple[int, int]]] = set()
56
- self._warned_unsync: set[tuple[str, int]] = set()
57
-
58
- def start(self) -> None:
59
- if self._thread is not None:
60
- return
61
- self._thread = Thread(target=self._loop, name="devman-trip-watchdog", daemon=True)
62
- self._thread.start()
63
-
64
- def stop(self) -> None:
65
- self._stop.set()
66
- if self._thread is not None:
67
- self._thread.join(timeout=2.0)
68
- self._thread = None
69
-
70
- def _log(self, message: str) -> None:
71
- ts = datetime.now().isoformat(timespec="seconds")
72
- print(f"[devman trip-watchdog {ts}] {message}", file=sys.stderr, flush=True)
73
-
74
- @staticmethod
75
- def _parse_members(resources: list[str]) -> list[tuple[int, int]]:
76
- members: list[tuple[int, int]] = []
77
- for resource in resources:
78
- match = _CHANNEL_RESOURCE_RE.match(str(resource).strip())
79
- if match:
80
- members.append((int(match.group(1)), int(match.group(2))))
81
- return members
82
-
83
- def _read_status(self, slot: int, channel: int) -> int | None:
84
- try:
85
- with self._device_lock:
86
- values = self._device.get_ch_param(slot, [channel], "Status")
87
- return int(values[0])
88
- except Exception:
89
- return None
90
-
91
- def _power_off(self, slot: int, channel: int) -> bool:
92
- try:
93
- with self._device_lock:
94
- self._device.set_ch_param(slot, [channel], "Pw", 0)
95
- return True
96
- except Exception as exc:
97
- self._log(f"FAILED to power off {slot}:{channel}: {exc}")
98
- return False
99
-
100
- def _read_param_any(self, slot: int, channel: int, names: list[str]) -> Any | None:
101
- for name in names:
102
- try:
103
- with self._device_lock:
104
- values = self._device.get_ch_param(slot, [channel], name)
105
- if isinstance(values, list) and values:
106
- return values[0]
107
- except Exception:
108
- continue
109
- return None
110
-
111
- def _group_settings_synchronized(self, members: list[tuple[int, int]]) -> bool:
112
- """Same-name equality of RUp/RDWn plus PDwn match across the group.
113
-
114
- The registering client keeps mixed-polarity groups with all four ramp
115
- values equal, so same-name equality is a valid check for both group
116
- kinds. Unreadable values are treated as synchronized (cannot judge).
117
- """
118
- for names, numeric in ((["RUp", "RUP"], True), (["RDWn", "RDown", "RDWN"], True), (["PDwn", "PDWN"], False)):
119
- seen: set[Any] = set()
120
- for slot, channel in members:
121
- value = self._read_param_any(slot, channel, names)
122
- if value is None:
123
- return True
124
- if numeric:
125
- try:
126
- value = round(float(value), 6)
127
- except Exception:
128
- return True
129
- else:
130
- value = str(value).strip().lower()
131
- seen.add(value)
132
- if len(seen) > 1:
133
- return False
134
- return True
135
-
136
- def _janitor_group(self, client: str, group_idx: int, members: list[tuple[int, int]]) -> bool:
137
- """Apply stale-group rules to a group whose owner lease expired.
138
-
139
- Returns True when the group was removed. Energized groups are always
140
- kept: dropping protection from powered channels on inference is the
141
- wrong failure direction.
142
- """
143
- statuses: dict[tuple[int, int], int] = {}
144
- for slot, channel in members:
145
- status = self._read_status(slot, channel)
146
- if status is None:
147
- return False # cannot judge: keep
148
- statuses[(slot, channel)] = status
149
- if any(status & (self.ON_MASK | self.TRIP_MASK) for status in statuses.values()):
150
- if not self._group_settings_synchronized(members):
151
- key = (client, int(group_idx))
152
- if key not in self._warned_unsync:
153
- self._warned_unsync.add(key)
154
- names = ", ".join(f"{s}:{c}" for s, c in sorted(members))
155
- self._log(
156
- f"WARNING: energized group [{names}] of stale client '{client}' has "
157
- "unsynchronized settings; keeping protection"
158
- )
159
- return False
160
- removed = self._db.remove_link_group(client, int(group_idx))
161
- if removed:
162
- names = ", ".join(f"{s}:{c}" for s, c in sorted(members))
163
- self._log(
164
- f"removed stale link group [{names}] of client '{client}': lease expired, all channels off"
165
- )
166
- self._latched_groups.discard(frozenset(members))
167
- self._warned_unsync.discard((client, int(group_idx)))
168
- return bool(removed)
169
-
170
- def check_groups_once(self) -> None:
171
- try:
172
- registered = self._db.link_groups_by_idx()
173
- except Exception as exc:
174
- self._log(f"failed to load link groups: {exc}")
175
- return
176
- for client, groups in registered.items():
177
- live = True
178
- if self._is_client_live is not None:
179
- try:
180
- live = bool(self._is_client_live(client))
181
- except Exception:
182
- live = True
183
- for group_idx, resources in sorted(groups.items()):
184
- members = self._parse_members(resources)
185
- if len(members) < 2:
186
- continue
187
- if not live and self._janitor_group(client, group_idx, members):
188
- continue
189
- self._check_one_group(client, members)
190
-
191
- def _check_one_group(self, client: str, members: list[tuple[int, int]]) -> None:
192
- group_key = frozenset(members)
193
- statuses: dict[tuple[int, int], int] = {}
194
- for slot, channel in members:
195
- status = self._read_status(slot, channel)
196
- if status is not None:
197
- statuses[(slot, channel)] = status
198
- tripped = [key for key, status in statuses.items() if status & self.TRIP_MASK]
199
- if not tripped:
200
- self._latched_groups.discard(group_key)
201
- return
202
- if group_key in self._latched_groups:
203
- return
204
- self._latched_groups.add(group_key)
205
- powered_off: list[str] = []
206
- for slot, channel in sorted(members):
207
- if (slot, channel) in tripped:
208
- continue
209
- status = statuses.get((slot, channel))
210
- if status is None or not status & self.ON_MASK:
211
- continue
212
- if self._power_off(slot, channel):
213
- powered_off.append(f"{slot}:{channel}")
214
- tripped_names = ", ".join(f"{s}:{c}" for s, c in sorted(tripped))
215
- self._log(
216
- f"TRIP on {tripped_names} (group of client '{client}'); "
217
- f"powered off partners: {', '.join(powered_off) or 'none'}"
218
- )
219
-
220
- def _loop(self) -> None:
221
- while not self._stop.wait(self._interval_sec):
222
- self.check_groups_once()
223
-
224
-
225
24
  def _expand_resource_template(template: str, context: dict[str, Any]) -> list[str]:
226
25
  expand_fields = _EXPAND_FIELD_RE.findall(template)
227
26
  if not expand_fields:
@@ -689,7 +488,6 @@ def serve_manager(
689
488
  singleton_file: str | None = None,
690
489
  singleton_file_function: str = "get_singleton",
691
490
  verbose: bool = False,
692
- trip_watchdog_interval: float = 0.0,
693
491
  client_lease_sec: float = 90.0,
694
492
  ) -> None:
695
493
  if init_file and init_function:
@@ -716,6 +514,10 @@ def serve_manager(
716
514
  "db_path": db_path,
717
515
  "hook_args": dict(hook_args or {}),
718
516
  "extra_args": list(extra_args or []),
517
+ # Device-specific extensions (e.g. safety monitors in hook files)
518
+ # can use core.db, core.is_client_live, and the ownership/link
519
+ # registry through this reference.
520
+ "core": core,
719
521
  }
720
522
  periodic_thread: Thread | None = None
721
523
  periodic_stop = Event()
@@ -761,23 +563,8 @@ def serve_manager(
761
563
  periodic_thread.start()
762
564
  core._log(f"periodic hook started interval={interval_sec:.3f}s")
763
565
 
764
- watchdog: TripWatchdog | None = None
765
- if trip_watchdog_interval > 0.0 and core._singleton_object is not None:
766
- watchdog = TripWatchdog(
767
- core._singleton_object,
768
- core.db,
769
- trip_watchdog_interval,
770
- is_client_live=core.is_client_live,
771
- device_lock=core._singleton_lock,
772
- )
773
- watchdog.start()
774
-
775
- try:
776
- with _ManagerTCPServer((host, port), core) as server:
777
- server.serve_forever()
778
- finally:
779
- if watchdog is not None:
780
- watchdog.stop()
566
+ with _ManagerTCPServer((host, port), core) as server:
567
+ server.serve_forever()
781
568
  finally:
782
569
  periodic_stop.set()
783
570
  if periodic_thread is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devman-runtime
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges
5
5
  Author: Kenji Shu
6
6
  License-Expression: MIT
@@ -30,20 +30,21 @@ a normal dependency upgrade instead of regeneration.
30
30
  (`set_link_groups` / `list_link_groups`).
31
31
  - `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
32
32
  dispatch with ownership enforcement, device lifecycle/periodic hooks,
33
- `TripWatchdog` (powers off linked partners when a channel trips, with
34
- lease-based stale-group janitoring).
33
+ client leases. Hook files receive `core` in their context, so
34
+ device-specific safety monitors (e.g. the CAEN trip watchdog in
35
+ caenhv-devman's server_hooks.py) can use the registry and leases.
35
36
  - `devman_runtime.db` — SQLite-backed ownership and link-group registry.
36
37
 
37
38
  ## Server features
38
39
 
39
- - `trip_watchdog_interval` / `--trip-watchdog-interval`: poll registered
40
- link groups and power off partners of a tripped channel, even when the
41
- registering client is offline.
42
40
  - `client_lease_sec` / `--client-lease-sec`: any authenticated request
43
- renews a client lease; groups of lease-expired clients are janitored when
44
- all their channels are off, and kept protected while energized.
41
+ renews a client lease; `core.is_client_live(name)` exposes it to hooks
42
+ (used e.g. for janitoring stale link groups).
45
43
  - Periodic hook (`periodic_file` / `periodic_function` +
46
44
  `periodic_interval_sec`): run a user callback on an interval with access
47
- to the device singleton.
45
+ to the device singleton and the core.
46
+
47
+ Device-specific policies (what a "trip" means, when to power off partners)
48
+ belong in per-bridge hook files, not in this package.
48
49
 
49
50
  This package is used by, e.g., `caenhv-devman-client`.
File without changes
File without changes