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.
- devman_runtime/__init__.py +23 -0
- devman_runtime/client.py +138 -0
- devman_runtime/db.py +111 -0
- devman_runtime/protocol.py +55 -0
- devman_runtime/server.py +794 -0
- devman_runtime/telegraf.py +125 -0
- devman_runtime-0.1.0.dist-info/METADATA +49 -0
- devman_runtime-0.1.0.dist-info/RECORD +11 -0
- devman_runtime-0.1.0.dist-info/WHEEL +5 -0
- devman_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- devman_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
devman_runtime/client.py
ADDED
|
@@ -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
|
devman_runtime/server.py
ADDED
|
@@ -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,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
|