devman-runtime 0.1.0__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.
@@ -0,0 +1,23 @@
1
+ """Python runtime library for devman-gen generated bridges.
2
+
3
+ Generated client packages use ManagerClient; generated server packages use
4
+ serve_manager / ManagerCore. The generator itself (devman-gen) is language
5
+ agnostic and is not required at runtime.
6
+ """
7
+
8
+ from .client import ManagerClient, ManagerError
9
+ from .db import OwnershipDB
10
+ from .server import ManagerCore, RuntimeFunctionSpec, TripWatchdog, serve_manager
11
+ from .telegraf import TelegrafSender, build_line
12
+
13
+ __all__ = [
14
+ "ManagerClient",
15
+ "ManagerError",
16
+ "ManagerCore",
17
+ "OwnershipDB",
18
+ "RuntimeFunctionSpec",
19
+ "TelegrafSender",
20
+ "TripWatchdog",
21
+ "build_line",
22
+ "serve_manager",
23
+ ]
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ from typing import Any
5
+
6
+ from .protocol import recv_message, send_message
7
+
8
+
9
+ class ManagerError(RuntimeError):
10
+ pass
11
+
12
+
13
+ class ManagerClient:
14
+ def __init__(
15
+ self,
16
+ host: str,
17
+ port: int,
18
+ client_name: str,
19
+ timeout: float = 5.0,
20
+ ) -> None:
21
+ self.host = host
22
+ self.port = int(port)
23
+ self.client_name = str(client_name).strip()
24
+ if not self.client_name:
25
+ raise ValueError("client_name is required")
26
+ self.timeout = timeout
27
+ self._session: str | None = None
28
+
29
+ def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
30
+ with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
31
+ send_message(sock, payload)
32
+ response = recv_message(sock)
33
+
34
+ if response.get("status") != "ok":
35
+ error = response.get("error", "unknown manager error")
36
+ raise ManagerError(str(error))
37
+ return response
38
+
39
+ def connect(self, force: bool = False) -> None:
40
+ if self._session:
41
+ return
42
+ response = self._request({"op": "connect", "client": self.client_name, "force": bool(force)})
43
+ session = response.get("session")
44
+ if not session:
45
+ raise ManagerError("manager did not return a session token")
46
+ self._session = str(session)
47
+
48
+ def disconnect(self) -> None:
49
+ if not self._session:
50
+ return
51
+ session = self._session
52
+ self._session = None
53
+ self._request(
54
+ {
55
+ "op": "disconnect",
56
+ "client": self.client_name,
57
+ "session": session,
58
+ }
59
+ )
60
+
61
+ def close(self) -> None:
62
+ self.disconnect()
63
+
64
+ def __enter__(self) -> "ManagerClient":
65
+ self.connect()
66
+ return self
67
+
68
+ def __exit__(self, exc_type, exc, tb) -> None:
69
+ self.disconnect()
70
+
71
+ def _with_session(self, payload: dict[str, Any]) -> dict[str, Any]:
72
+ self.connect()
73
+ assert self._session is not None
74
+ data = dict(payload)
75
+ data["client"] = self.client_name
76
+ data["session"] = self._session
77
+ return data
78
+
79
+ def acquire(self, resource: str) -> bool:
80
+ response = self._request(self._with_session({"op": "acquire", "resource": resource}))
81
+ return bool(response.get("acquired", False))
82
+
83
+ def release(self, resource: str) -> bool:
84
+ response = self._request(self._with_session({"op": "release", "resource": resource}))
85
+ return bool(response.get("released", False))
86
+
87
+ def owner_of(self, resource: str) -> str | None:
88
+ response = self._request(self._with_session({"op": "owner_of", "resource": resource}))
89
+ owner = response.get("owner")
90
+ if owner is None:
91
+ return None
92
+ return str(owner)
93
+
94
+ def owners_of(self, resources: list[str]) -> dict[str, str | None]:
95
+ response = self._request(self._with_session({"op": "owners_of", "resources": list(resources)}))
96
+ owners = response.get("owners")
97
+ if not isinstance(owners, dict):
98
+ return {}
99
+ result: dict[str, str | None] = {}
100
+ for key, value in owners.items():
101
+ if value is None:
102
+ result[str(key)] = None
103
+ else:
104
+ result[str(key)] = str(value)
105
+ return result
106
+
107
+ def set_link_groups(self, groups: list[list[str]]) -> int:
108
+ response = self._request(
109
+ self._with_session({"op": "set_link_groups", "groups": [list(g) for g in groups]})
110
+ )
111
+ return int(response.get("groups", 0))
112
+
113
+ def list_link_groups(self) -> dict[str, list[list[str]]]:
114
+ response = self._request(self._with_session({"op": "list_link_groups"}))
115
+ registered = response.get("link_groups")
116
+ return registered if isinstance(registered, dict) else {}
117
+
118
+ def invoke(
119
+ self,
120
+ function: str,
121
+ args: list[Any],
122
+ kwargs: dict[str, Any],
123
+ resources: list[str],
124
+ handle: str | None = None,
125
+ ) -> Any:
126
+ payload = self._with_session(
127
+ {
128
+ "op": "call",
129
+ "function": function,
130
+ "args": args,
131
+ "kwargs": kwargs,
132
+ "resources": resources,
133
+ }
134
+ )
135
+ if handle is not None:
136
+ payload["handle"] = handle
137
+ response = self._request(payload)
138
+ return response.get("result")
devman_runtime/db.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+
7
+ class OwnershipDB:
8
+ def __init__(self, db_path: str | Path) -> None:
9
+ self.db_path = str(db_path)
10
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
11
+ self._conn.execute(
12
+ """
13
+ CREATE TABLE IF NOT EXISTS ownership (
14
+ resource TEXT PRIMARY KEY,
15
+ owner TEXT NOT NULL,
16
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
17
+ )
18
+ """
19
+ )
20
+ self._conn.execute(
21
+ """
22
+ CREATE TABLE IF NOT EXISTS link_groups (
23
+ client TEXT NOT NULL,
24
+ group_idx INTEGER NOT NULL,
25
+ resource TEXT NOT NULL,
26
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
27
+ PRIMARY KEY (client, group_idx, resource)
28
+ )
29
+ """
30
+ )
31
+ self._conn.commit()
32
+
33
+ def owner_of(self, resource: str) -> str | None:
34
+ row = self._conn.execute(
35
+ "SELECT owner FROM ownership WHERE resource = ?", (resource,)
36
+ ).fetchone()
37
+ if row is None:
38
+ return None
39
+ return str(row[0])
40
+
41
+ def acquire(self, resource: str, owner: str) -> bool:
42
+ current = self.owner_of(resource)
43
+ if current is None:
44
+ self._conn.execute(
45
+ "INSERT INTO ownership(resource, owner) VALUES(?, ?)", (resource, owner)
46
+ )
47
+ self._conn.commit()
48
+ return True
49
+ if current == owner:
50
+ return True
51
+ return False
52
+
53
+ def release(self, resource: str, owner: str) -> bool:
54
+ current = self.owner_of(resource)
55
+ if current != owner:
56
+ return False
57
+ self._conn.execute("DELETE FROM ownership WHERE resource = ?", (resource,))
58
+ self._conn.commit()
59
+ return True
60
+
61
+ def release_all_by_owner(self, owner: str) -> int:
62
+ cur = self._conn.execute("DELETE FROM ownership WHERE owner = ?", (owner,))
63
+ self._conn.commit()
64
+ return int(cur.rowcount)
65
+
66
+ def set_link_groups(self, client: str, groups: list[list[str]]) -> int:
67
+ """Replace all link groups registered by a client.
68
+
69
+ Groups persist across client disconnects so a server-side watchdog
70
+ can keep protecting them while the client application is closed.
71
+ """
72
+ self._conn.execute("DELETE FROM link_groups WHERE client = ?", (client,))
73
+ count = 0
74
+ for idx, group in enumerate(groups):
75
+ members = sorted({str(resource) for resource in group})
76
+ if len(members) < 2:
77
+ continue
78
+ for resource in members:
79
+ self._conn.execute(
80
+ "INSERT OR REPLACE INTO link_groups(client, group_idx, resource) VALUES(?, ?, ?)",
81
+ (client, idx, resource),
82
+ )
83
+ count += 1
84
+ self._conn.commit()
85
+ return count
86
+
87
+ def link_groups_by_idx(self) -> dict[str, dict[int, list[str]]]:
88
+ rows = self._conn.execute(
89
+ "SELECT client, group_idx, resource FROM link_groups ORDER BY client, group_idx, resource"
90
+ ).fetchall()
91
+ grouped: dict[str, dict[int, list[str]]] = {}
92
+ for client, group_idx, resource in rows:
93
+ grouped.setdefault(str(client), {}).setdefault(int(group_idx), []).append(str(resource))
94
+ return grouped
95
+
96
+ def all_link_groups(self) -> dict[str, list[list[str]]]:
97
+ return {
98
+ client: [members for _idx, members in sorted(groups.items())]
99
+ for client, groups in self.link_groups_by_idx().items()
100
+ }
101
+
102
+ def remove_link_group(self, client: str, group_idx: int) -> int:
103
+ cur = self._conn.execute(
104
+ "DELETE FROM link_groups WHERE client = ? AND group_idx = ?",
105
+ (client, int(group_idx)),
106
+ )
107
+ self._conn.commit()
108
+ return int(cur.rowcount)
109
+
110
+ def close(self) -> None:
111
+ self._conn.close()
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, is_dataclass
4
+ from enum import Enum
5
+ import json
6
+ import socket
7
+ import struct
8
+ from typing import Any
9
+
10
+
11
+ class ProtocolError(RuntimeError):
12
+ pass
13
+
14
+
15
+ def _json_default(value: Any) -> Any:
16
+ if is_dataclass(value):
17
+ return asdict(value)
18
+ if isinstance(value, Enum):
19
+ return value.name
20
+ if isinstance(value, (bytes, bytearray)):
21
+ return value.decode("utf-8", errors="replace")
22
+ if hasattr(value, "__dict__"):
23
+ return vars(value)
24
+ return str(value)
25
+
26
+
27
+ def send_message(sock: socket.socket, payload: dict[str, Any]) -> None:
28
+ body = json.dumps(payload, default=_json_default, separators=(",", ":")).encode("utf-8")
29
+ sock.sendall(struct.pack("!I", len(body)))
30
+ sock.sendall(body)
31
+
32
+
33
+ def _recv_exact(sock: socket.socket, size: int) -> bytes:
34
+ chunks: list[bytes] = []
35
+ remaining = size
36
+ while remaining > 0:
37
+ chunk = sock.recv(remaining)
38
+ if not chunk:
39
+ raise ProtocolError("unexpected EOF while reading message")
40
+ chunks.append(chunk)
41
+ remaining -= len(chunk)
42
+ return b"".join(chunks)
43
+
44
+
45
+ def recv_message(sock: socket.socket) -> dict[str, Any]:
46
+ header = _recv_exact(sock, 4)
47
+ (size,) = struct.unpack("!I", header)
48
+ raw = _recv_exact(sock, size)
49
+ try:
50
+ data = json.loads(raw.decode("utf-8"))
51
+ except json.JSONDecodeError as exc:
52
+ raise ProtocolError(f"invalid message JSON: {exc}") from exc
53
+ if not isinstance(data, dict):
54
+ raise ProtocolError("message must be a JSON object")
55
+ return data
@@ -0,0 +1,794 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import importlib.util
5
+ import inspect
6
+ import itertools
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ import re
10
+ import socketserver
11
+ import sys
12
+ import time
13
+ import traceback
14
+ import uuid
15
+ from dataclasses import dataclass
16
+ from threading import Event, Lock, Thread
17
+ from typing import Any
18
+
19
+ from .db import OwnershipDB
20
+ from .protocol import recv_message, send_message
21
+
22
+ _EXPAND_FIELD_RE = re.compile(r"\{([A-Za-z_]\w*)\[\]\}")
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
+ def _expand_resource_template(template: str, context: dict[str, Any]) -> list[str]:
226
+ expand_fields = _EXPAND_FIELD_RE.findall(template)
227
+ if not expand_fields:
228
+ return [template.format(**context)]
229
+
230
+ ordered_fields = list(dict.fromkeys(expand_fields))
231
+ normalized = template
232
+ values_by_field: list[list[Any]] = []
233
+ for field in ordered_fields:
234
+ normalized = normalized.replace(f"{{{field}[]}}", f"{{{field}}}")
235
+ raw = context.get(field)
236
+ if raw is None:
237
+ return []
238
+ if isinstance(raw, (str, bytes, bytearray)):
239
+ values = [raw]
240
+ else:
241
+ try:
242
+ values = list(raw)
243
+ except TypeError:
244
+ values = [raw]
245
+ if not values:
246
+ return []
247
+ values_by_field.append(values)
248
+
249
+ resources: list[str] = []
250
+ for combo in itertools.product(*values_by_field):
251
+ local_context = dict(context)
252
+ for field, value in zip(ordered_fields, combo):
253
+ local_context[field] = value
254
+ resources.append(normalized.format(**local_context))
255
+ return resources
256
+
257
+
258
+ def _resolve_backend_callable(backend: Any, dotted: str):
259
+ target: Any = backend
260
+ for token in dotted.split("."):
261
+ target = getattr(target, token)
262
+ if not callable(target):
263
+ raise AttributeError(f"{dotted} is not callable")
264
+ return target
265
+
266
+
267
+ def _resolve_file_callable(file_path: str, function_name: str):
268
+ path = Path(file_path)
269
+ if not path.exists():
270
+ raise FileNotFoundError(f"hook file not found: {file_path}")
271
+ module_name = f"_devman_hook_{uuid.uuid4().hex}"
272
+ spec = importlib.util.spec_from_file_location(module_name, str(path))
273
+ if spec is None or spec.loader is None:
274
+ raise RuntimeError(f"cannot import hook file: {file_path}")
275
+ module = importlib.util.module_from_spec(spec)
276
+ spec.loader.exec_module(module)
277
+ fn = getattr(module, function_name, None)
278
+ if not callable(fn):
279
+ raise AttributeError(f"{function_name} is not callable in {file_path}")
280
+ return fn
281
+
282
+
283
+ def _invoke_hook(fn, context: dict[str, Any]) -> Any:
284
+ sig = inspect.signature(fn)
285
+ params = list(sig.parameters.values())
286
+ if not params:
287
+ return fn()
288
+
289
+ accepts_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params)
290
+ if accepts_var_kw:
291
+ return fn(**context)
292
+
293
+ kwargs: dict[str, Any] = {}
294
+ for p in params:
295
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
296
+ if p.name in context:
297
+ kwargs[p.name] = context[p.name]
298
+ if kwargs:
299
+ return fn(**kwargs)
300
+
301
+ first = params[0]
302
+ if first.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
303
+ return fn(context)
304
+
305
+ return fn()
306
+
307
+
308
+ @dataclass(slots=True)
309
+ class RuntimeFunctionSpec:
310
+ name: str
311
+ param_order: list[str]
312
+ param_kinds: dict[str, str]
313
+ resource_template: str | None
314
+ dispatch: str = "default"
315
+ dispatch_target: str | None = None
316
+
317
+
318
+ class ManagerCore:
319
+ def __init__(
320
+ self,
321
+ backend_module: str,
322
+ db_path: str,
323
+ functions: dict[str, RuntimeFunctionSpec],
324
+ singleton_object: Any | None = None,
325
+ verbose: bool = False,
326
+ client_lease_sec: float = 90.0,
327
+ ):
328
+ self.backend = importlib.import_module(backend_module)
329
+ self.db = OwnershipDB(db_path)
330
+ self.functions = functions
331
+ self._singleton_object = singleton_object
332
+ self.verbose = bool(verbose)
333
+ self._handles: dict[str, Any] = {}
334
+ self._handle_owners: dict[str, str] = {}
335
+ self._handles_lock = Lock()
336
+ self._sessions_by_name: dict[str, str] = {}
337
+ self._sessions_by_id: dict[str, str] = {}
338
+ self._sessions_lock = Lock()
339
+ self._singleton_lock = Lock()
340
+ self.client_lease_sec = float(client_lease_sec)
341
+ self._last_seen: dict[str, float] = {}
342
+ self._last_seen_lock = Lock()
343
+ self._started_at = time.monotonic()
344
+
345
+ def _call_singleton(self, method_name: str, args: list[Any], kwargs: dict[str, Any]) -> Any:
346
+ target = self._singleton_object
347
+ if target is None:
348
+ raise RuntimeError("singleton dispatch requested but no singleton object is configured")
349
+ with self._singleton_lock:
350
+ method = _resolve_backend_callable(target, method_name)
351
+ return method(*args, **kwargs)
352
+
353
+ def _note_client_seen(self, client: str) -> None:
354
+ with self._last_seen_lock:
355
+ self._last_seen[str(client)] = time.monotonic()
356
+
357
+ def is_client_live(self, client: str) -> bool:
358
+ """A client is live while its lease keeps renewing.
359
+
360
+ Any authenticated request renews the lease. Unknown clients fall back
361
+ to the server start time, giving reconnecting clients a grace period
362
+ of one lease window after a server restart.
363
+ """
364
+ if self.client_lease_sec <= 0:
365
+ return True
366
+ with self._last_seen_lock:
367
+ seen = self._last_seen.get(str(client))
368
+ if seen is None:
369
+ seen = self._started_at
370
+ return (time.monotonic() - seen) <= self.client_lease_sec
371
+
372
+ def _resolve_resources(
373
+ self, fn_spec: RuntimeFunctionSpec, args: list[Any], kwargs: dict[str, Any]
374
+ ) -> list[str]:
375
+ if fn_spec.resource_template is None:
376
+ return []
377
+
378
+ context = dict(kwargs)
379
+ positional_index = 0
380
+ for name in fn_spec.param_order:
381
+ kind = fn_spec.param_kinds.get(name, "POSITIONAL_OR_KEYWORD")
382
+ if kind in ("POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD") and positional_index < len(args):
383
+ context.setdefault(name, args[positional_index])
384
+ positional_index += 1
385
+
386
+ try:
387
+ return _expand_resource_template(fn_spec.resource_template, context)
388
+ except Exception as exc:
389
+ raise RuntimeError(f"failed to resolve resource template for {fn_spec.name}: {exc}") from exc
390
+
391
+ def _resolve_dotted_callable(self, function: str) -> Any:
392
+ target: Any = self.backend
393
+ for token in function.split("."):
394
+ target = getattr(target, token)
395
+ if not callable(target):
396
+ raise AttributeError(f"{function} is not callable")
397
+ return target
398
+
399
+ def _get_handle(self, handle: str, client: str) -> Any:
400
+ with self._handles_lock:
401
+ obj = self._handles.get(handle)
402
+ owner = self._handle_owners.get(handle)
403
+ if obj is None:
404
+ raise RuntimeError(f"unknown handle: {handle}")
405
+ if owner is not None and owner != client:
406
+ raise RuntimeError(f"handle '{handle}' is owned by '{owner}'")
407
+ return obj
408
+
409
+ def _register_handle(self, obj: Any, owner: str) -> str:
410
+ handle = uuid.uuid4().hex
411
+ with self._handles_lock:
412
+ self._handles[handle] = obj
413
+ self._handle_owners[handle] = owner
414
+ return handle
415
+
416
+ def _release_handle(self, handle: str) -> None:
417
+ with self._handles_lock:
418
+ self._handles.pop(handle, None)
419
+ self._handle_owners.pop(handle, None)
420
+
421
+ def _release_client_handles(self, client: str) -> None:
422
+ to_close: list[Any] = []
423
+ with self._handles_lock:
424
+ for handle, owner in list(self._handle_owners.items()):
425
+ if owner != client:
426
+ continue
427
+ self._handle_owners.pop(handle, None)
428
+ obj = self._handles.pop(handle, None)
429
+ if obj is not None:
430
+ to_close.append(obj)
431
+ for obj in to_close:
432
+ close_fn = getattr(obj, "close", None)
433
+ if callable(close_fn):
434
+ try:
435
+ close_fn()
436
+ except Exception:
437
+ pass
438
+
439
+ def _connect_client(self, client: str, force: bool = False) -> str:
440
+ with self._sessions_lock:
441
+ if client in self._sessions_by_name:
442
+ if not force:
443
+ raise RuntimeError(f"client '{client}' is already connected")
444
+ old_session = self._sessions_by_name[client]
445
+ self._sessions_by_name.pop(client, None)
446
+ self._sessions_by_id.pop(old_session, None)
447
+ session = uuid.uuid4().hex
448
+ self._sessions_by_name[client] = session
449
+ self._sessions_by_id[session] = client
450
+ return session
451
+
452
+ def _disconnect_client(self, client: str, session: str) -> None:
453
+ with self._sessions_lock:
454
+ active_session = self._sessions_by_name.get(client)
455
+ if active_session != session:
456
+ raise RuntimeError("invalid session")
457
+ self._sessions_by_name.pop(client, None)
458
+ self._sessions_by_id.pop(session, None)
459
+
460
+ def _ensure_connected(self, client: str, session: str | None) -> None:
461
+ if not session:
462
+ raise RuntimeError("missing session")
463
+ with self._sessions_lock:
464
+ active_session = self._sessions_by_name.get(client)
465
+ if active_session != session:
466
+ raise RuntimeError(f"client '{client}' is not connected")
467
+
468
+ def _log(self, message: str) -> None:
469
+ if self.verbose:
470
+ ts = datetime.now().isoformat(timespec="seconds")
471
+ print(f"[devman {ts}] {message}", file=sys.stderr, flush=True)
472
+
473
+ def _log_request(self, client: str, request: dict[str, Any]) -> None:
474
+ if not self.verbose:
475
+ return
476
+ op = request.get("op")
477
+ if op == "call":
478
+ function = request.get("function")
479
+ args = request.get("args", [])
480
+ kwargs = request.get("kwargs", {})
481
+ resources = request.get("resources")
482
+ handle = request.get("handle")
483
+ self._log(
484
+ f"client={client} op=call function={function} handle={handle} "
485
+ f"args={args!r} kwargs={kwargs!r} resources={resources!r}"
486
+ )
487
+ elif op in ("acquire", "release", "owner_of"):
488
+ self._log(f"client={client} op={op} resource={request.get('resource')!r}")
489
+ elif op == "owners_of":
490
+ resources = request.get("resources")
491
+ count = len(resources) if isinstance(resources, list) else "?"
492
+ self._log(f"client={client} op=owners_of count={count}")
493
+ elif op in ("connect", "disconnect"):
494
+ self._log(f"client={client} op={op}")
495
+ else:
496
+ self._log(f"client={client} op={op} request={request!r}")
497
+
498
+ def _dispatch(
499
+ self,
500
+ fn_spec: RuntimeFunctionSpec,
501
+ function: str,
502
+ args: list[Any],
503
+ kwargs: dict[str, Any],
504
+ handle: str | None,
505
+ client: str,
506
+ ) -> Any:
507
+ if fn_spec.dispatch == "singleton":
508
+ method_name = fn_spec.dispatch_target or function
509
+ return self._call_singleton(method_name, args, kwargs)
510
+
511
+ if function == "Device_open":
512
+ device_cls = getattr(self.backend, "Device")
513
+ obj = device_cls.open(*args, **kwargs)
514
+ return {"__devman_handle__": self._register_handle(obj, owner=client)}
515
+
516
+ if function.startswith("Device_"):
517
+ method_name = function[len("Device_") :]
518
+ if handle is not None:
519
+ target = self._get_handle(handle, client=client)
520
+ else:
521
+ target = getattr(self.backend, "Device")
522
+ method = getattr(target, method_name)
523
+ result = method(*args, **kwargs)
524
+ if method_name == "close" and handle is not None:
525
+ self._release_handle(handle)
526
+ return result
527
+
528
+ backend_fn = self._resolve_dotted_callable(function)
529
+ return backend_fn(*args, **kwargs)
530
+
531
+ def handle(self, request: dict[str, Any]) -> dict[str, Any]:
532
+ op = request.get("op")
533
+ client = request.get("client")
534
+ if not client:
535
+ return {"status": "error", "error": "missing client name"}
536
+ client_name = str(client)
537
+ self._log_request(client_name, request)
538
+ session = request.get("session")
539
+
540
+ if op == "connect":
541
+ try:
542
+ connected_session = self._connect_client(client_name, force=bool(request.get("force", False)))
543
+ except Exception as exc:
544
+ return {"status": "error", "error": str(exc)}
545
+ self._note_client_seen(client_name)
546
+ return {"status": "ok", "session": connected_session}
547
+
548
+ if op == "disconnect":
549
+ try:
550
+ self._disconnect_client(client_name, str(session) if session else "")
551
+ except Exception as exc:
552
+ return {"status": "error", "error": str(exc)}
553
+ return {"status": "ok", "disconnected": True}
554
+
555
+ try:
556
+ self._ensure_connected(client_name, str(session) if session else None)
557
+ except Exception as exc:
558
+ return {"status": "error", "error": str(exc)}
559
+ self._note_client_seen(client_name)
560
+
561
+ if op == "acquire":
562
+ resource = request.get("resource")
563
+ if not resource:
564
+ return {"status": "error", "error": "missing resource"}
565
+ return {"status": "ok", "acquired": self.db.acquire(str(resource), client_name)}
566
+
567
+ if op == "release":
568
+ resource = request.get("resource")
569
+ if not resource:
570
+ return {"status": "error", "error": "missing resource"}
571
+ return {"status": "ok", "released": self.db.release(str(resource), client_name)}
572
+
573
+ if op == "owner_of":
574
+ resource = request.get("resource")
575
+ if not resource:
576
+ return {"status": "error", "error": "missing resource"}
577
+ return {"status": "ok", "owner": self.db.owner_of(str(resource))}
578
+
579
+ if op == "owners_of":
580
+ resources = request.get("resources")
581
+ if not isinstance(resources, list):
582
+ return {"status": "error", "error": "resources must be a list"}
583
+ owners = {str(resource): self.db.owner_of(str(resource)) for resource in resources}
584
+ return {"status": "ok", "owners": owners}
585
+
586
+ if op == "set_link_groups":
587
+ groups = request.get("groups")
588
+ if not isinstance(groups, list) or not all(isinstance(g, list) for g in groups):
589
+ return {"status": "error", "error": "groups must be a list of resource lists"}
590
+ try:
591
+ count = self.db.set_link_groups(client_name, [[str(r) for r in g] for g in groups])
592
+ except Exception as exc:
593
+ return {"status": "error", "error": f"failed to store link groups: {exc}"}
594
+ return {"status": "ok", "groups": count}
595
+
596
+ if op == "list_link_groups":
597
+ try:
598
+ registered = self.db.all_link_groups()
599
+ except Exception as exc:
600
+ return {"status": "error", "error": f"failed to load link groups: {exc}"}
601
+ return {"status": "ok", "link_groups": registered}
602
+
603
+ if op != "call":
604
+ return {"status": "error", "error": f"unsupported operation: {op}"}
605
+
606
+ function = request.get("function")
607
+ if not function:
608
+ return {"status": "error", "error": "missing function"}
609
+ fn_spec = self.functions.get(str(function))
610
+ if fn_spec is None:
611
+ return {"status": "error", "error": f"unknown function: {function}"}
612
+
613
+ args = request.get("args", [])
614
+ kwargs = request.get("kwargs", {})
615
+ handle = request.get("handle")
616
+ resources = request.get("resources")
617
+ if resources is None:
618
+ resources = self._resolve_resources(fn_spec, list(args), dict(kwargs))
619
+
620
+ for resource in resources:
621
+ owner = self.db.owner_of(str(resource))
622
+ if owner != client_name:
623
+ return {
624
+ "status": "error",
625
+ "error": f"resource '{resource}' is owned by '{owner}'",
626
+ }
627
+
628
+ try:
629
+ result = self._dispatch(
630
+ fn_spec,
631
+ str(function),
632
+ list(args),
633
+ dict(kwargs),
634
+ handle=str(handle) if handle else None,
635
+ client=client_name,
636
+ )
637
+ except Exception:
638
+ return {
639
+ "status": "error",
640
+ "error": f"backend call failed: {traceback.format_exc(limit=2)}",
641
+ }
642
+ return {"status": "ok", "result": result}
643
+
644
+ def shutdown(self) -> None:
645
+ with self._sessions_lock:
646
+ clients = list(self._sessions_by_name.keys())
647
+ self._sessions_by_name.clear()
648
+ self._sessions_by_id.clear()
649
+ for client in clients:
650
+ self._release_client_handles(client)
651
+ self.db.close()
652
+
653
+
654
+ class _TCPHandler(socketserver.BaseRequestHandler):
655
+ def handle(self) -> None:
656
+ assert isinstance(self.server, _ManagerTCPServer)
657
+ request = recv_message(self.request)
658
+ response = self.server.core.handle(request)
659
+ send_message(self.request, response)
660
+
661
+
662
+ class _ManagerTCPServer(socketserver.ThreadingTCPServer):
663
+ allow_reuse_address = True
664
+
665
+ def __init__(self, server_address: tuple[str, int], core: ManagerCore):
666
+ super().__init__(server_address, _TCPHandler)
667
+ self.core = core
668
+
669
+
670
+ def serve_manager(
671
+ backend_module: str,
672
+ host: str,
673
+ port: int,
674
+ db_path: str,
675
+ functions: dict[str, RuntimeFunctionSpec],
676
+ init_function: str | None = None,
677
+ deinit_function: str | None = None,
678
+ init_file: str | None = None,
679
+ deinit_file: str | None = None,
680
+ init_file_function: str = "init",
681
+ deinit_file_function: str = "deinit",
682
+ hook_args: dict[str, str] | None = None,
683
+ extra_args: list[str] | None = None,
684
+ periodic_function: str | None = None,
685
+ periodic_file: str | None = None,
686
+ periodic_file_function: str = "periodic",
687
+ periodic_interval_sec: float = 0.0,
688
+ singleton_function: str | None = None,
689
+ singleton_file: str | None = None,
690
+ singleton_file_function: str = "get_singleton",
691
+ verbose: bool = False,
692
+ trip_watchdog_interval: float = 0.0,
693
+ client_lease_sec: float = 90.0,
694
+ ) -> None:
695
+ if init_file and init_function:
696
+ raise ValueError("init_file and init_function are mutually exclusive")
697
+ if deinit_file and deinit_function:
698
+ raise ValueError("deinit_file and deinit_function are mutually exclusive")
699
+ if periodic_file and periodic_function:
700
+ raise ValueError("periodic_file and periodic_function are mutually exclusive")
701
+ if singleton_file and singleton_function:
702
+ raise ValueError("singleton_file and singleton_function are mutually exclusive")
703
+
704
+ core = ManagerCore(
705
+ backend_module=backend_module,
706
+ db_path=db_path,
707
+ functions=functions,
708
+ verbose=verbose,
709
+ client_lease_sec=client_lease_sec,
710
+ )
711
+ hook_context: dict[str, Any] = {
712
+ "backend": core.backend,
713
+ "backend_module": backend_module,
714
+ "host": host,
715
+ "port": port,
716
+ "db_path": db_path,
717
+ "hook_args": dict(hook_args or {}),
718
+ "extra_args": list(extra_args or []),
719
+ }
720
+ periodic_thread: Thread | None = None
721
+ periodic_stop = Event()
722
+ try:
723
+ init_result: Any = None
724
+ init_cb = None
725
+ if init_file:
726
+ init_cb = _resolve_file_callable(init_file, init_file_function)
727
+ elif init_function:
728
+ init_cb = _resolve_backend_callable(core.backend, init_function)
729
+ if init_cb:
730
+ init_result = _invoke_hook(init_cb, hook_context)
731
+ hook_context["init_result"] = init_result
732
+
733
+ singleton_cb = None
734
+ if singleton_file:
735
+ singleton_cb = _resolve_file_callable(singleton_file, singleton_file_function)
736
+ elif singleton_function:
737
+ singleton_cb = _resolve_backend_callable(core.backend, singleton_function)
738
+
739
+ if singleton_cb:
740
+ core._singleton_object = _invoke_hook(singleton_cb, hook_context)
741
+ elif init_result is not None:
742
+ core._singleton_object = init_result
743
+ hook_context["singleton"] = core._singleton_object
744
+
745
+ periodic_cb = None
746
+ if periodic_file:
747
+ periodic_cb = _resolve_file_callable(periodic_file, periodic_file_function)
748
+ elif periodic_function:
749
+ periodic_cb = _resolve_backend_callable(core.backend, periodic_function)
750
+
751
+ interval_sec = float(periodic_interval_sec or 0.0)
752
+ if periodic_cb and interval_sec > 0.0:
753
+ def _periodic_loop() -> None:
754
+ while not periodic_stop.wait(interval_sec):
755
+ try:
756
+ _invoke_hook(periodic_cb, hook_context)
757
+ except Exception as exc:
758
+ core._log(f"periodic hook failed: {exc}")
759
+
760
+ periodic_thread = Thread(target=_periodic_loop, name="devman-periodic-hook", daemon=True)
761
+ periodic_thread.start()
762
+ core._log(f"periodic hook started interval={interval_sec:.3f}s")
763
+
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()
781
+ finally:
782
+ periodic_stop.set()
783
+ if periodic_thread is not None:
784
+ periodic_thread.join(timeout=2.0)
785
+ try:
786
+ deinit_cb = None
787
+ if deinit_file:
788
+ deinit_cb = _resolve_file_callable(deinit_file, deinit_file_function)
789
+ elif deinit_function:
790
+ deinit_cb = _resolve_backend_callable(core.backend, deinit_function)
791
+ if deinit_cb:
792
+ _invoke_hook(deinit_cb, hook_context)
793
+ finally:
794
+ core.shutdown()
@@ -0,0 +1,125 @@
1
+ """Minimal InfluxDB line-protocol sender for Telegraf.
2
+
3
+ Supports Telegraf's socket_listener (udp://host:port) and
4
+ influxdb_listener / http_listener_v2 (http://host:port[/path]) inputs.
5
+ Stdlib only; senders are cheap to construct and hold no open connection
6
+ except the UDP socket.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+ import urllib.request
13
+ from typing import Any
14
+ from urllib.parse import urlparse
15
+
16
+
17
+ def _escape_measurement(value: str) -> str:
18
+ return value.replace("\\", "\\\\").replace(",", "\\,").replace(" ", "\\ ")
19
+
20
+
21
+ def _escape_tag(value: str) -> str:
22
+ return (
23
+ value.replace("\\", "\\\\")
24
+ .replace(",", "\\,")
25
+ .replace("=", "\\=")
26
+ .replace(" ", "\\ ")
27
+ )
28
+
29
+
30
+ def _format_field_value(value: Any) -> str:
31
+ if isinstance(value, bool):
32
+ return "true" if value else "false"
33
+ if isinstance(value, int):
34
+ return f"{value}i"
35
+ if isinstance(value, float):
36
+ return repr(float(value))
37
+ text = str(value).replace("\\", "\\\\").replace('"', '\\"')
38
+ return f'"{text}"'
39
+
40
+
41
+ def build_line(
42
+ measurement: str,
43
+ tags: dict[str, Any],
44
+ fields: dict[str, Any],
45
+ timestamp_ns: int | None = None,
46
+ ) -> str:
47
+ """Build one line-protocol record; fields must be non-empty."""
48
+ if not fields:
49
+ raise ValueError("line protocol requires at least one field")
50
+ parts = [_escape_measurement(str(measurement))]
51
+ for key in sorted(tags):
52
+ value = tags[key]
53
+ if value is None or str(value) == "":
54
+ continue
55
+ parts.append(f"{_escape_tag(str(key))}={_escape_tag(str(value))}")
56
+ head = ",".join(parts)
57
+ body = ",".join(
58
+ f"{_escape_tag(str(key))}={_format_field_value(value)}"
59
+ for key, value in sorted(fields.items())
60
+ if value is not None
61
+ )
62
+ line = f"{head} {body}"
63
+ if timestamp_ns is not None:
64
+ line += f" {int(timestamp_ns)}"
65
+ return line
66
+
67
+
68
+ class TelegrafSender:
69
+ """Send line-protocol batches to Telegraf over UDP or HTTP."""
70
+
71
+ def __init__(self, url: str, timeout: float = 2.0) -> None:
72
+ parsed = urlparse(str(url))
73
+ scheme = (parsed.scheme or "").lower()
74
+ if scheme not in ("udp", "http", "https"):
75
+ raise ValueError(f"unsupported telegraf url scheme: {url!r} (use udp:// or http(s)://)")
76
+ if not parsed.hostname or not parsed.port:
77
+ raise ValueError(f"telegraf url must include host and port: {url!r}")
78
+ self.url = str(url)
79
+ self.scheme = scheme
80
+ self.host = parsed.hostname
81
+ self.port = int(parsed.port)
82
+ self.timeout = float(timeout)
83
+ self._http_url: str | None = None
84
+ self._socket: socket.socket | None = None
85
+ if scheme == "udp":
86
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
87
+ else:
88
+ path = parsed.path if parsed.path and parsed.path != "/" else "/write"
89
+ self._http_url = f"{scheme}://{parsed.hostname}:{parsed.port}{path}"
90
+
91
+ def send_lines(self, lines: list[str]) -> None:
92
+ payload = "\n".join(line for line in lines if line)
93
+ if not payload:
94
+ return
95
+ data = (payload + "\n").encode("utf-8")
96
+ if self._socket is not None:
97
+ # UDP datagrams should stay well under typical MTU-ish limits;
98
+ # send line-by-line batches of moderate size.
99
+ batch: list[str] = []
100
+ size = 0
101
+ for line in payload.split("\n"):
102
+ encoded = len(line.encode("utf-8")) + 1
103
+ if batch and size + encoded > 60000:
104
+ self._socket.sendto(("\n".join(batch) + "\n").encode("utf-8"), (self.host, self.port))
105
+ batch, size = [], 0
106
+ batch.append(line)
107
+ size += encoded
108
+ if batch:
109
+ self._socket.sendto(("\n".join(batch) + "\n").encode("utf-8"), (self.host, self.port))
110
+ return
111
+ request = urllib.request.Request(
112
+ self._http_url or self.url,
113
+ data=data,
114
+ headers={"Content-Type": "text/plain; charset=utf-8"},
115
+ method="POST",
116
+ )
117
+ with urllib.request.urlopen(request, timeout=self.timeout):
118
+ pass
119
+
120
+ def close(self) -> None:
121
+ if self._socket is not None:
122
+ try:
123
+ self._socket.close()
124
+ finally:
125
+ self._socket = None
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: devman-runtime
3
+ Version: 0.1.0
4
+ Summary: Python runtime (protocol, client, server core) for devman-gen generated device-manager bridges
5
+ Author: Kenji Shu
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/kenji0923/devman-runtime
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # devman-runtime
16
+
17
+ Python runtime library for bridges generated by [devman-gen](https://github.com/kenji0923/devman-gen).
18
+
19
+ devman-gen is a language-agnostic generator that wraps a device-control
20
+ library (e.g. `caen_libs` for CAEN HV crates) in a client/server pair with
21
+ resource-ownership management. This package is the Python-side runtime that
22
+ those generated packages import, so runtime fixes reach every bridge through
23
+ a normal dependency upgrade instead of regeneration.
24
+
25
+ ## Contents
26
+
27
+ - `devman_runtime.protocol` — length-prefixed JSON over TCP.
28
+ - `devman_runtime.client` — `ManagerClient`: sessions, `acquire`/`release`
29
+ resource ownership, `invoke`, link-group registration
30
+ (`set_link_groups` / `list_link_groups`).
31
+ - `devman_runtime.server` — `ManagerCore` and `serve_manager`: request
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).
35
+ - `devman_runtime.db` — SQLite-backed ownership and link-group registry.
36
+
37
+ ## Server features
38
+
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
+ - `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.
45
+ - Periodic hook (`periodic_file` / `periodic_function` +
46
+ `periodic_interval_sec`): run a user callback on an interval with access
47
+ to the device singleton.
48
+
49
+ This package is used by, e.g., `caenhv-devman-client`.
@@ -0,0 +1,11 @@
1
+ devman_runtime/__init__.py,sha256=mpSymz8cWCh2f0xA85zC_sqmDbBzXBjZgLVAHt9eqck,666
2
+ devman_runtime/client.py,sha256=Sv1a3L0XaloP-oD0Ilfjccqb5mMeLZSQhavRYHVK_IE,4477
3
+ devman_runtime/db.py,sha256=LvpMFwi9J2c0KRSj-lbv-pkr-vMJwLO9A7eyFo2NHK4,3997
4
+ devman_runtime/protocol.py,sha256=wUdi8nLMXkUUFoNxGdtPdVb2wiXabghlESRggP6ytU8,1590
5
+ devman_runtime/server.py,sha256=cTGSyAy0zenDHZvtVqIIhrNBITZV7nKgNeP6x-9SkWI,30918
6
+ devman_runtime/telegraf.py,sha256=aqQNeCpFmV6xmJoEYA_JcGuRvGtsjNnnqLSCfevOz6Y,4392
7
+ devman_runtime-0.1.0.dist-info/licenses/LICENSE,sha256=P8fFayUDYkFlhTX_KIfX0U24GEQd15fdknSBFrc08NM,1066
8
+ devman_runtime-0.1.0.dist-info/METADATA,sha256=Mt-TS-yNAQ0PWW2tC0OagAllg1j8oiQczHsx6fWWB9k,2165
9
+ devman_runtime-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ devman_runtime-0.1.0.dist-info/top_level.txt,sha256=WNif9wbrjIBQ00D28VhFCibjRlbiS9TufDGzyRHmcF4,15
11
+ devman_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenji Shu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ devman_runtime