wizata-dsapi 2.2.0.dev20__py3-none-any.whl → 2.2.0.dev21__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.
wizata_dsapi/__init__.py CHANGED
@@ -65,10 +65,13 @@ from .edge_device import EdgeDevice, EdgeDeviceStatus, EdgeDeviceType, EdgeDevic
65
65
  from .edge_module import EdgeModule, EdgeModuleType, WIZATA_MODULES
66
66
  from .edge_config import EdgeConsumer, EdgeWriter, EdgeTrigger, EdgeFile, EdgeConfig, EdgeCommand
67
67
  from .edge_package import EdgePackage, EdgePackageOperation, EdgeRelease
68
- from .connection import Connection, ConnectionMapping, ConnectionDirection, TransformKind, \
69
- EdgeDeviceKey, EdgeDeviceKeyStatus
70
- from .connection_types import ConnectionType, CONNECTION_TYPES, TRANSFORM_SCHEMAS, get_connection_type, \
71
- list_connection_types
68
+ from .connection import Connection, ConnectionChannel, ConnectionSignal, ConnectionDirection, \
69
+ TransformKind, EdgeDeviceKey, EdgeDeviceKeyStatus
70
+ from .connection_types import ConnectionType, ChannelType, CONNECTION_TYPES, SYSTEM_TYPES, \
71
+ TRANSFORM_SCHEMAS, get_connection_type, list_connection_types
72
+ from .connection_policy import DatapointPolicy, PolicyError, canonical_node_id
73
+ from .connection_discovery import ConnectionSeenName, ConnectionSeenReport, ConnectionBrowse, \
74
+ SEEN_OUTCOMES, BROWSE_REQUESTED, BROWSE_STATUSES, BROWSE_EXPIRES_SECONDS
72
75
  from .product import ProductKind, Product, ProductComponent, BUILTIN_PRODUCT_KINDS, is_valid_product_key
73
76
  from .inventory import (InventoryBalance, InventoryStorage, InventoryStorageDatapoint, inventory_mode,
74
77
  InventoryStock, InventoryStockProduct, InventoryStockLine, InventoryStockCount, INVENTORY_OPTION, INVENTORY_MODES,
@@ -2,16 +2,18 @@
2
2
  Edge connection DTOs (DEV-6013).
3
3
 
4
4
  An edge connector used to be one untyped dict in the device's ``consumers.json``,
5
- credentials inline. It is now split in two entities, the same for every type:
5
+ credentials inline. It is now (12.2 redesign) three entities, the same for every type:
6
6
 
7
7
  Connection — how to reach a source or a target (endpoint, credentials, certificates),
8
- bound to one edge device. ``config`` is clear; ``secrets`` holds one sealed envelope
9
- per secret field, readable by the owning edge only.
8
+ bound to one edge device and entered once. ``config`` is clear; ``secrets`` holds one
9
+ sealed envelope per secret field, readable by the owning edge only. ``cloud`` (hub) and
10
+ ``edge`` (tsdb) are the device's built-in connections.
10
11
 
11
- ConnectionMapping — one thing to read or write through a connection (an OPC node, an
12
- MQTT topic, a Modbus register, an S7 block entry, a queue). ``address`` and
13
- ``options`` are shaped by the connection type (see ``connection_types``); the other
14
- fields are common to every type.
12
+ ConnectionChannel — a reader or a writer of a connection: what it reads or writes
13
+ (``spec``, shaped by the type) and its datapoint policy (all / list / mapping).
14
+
15
+ ConnectionSignal — one row of a policy: a reader's name and the datapoint it goes on as,
16
+ or a datapoint and where a writer sends it.
15
17
 
16
18
  EdgeDeviceKey — the public half of the key pair an edge generated for itself. Secrets
17
19
  are sealed for the edge's active key; rotating adds a row and retires the previous.
@@ -74,6 +76,17 @@ class Connection:
74
76
  self.secrets: dict = {}
75
77
  self.created_at: Optional[datetime] = None
76
78
  self.updated_at: Optional[datetime] = None
79
+ # read-only summaries a search adds: [ConnectionChannel], without their rows
80
+ self.readers: Optional[list] = None
81
+ self.writers: Optional[list] = None
82
+
83
+ @property
84
+ def system(self) -> bool:
85
+ """A device's built-in ``cloud`` / ``edge`` connection (DEV-6013): created by the
86
+ platform, never deleted, only its writer's policy edited."""
87
+ from .connection_types import get_connection_type
88
+ connection_type = get_connection_type(self.connection_type)
89
+ return bool(connection_type and connection_type.system)
77
90
 
78
91
  def secrets_set(self) -> list:
79
92
  """Names of the secret fields holding a value."""
@@ -99,6 +112,7 @@ class Connection:
99
112
  obj["connectionType"] = self.connection_type
100
113
  if self.edge_device_id is not None:
101
114
  obj["edgeDeviceId"] = self.edge_device_id
115
+ obj["system"] = self.system
102
116
  obj["config"] = dict(self.config or {})
103
117
  if include_secrets:
104
118
  obj["secrets"] = dict(self.secrets or {})
@@ -113,6 +127,10 @@ class Connection:
113
127
  obj["createdAt"] = _ts_out(self.created_at)
114
128
  if self.updated_at is not None:
115
129
  obj["updatedAt"] = _ts_out(self.updated_at)
130
+ if self.readers is not None:
131
+ obj["readers"] = [c.to_dict() for c in self.readers]
132
+ if self.writers is not None:
133
+ obj["writers"] = [c.to_dict() for c in self.writers]
116
134
  return obj
117
135
 
118
136
  @classmethod
@@ -144,50 +162,70 @@ class Connection:
144
162
  f"type={self.connection_type!r}, edge={self.edge_device_id!r})")
145
163
 
146
164
 
147
- class ConnectionMapping:
165
+ class ConnectionChannel:
148
166
  """
149
- ConnectionMapping entity stored in PostgreSQL ``"ConnectionMapping"`` table.
150
- PK ``id`` (UUID). FK ``connection_id`` → ``Connection.id``, cascade on delete.
151
-
152
- - ``direction``: READ (source → platform) or WRITE (platform → target).
153
- - ``address``: where, shaped by the type (``{"node_id": ...}``, ``{"topic": ...}``).
154
- - ``hardware_id``: the datapoint. Optional where the source names it (OPC browse
155
- with ``children``, a topic function fanning out to many datapoints).
156
- - ``poll_interval_ms``: polling cadence for polled types; ignored when pushed.
157
- - ``transform``: ``{"kind": "expression", "expression": "x / 10"}`` or
158
- ``{"kind": "function", "filepath": ..., "function_name": ...}``.
159
- - ``options``: type-specific tuning (``qos``, ``batch_size``, ``pack``...).
167
+ A reader or a writer of a connection (DEV-6013, 12.2 redesign), stored in PostgreSQL
168
+ ``"ConnectionChannel"``. PK ``id`` (UUID); FK ``connection_id`` → ``Connection.id``,
169
+ cascade on delete.
170
+
171
+ - ``direction``: READ (a reader, a consumers.json entry) or WRITE (a writer, a
172
+ writers.json entry).
173
+ - ``key``: unique per device and direction; it is the consumer or writer ``id`` on the
174
+ edge, so it is what the edge's logs and seen names call it.
175
+ - ``spec``: what it reads or writes, shaped by the connection type
176
+ (``connection_types``): OPC nodes and cadence, MQTT topics and function, a queue...
177
+ - ``policy_mode`` / ``prefix``: its datapoint policy (``connection_policy``); the rows
178
+ of the policy are its ``ConnectionSignal`` s.
179
+ - ``enabled``: a disabled reader or writer is not rendered.
180
+ - ``signals``: on create only, the policy rows to write with it (read back from search).
181
+ - ``signal_count`` / ``mapped_count``: read-only, how many rows and how many name a
182
+ datapoint.
160
183
  """
161
184
 
162
185
  def __init__(self):
163
186
  self.id: Optional[str] = None
164
187
  self.connection_id: Optional[str] = None
188
+ self.edge_device_id: Optional[str] = None
165
189
  self.direction: ConnectionDirection = ConnectionDirection.READ
166
- self.address: dict = {}
167
- self.hardware_id: Optional[str] = None
168
- self.poll_interval_ms: Optional[int] = None
169
- self.transform: Optional[dict] = None
170
- self.options: dict = {}
190
+ self.key: Optional[str] = None
191
+ self.name: Optional[str] = None
192
+ self.enabled: bool = True
193
+ self.spec: dict = {}
194
+ self.policy_mode: Optional[str] = None
195
+ self.prefix: Optional[str] = None
196
+ self.signals: Optional[list] = None
197
+ self.signal_count: Optional[int] = None
198
+ self.mapped_count: Optional[int] = None
171
199
  self.created_at: Optional[datetime] = None
172
200
  self.updated_at: Optional[datetime] = None
173
201
 
202
+ @property
203
+ def is_reader(self) -> bool:
204
+ return ConnectionDirection(self.direction) == ConnectionDirection.READ
205
+
174
206
  def to_dict(self) -> dict:
175
207
  obj = {}
176
208
  if self.id is not None:
177
209
  obj["id"] = str(self.id)
178
210
  if self.connection_id is not None:
179
211
  obj["connectionId"] = str(self.connection_id)
180
- obj["direction"] = (self.direction.value
181
- if isinstance(self.direction, ConnectionDirection)
212
+ if self.edge_device_id is not None:
213
+ obj["edgeDeviceId"] = self.edge_device_id
214
+ obj["direction"] = (self.direction.value if isinstance(self.direction, ConnectionDirection)
182
215
  else self.direction)
183
- obj["address"] = dict(self.address or {})
184
- if self.hardware_id is not None:
185
- obj["hardwareId"] = self.hardware_id
186
- if self.poll_interval_ms is not None:
187
- obj["pollIntervalMs"] = self.poll_interval_ms
188
- if self.transform is not None:
189
- obj["transform"] = dict(self.transform)
190
- obj["options"] = dict(self.options or {})
216
+ if self.key is not None:
217
+ obj["key"] = self.key
218
+ if self.name is not None:
219
+ obj["name"] = self.name
220
+ obj["enabled"] = bool(self.enabled)
221
+ obj["spec"] = dict(self.spec or {})
222
+ obj["policy"] = {"mode": self.policy_mode, "prefix": self.prefix or ""}
223
+ if self.signals is not None:
224
+ obj["signals"] = [s.to_dict() if hasattr(s, "to_dict") else dict(s) for s in self.signals]
225
+ if self.signal_count is not None:
226
+ obj["signalCount"] = self.signal_count
227
+ if self.mapped_count is not None:
228
+ obj["mappedCount"] = self.mapped_count
191
229
  if self.created_at is not None:
192
230
  obj["createdAt"] = _ts_out(self.created_at)
193
231
  if self.updated_at is not None:
@@ -195,19 +233,32 @@ class ConnectionMapping:
195
233
  return obj
196
234
 
197
235
  @classmethod
198
- def from_dict(cls, data: dict) -> "ConnectionMapping":
236
+ def from_dict(cls, data: dict) -> "ConnectionChannel":
199
237
  obj = cls()
200
238
  obj.id = data.get("id")
201
239
  obj.connection_id = data.get("connectionId") or data.get("connection_id")
240
+ obj.edge_device_id = data.get("edgeDeviceId") or data.get("edge_device_id")
202
241
  raw_direction = data.get("direction")
203
242
  if raw_direction is not None:
204
243
  obj.direction = ConnectionDirection(str(raw_direction).lower())
205
- obj.address = data.get("address") or {}
206
- obj.hardware_id = data.get("hardwareId") or data.get("hardware_id")
207
- poll = data.get("pollIntervalMs", data.get("poll_interval_ms"))
208
- obj.poll_interval_ms = int(poll) if poll is not None else None
209
- obj.transform = data.get("transform")
210
- obj.options = data.get("options") or {}
244
+ obj.key = data.get("key")
245
+ obj.name = data.get("name")
246
+ enabled = data.get("enabled")
247
+ obj.enabled = True if enabled is None else bool(enabled)
248
+ obj.spec = data.get("spec") or {}
249
+ policy = data.get("policy")
250
+ if isinstance(policy, dict):
251
+ obj.policy_mode = policy.get("mode")
252
+ obj.prefix = policy.get("prefix")
253
+ else:
254
+ obj.policy_mode = data.get("policyMode") or data.get("policy_mode")
255
+ obj.prefix = data.get("prefix")
256
+ signals = data.get("signals")
257
+ if signals is not None:
258
+ obj.signals = [s if isinstance(s, ConnectionSignal) else ConnectionSignal.from_dict(s)
259
+ for s in signals]
260
+ obj.signal_count = data.get("signalCount")
261
+ obj.mapped_count = data.get("mappedCount")
211
262
  obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
212
263
  obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
213
264
  return obj
@@ -216,14 +267,73 @@ class ConnectionMapping:
216
267
  return str(self.id) if self.id else None
217
268
 
218
269
  def api_key(self) -> str:
219
- return self.api_id()
270
+ return self.key
271
+
272
+ def set_id(self, value):
273
+ self.id = str(value)
274
+
275
+ def __repr__(self) -> str:
276
+ return (f"ConnectionChannel(id={self.id!r}, key={self.key!r}, direction={self.direction!r}, "
277
+ f"connection={self.connection_id!r}, policy={self.policy_mode!r})")
278
+
279
+
280
+ class ConnectionSignal:
281
+ """
282
+ One row of a reader's or writer's datapoint policy (DEV-6013, 12.2 redesign), stored in
283
+ PostgreSQL ``"ConnectionSignal"``. PK ``id`` (UUID); FK ``channel_id`` →
284
+ ``ConnectionChannel.id``, cascade on delete. Unique per channel: ``tag``, and
285
+ ``hardware_id`` when set.
286
+
287
+ - For a **reader**, ``tag`` is the reader's own name for what it read (an OPC node id,
288
+ a point name, a function's output) and ``hardware_id`` the datapoint it goes on as.
289
+ In a ``list`` policy a row with no ``hardware_id`` lets the tag through (prefixed).
290
+ - For a **writer**, ``hardware_id`` is the datapoint and ``tag`` where it goes (the OPC
291
+ node, the name written). In a ``list`` policy a row with no ``tag`` lets the
292
+ datapoint through.
293
+ - A row with both always renames, whatever the mode.
294
+ """
295
+
296
+ def __init__(self, tag: str = None, hardware_id: str = None):
297
+ self.id: Optional[str] = None
298
+ self.channel_id: Optional[str] = None
299
+ self.tag: Optional[str] = tag
300
+ self.hardware_id: Optional[str] = hardware_id
301
+ self.created_at: Optional[datetime] = None
302
+ self.updated_at: Optional[datetime] = None
303
+
304
+ def to_dict(self) -> dict:
305
+ obj = {}
306
+ if self.id is not None:
307
+ obj["id"] = str(self.id)
308
+ if self.channel_id is not None:
309
+ obj["channelId"] = str(self.channel_id)
310
+ obj["tag"] = self.tag
311
+ obj["hardwareId"] = self.hardware_id
312
+ if self.created_at is not None:
313
+ obj["createdAt"] = _ts_out(self.created_at)
314
+ if self.updated_at is not None:
315
+ obj["updatedAt"] = _ts_out(self.updated_at)
316
+ return obj
317
+
318
+ @classmethod
319
+ def from_dict(cls, data: dict) -> "ConnectionSignal":
320
+ obj = cls()
321
+ obj.id = data.get("id")
322
+ obj.channel_id = data.get("channelId") or data.get("channel_id")
323
+ obj.tag = data.get("tag")
324
+ obj.hardware_id = data.get("hardwareId") or data.get("hardware_id")
325
+ obj.created_at = _ts_in(data.get("createdAt") or data.get("created_at"))
326
+ obj.updated_at = _ts_in(data.get("updatedAt") or data.get("updated_at"))
327
+ return obj
328
+
329
+ def api_id(self) -> str:
330
+ return str(self.id) if self.id else None
220
331
 
221
332
  def set_id(self, value):
222
333
  self.id = str(value)
223
334
 
224
335
  def __repr__(self) -> str:
225
- return (f"ConnectionMapping(id={self.id!r}, connection={self.connection_id!r}, "
226
- f"direction={self.direction!r}, hardware_id={self.hardware_id!r})")
336
+ return f"ConnectionSignal(tag={self.tag!r}, hardware_id={self.hardware_id!r})"
227
337
 
228
338
 
229
339
  class EdgeDeviceKey:
@@ -0,0 +1,196 @@
1
+ """
2
+ What an edge tells the platform about its connections (DEV-6013, release 12.2):
3
+
4
+ - **seen names**: every name a reader produced, and what its policy did with it. A new
5
+ reader lets nothing through (an empty list), so this is how a user sees what to allow.
6
+ **Names, counts and times, never values**: what a policy dropped must not leave the edge
7
+ through the report.
8
+ - **browse results**: the nodes of an OPC-UA server, browsed by the edge on a platform
9
+ request (``browse_opc`` command) or by hand on a manual edge (``browse.py``).
10
+
11
+ Stored in PostgreSQL (alembic 0029): ``"ConnectionSeenName"``, ``"ConnectionSeenReport"``,
12
+ ``"ConnectionBrowse"``.
13
+ """
14
+ import uuid
15
+ from datetime import datetime
16
+ from typing import Optional
17
+
18
+ from .connection import _ts_in, _ts_out
19
+
20
+ SEEN_OUTCOMES = ("passed", "renamed", "dropped")
21
+
22
+ BROWSE_REQUESTED = "requested"
23
+ BROWSE_STATUSES = (BROWSE_REQUESTED, "ok", "partial", "failed", "refused")
24
+ #: A request the edge never answered reads as expired after this (the command is served
25
+ #: 15 minutes, a browse runs up to 5, and its upload is retried up to 15).
26
+ BROWSE_EXPIRES_SECONDS = 3600
27
+
28
+
29
+ class ConnectionSeenName:
30
+ """
31
+ One name a reader produced (PK ``channel_id`` + ``name``), as the edge last reported it.
32
+
33
+ - ``name``: the reader's native name (OPC node id or display name, a point name, what a
34
+ function emitted), before its policy.
35
+ - ``hardware_id`` / ``outcome``: what the policy the edge ran made of it (``passed``,
36
+ ``renamed``, ``dropped``); ``hardware_id`` is None when dropped.
37
+ - ``count``: messages since the edge's reader started; ``first_seen`` / ``last_seen``.
38
+ - ``value_type``: ``number``, ``text``, ``bool``... never the value.
39
+ - ``current_hardware_id`` / ``current_outcome``: read-only, what the policy stored now
40
+ makes of the name (the edge applies it on its next sync).
41
+ """
42
+
43
+ def __init__(self, channel_id: str = None, name: str = None):
44
+ self.channel_id: Optional[str] = channel_id
45
+ self.name: Optional[str] = name
46
+ self.hardware_id: Optional[str] = None
47
+ self.outcome: Optional[str] = None
48
+ self.count: int = 0
49
+ self.first_seen: Optional[datetime] = None
50
+ self.last_seen: Optional[datetime] = None
51
+ self.value_type: Optional[str] = None
52
+ self.reported_at: Optional[datetime] = None
53
+ self.current_hardware_id: Optional[str] = None
54
+ self.current_outcome: Optional[str] = None
55
+
56
+ def to_dict(self) -> dict:
57
+ obj = {"channelId": str(self.channel_id) if self.channel_id else None, "name": self.name,
58
+ "hardwareId": self.hardware_id, "outcome": self.outcome, "count": self.count,
59
+ "firstSeen": _ts_out(self.first_seen), "lastSeen": _ts_out(self.last_seen),
60
+ "valueType": self.value_type, "reportedAt": _ts_out(self.reported_at)}
61
+ if self.current_outcome is not None:
62
+ obj["current"] = {"hardwareId": self.current_hardware_id, "outcome": self.current_outcome}
63
+ return obj
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict) -> "ConnectionSeenName":
67
+ obj = cls(channel_id=data.get("channelId"), name=data.get("name"))
68
+ obj.hardware_id = data.get("hardwareId")
69
+ obj.outcome = data.get("outcome")
70
+ obj.count = int(data.get("count") or 0)
71
+ obj.first_seen = _ts_in(data.get("firstSeen"))
72
+ obj.last_seen = _ts_in(data.get("lastSeen"))
73
+ obj.value_type = data.get("valueType")
74
+ obj.reported_at = _ts_in(data.get("reportedAt"))
75
+ return obj
76
+
77
+ def __repr__(self) -> str:
78
+ return f"ConnectionSeenName(name={self.name!r}, outcome={self.outcome!r})"
79
+
80
+
81
+ class ConnectionSeenReport:
82
+ """
83
+ The last report of one reader (PK ``channel_id``): the digest of the policy the edge
84
+ ran, so a user sees whether the edge applies the stored one yet; names beyond the
85
+ edge's cap (``overflow``) and messages it could not name (``invalid``).
86
+ """
87
+
88
+ def __init__(self, channel_id: str = None):
89
+ self.channel_id: Optional[str] = channel_id
90
+ self.policy_digest: Optional[str] = None
91
+ self.overflow: int = 0
92
+ self.invalid: int = 0
93
+ self.reported_at: Optional[datetime] = None
94
+
95
+ def to_dict(self) -> dict:
96
+ return {"channelId": str(self.channel_id) if self.channel_id else None,
97
+ "policyDigest": self.policy_digest, "overflow": self.overflow,
98
+ "invalid": self.invalid, "reportedAt": _ts_out(self.reported_at)}
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: dict) -> "ConnectionSeenReport":
102
+ obj = cls(channel_id=data.get("channelId"))
103
+ obj.policy_digest = data.get("policyDigest")
104
+ obj.overflow = int(data.get("overflow") or 0)
105
+ obj.invalid = int(data.get("invalid") or 0)
106
+ obj.reported_at = _ts_in(data.get("reportedAt"))
107
+ return obj
108
+
109
+
110
+ class ConnectionBrowse:
111
+ """
112
+ One OPC-UA browse of a connection (PK ``id``, the command id the edge answers with).
113
+
114
+ - ``source``: ``platform`` (requested here, sent as a ``browse_opc`` command) or
115
+ ``edge`` (run by hand on a manual edge and uploaded).
116
+ - ``status``: ``requested`` until the edge answers, then the edge's ``ok``,
117
+ ``partial`` (deadline or node cap reached), ``failed`` or ``refused``. Read as
118
+ ``expired`` when a request got no answer within ``BROWSE_EXPIRES_SECONDS``.
119
+ - ``roots``, ``depth``, ``max_nodes``, ``timeout_seconds``: what was asked (the edge
120
+ clamps them).
121
+ - ``server``: ``{url, namespaces}``; ``nodes``: what the browser returned (node id,
122
+ names, class, data type, access, ``readable``, ``hasChildren``, parent, depth).
123
+ - ``pages`` / ``complete``: the edge uploads at most 1000 nodes per page; ``complete``
124
+ once its last page arrived.
125
+ """
126
+
127
+ def __init__(self, id: str = None, connection_id: str = None, edge_device_id: str = None):
128
+ self.id: Optional[str] = id
129
+ self.connection_id: Optional[str] = connection_id
130
+ self.edge_device_id: Optional[str] = edge_device_id
131
+ self.source: str = "platform"
132
+ self.status: str = BROWSE_REQUESTED
133
+ self.roots: Optional[list] = None
134
+ self.depth: Optional[int] = None
135
+ self.max_nodes: Optional[int] = None
136
+ self.timeout_seconds: Optional[int] = None
137
+ self.requested_by: Optional[str] = None
138
+ self.requested_at: Optional[datetime] = None
139
+ self.started_at: Optional[str] = None
140
+ self.finished_at: Optional[str] = None
141
+ self.error: Optional[str] = None
142
+ self.truncated: bool = False
143
+ self.server: Optional[dict] = None
144
+ self.nodes: list = []
145
+ self.node_count: int = 0
146
+ self.pages: int = 0
147
+ self.complete: bool = False
148
+
149
+ def api_id(self) -> str:
150
+ return str(self.id) if self.id else None
151
+
152
+ def set_id(self, value):
153
+ self.id = str(value)
154
+
155
+ def get_id_type(self) -> type:
156
+ return uuid.UUID
157
+
158
+ def to_dict(self, include_nodes: bool = True) -> dict:
159
+ obj = {"id": str(self.id) if self.id else None,
160
+ "connectionId": str(self.connection_id) if self.connection_id else None,
161
+ "edgeDeviceId": self.edge_device_id, "source": self.source, "status": self.status,
162
+ "roots": self.roots, "depth": self.depth, "maxNodes": self.max_nodes,
163
+ "timeoutSeconds": self.timeout_seconds, "requestedBy": self.requested_by,
164
+ "requestedAt": _ts_out(self.requested_at), "startedAt": self.started_at,
165
+ "finishedAt": self.finished_at, "error": self.error, "truncated": bool(self.truncated),
166
+ "server": self.server, "nodeCount": self.node_count, "pages": self.pages,
167
+ "complete": bool(self.complete)}
168
+ if include_nodes:
169
+ obj["nodes"] = list(self.nodes or [])
170
+ return obj
171
+
172
+ @classmethod
173
+ def from_dict(cls, data: dict) -> "ConnectionBrowse":
174
+ obj = cls(id=data.get("id"), connection_id=data.get("connectionId"),
175
+ edge_device_id=data.get("edgeDeviceId"))
176
+ obj.source = data.get("source") or "platform"
177
+ obj.status = data.get("status") or BROWSE_REQUESTED
178
+ obj.roots = data.get("roots")
179
+ obj.depth = data.get("depth")
180
+ obj.max_nodes = data.get("maxNodes")
181
+ obj.timeout_seconds = data.get("timeoutSeconds")
182
+ obj.requested_by = data.get("requestedBy")
183
+ obj.requested_at = _ts_in(data.get("requestedAt"))
184
+ obj.started_at = data.get("startedAt")
185
+ obj.finished_at = data.get("finishedAt")
186
+ obj.error = data.get("error")
187
+ obj.truncated = bool(data.get("truncated"))
188
+ obj.server = data.get("server")
189
+ obj.nodes = list(data.get("nodes") or [])
190
+ obj.node_count = int(data.get("nodeCount") or len(obj.nodes))
191
+ obj.pages = int(data.get("pages") or 0)
192
+ obj.complete = bool(data.get("complete"))
193
+ return obj
194
+
195
+ def __repr__(self) -> str:
196
+ return f"ConnectionBrowse(id={self.id!r}, status={self.status!r}, nodes={self.node_count})"
@@ -0,0 +1,194 @@
1
+ """
2
+ DEV-6013 (12.2 redesign): the datapoint policy of an edge reader or writer, and the one
3
+ resolution of it that both the platform and the edge run.
4
+
5
+ A reader names what it reads with its own **tag**: an OPC node id or name (per its
6
+ ``name_source``), a modbus or S7 point name, or the ``HardwareId`` an MQTT or RabbitMQ
7
+ function emits. A writer receives datapoints (``HardwareId``). The policy decides what
8
+ goes on, and under which name:
9
+
10
+ | Name ``n`` | Result |
11
+ |---|---|
12
+ | in ``mapping`` | ``mapping[n]``, verbatim, whatever the mode |
13
+ | mode ``all`` | ``prefix + n`` |
14
+ | mode ``list`` and ``n`` in ``names`` | ``prefix + n`` |
15
+ | otherwise | dropped (``None``) |
16
+
17
+ So ``mapping`` mode is strict: a name with no mapping is dropped. ``list`` matches the
18
+ native name, so changing the prefix never empties a list. ``all`` with mapping entries is
19
+ how a 12.1 OPC consumer named its nodes (its ``mapping``, else ``prefix + name``). For a
20
+ writer, the mapping is ``{datapoint: target}``: the OPC node id, or the name written to
21
+ the cloud or to the store.
22
+
23
+ The platform renders a policy into every ``consumers.json`` / ``writers.json`` entry as
24
+ ``{"mode", "prefix", "names", "mapping"}``. message_client reads it back with
25
+ ``DatapointPolicy.from_dict`` and applies ``resolve``. A policy that cannot be read is a
26
+ ``PolicyError``; the edge then drops everything for that reader or writer (fail closed),
27
+ because a policy is what keeps data from leaving.
28
+ """
29
+ import hashlib
30
+ import json
31
+
32
+ MODE_ALL = "all"
33
+ MODE_LIST = "list"
34
+ MODE_MAPPING = "mapping"
35
+ MODES = (MODE_ALL, MODE_LIST, MODE_MAPPING)
36
+
37
+ OUTCOME_PASSED = "passed"
38
+ OUTCOME_RENAMED = "renamed"
39
+ OUTCOME_DROPPED = "dropped"
40
+
41
+
42
+ class PolicyError(ValueError):
43
+ """The policy cannot be read: a mode it does not know, or names that are not text."""
44
+
45
+
46
+ class DatapointPolicy:
47
+ """
48
+ An immutable datapoint policy. Build it with ``from_dict`` (the rendered form),
49
+ ``from_datapoints`` (a writers.json entry without one), ``deny`` or ``allow_all``.
50
+ """
51
+
52
+ __slots__ = ("mode", "prefix", "names", "mapping", "_digest")
53
+
54
+ def __init__(self, mode: str = MODE_LIST, prefix: str = None, names=None, mapping: dict = None):
55
+ if mode not in MODES:
56
+ raise PolicyError(f"unknown policy mode {mode!r}: use one of {', '.join(MODES)}")
57
+ if prefix is None:
58
+ prefix = ""
59
+ if not isinstance(prefix, str):
60
+ raise PolicyError("the policy prefix must be text")
61
+ listed = set()
62
+ for name in names or ():
63
+ if not isinstance(name, str) or not name:
64
+ raise PolicyError(f"a listed name must be non-empty text, not {name!r}")
65
+ listed.add(name)
66
+ renames = {}
67
+ if mapping is not None and not isinstance(mapping, dict):
68
+ raise PolicyError("the policy mapping must be an object {name: name}")
69
+ for source, target in (mapping or {}).items():
70
+ if not isinstance(source, str) or not source:
71
+ raise PolicyError(f"a mapped name must be non-empty text, not {source!r}")
72
+ if not isinstance(target, str) or not target:
73
+ raise PolicyError(f"{source!r} must map to a non-empty name, not {target!r}")
74
+ renames[source] = target
75
+ object.__setattr__(self, "mode", mode)
76
+ object.__setattr__(self, "prefix", prefix)
77
+ object.__setattr__(self, "names", frozenset(listed))
78
+ object.__setattr__(self, "mapping", renames)
79
+ object.__setattr__(self, "_digest", None)
80
+
81
+ def __setattr__(self, key, value):
82
+ raise AttributeError("a DatapointPolicy is immutable")
83
+
84
+ def __eq__(self, other):
85
+ return isinstance(other, DatapointPolicy) and self.to_dict() == other.to_dict()
86
+
87
+ def __hash__(self):
88
+ return hash(self.digest())
89
+
90
+ def __repr__(self):
91
+ return (f"DatapointPolicy(mode={self.mode!r}, prefix={self.prefix!r}, "
92
+ f"names={len(self.names)}, mapping={len(self.mapping)})")
93
+
94
+ # ------------------------------------------------------------------ builders
95
+
96
+ @classmethod
97
+ def from_dict(cls, data: dict) -> "DatapointPolicy":
98
+ """The rendered form ``{"mode", "prefix", "names", "mapping"}``. A missing mode is
99
+ ``list``, so a policy that says nothing lets nothing through. Unknown keys are
100
+ ignored, so a newer platform can add some."""
101
+ if not isinstance(data, dict):
102
+ raise PolicyError(f"a policy must be an object, not {type(data).__name__}")
103
+ names = data.get("names")
104
+ if names is not None and not isinstance(names, (list, tuple, set, frozenset)):
105
+ raise PolicyError("the policy names must be a list")
106
+ return cls(mode=data.get("mode", MODE_LIST), prefix=data.get("prefix"),
107
+ names=names, mapping=data.get("mapping"))
108
+
109
+ @classmethod
110
+ def from_datapoints(cls, datapoints) -> "DatapointPolicy":
111
+ """
112
+ The policy a writers.json entry without one has always meant: ``"*"`` is all, a
113
+ list keeps those datapoints, and a dict (an OPC writer's ``{datapoint: node id}``)
114
+ keeps its keys and does not rename them (the writer maps them itself). Anything
115
+ else, including no ``datapoints`` at all, keeps nothing.
116
+ """
117
+ if datapoints == "*":
118
+ return cls.allow_all()
119
+ if isinstance(datapoints, dict):
120
+ return cls(mode=MODE_LIST, names=[k for k in datapoints if isinstance(k, str) and k])
121
+ if isinstance(datapoints, (list, tuple, set, frozenset)):
122
+ return cls(mode=MODE_LIST, names=[k for k in datapoints if isinstance(k, str) and k])
123
+ return cls.deny()
124
+
125
+ @classmethod
126
+ def deny(cls) -> "DatapointPolicy":
127
+ """An empty list: nothing goes on. What a new reader or cloud/edge writer starts with."""
128
+ return cls(mode=MODE_LIST)
129
+
130
+ @classmethod
131
+ def allow_all(cls, prefix: str = None) -> "DatapointPolicy":
132
+ return cls(mode=MODE_ALL, prefix=prefix)
133
+
134
+ # ------------------------------------------------------------------ resolution
135
+
136
+ def resolve(self, name):
137
+ """The name ``name`` goes on as, or ``None`` when the policy drops it."""
138
+ if not isinstance(name, str):
139
+ return None
140
+ mapped = self.mapping.get(name)
141
+ if mapped is not None:
142
+ return mapped
143
+ if self.mode == MODE_ALL or (self.mode == MODE_LIST and name in self.names):
144
+ return self.prefix + name
145
+ return None
146
+
147
+ def allows(self, name) -> bool:
148
+ return self.resolve(name) is not None
149
+
150
+ def outcome(self, name) -> str:
151
+ """``passed`` (same name), ``renamed`` or ``dropped``: what the edge reports for a
152
+ name it saw."""
153
+ resolved = self.resolve(name)
154
+ if resolved is None:
155
+ return OUTCOME_DROPPED
156
+ return OUTCOME_PASSED if resolved == name else OUTCOME_RENAMED
157
+
158
+ @property
159
+ def is_identity(self) -> bool:
160
+ """Every name goes on unchanged: nothing to filter, nothing to copy."""
161
+ return self.mode == MODE_ALL and not self.prefix and not self.mapping
162
+
163
+ # ------------------------------------------------------------------ rendering
164
+
165
+ def to_dict(self) -> dict:
166
+ """The rendered form, sorted, so the same policy always renders the same bytes."""
167
+ return {
168
+ "mode": self.mode,
169
+ "prefix": self.prefix,
170
+ "names": sorted(self.names),
171
+ "mapping": {k: self.mapping[k] for k in sorted(self.mapping)},
172
+ }
173
+
174
+ def digest(self) -> str:
175
+ """``sha256:<hex>`` of the rendered form. The edge reports the digest it applies, so
176
+ the platform can tell an edit the edge already runs from one still on its way."""
177
+ if self._digest is None:
178
+ text = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
179
+ object.__setattr__(self, "_digest", "sha256:" + hashlib.sha256(text.encode()).hexdigest())
180
+ return self._digest
181
+
182
+
183
+ def canonical_node_id(text: str) -> str:
184
+ """
185
+ A light canonical form of an OPC node id, for the platform, which has no OPC stack:
186
+ surrounding spaces trimmed and the default namespace dropped (``ns=0;i=85`` and
187
+ ``i=85`` are one node). The edge normalises with asyncua, which agrees on these forms.
188
+ """
189
+ if not isinstance(text, str):
190
+ return text
191
+ text = text.strip()
192
+ if text.startswith("ns=0;"):
193
+ text = text[len("ns=0;"):]
194
+ return text
@@ -1,27 +1,37 @@
1
1
  """
2
- Connection type registry (DEV-6013).
2
+ Connection type registry (DEV-6013; readers and writers since the 12.2 redesign).
3
3
 
4
- One definition per connector type, used for three things:
4
+ A **connection** says how an edge reaches something: its endpoint and credentials, entered
5
+ once. A connection holds **readers** and **writers**, and each one has:
6
+ - a **spec**: what it reads or writes, and how (OPC nodes and cadence, MQTT topics and the
7
+ function parsing them, a queue, Modbus points, S7 blocks);
8
+ - a **datapoint policy** (``connection_policy``): ``all``, ``list`` or ``mapping``, over
9
+ the reader's own names (its *tag*) or the writer's datapoints.
10
+
11
+ One definition per type serves three things:
5
12
  - the front-end lists the types and builds each form from its JSON Schemas;
6
- - the platform validates every connection and mapping against them on write — the
13
+ - the platform validates every connection, reader and writer against them on write — the
7
14
  single place a secret-bearing key is refused outside ``secrets``;
8
15
  - the platform renders the edge configuration from the entities.
9
16
 
10
- Schemas are plain JSON Schema (draft 2020-12) dicts, so serving them needs nothing.
11
- Secret fields are ``writeOnly``: accepted on write, never returned.
17
+ Schemas are plain JSON Schema (draft 2020-12) dicts. Secret fields are ``writeOnly``:
18
+ accepted on write, never returned. Key names are the entity's, not the legacy
19
+ ``consumers.json`` spelling; rendering maps them back.
12
20
 
13
- Key names are the entity's, not the legacy ``consumers.json`` spelling, which drifted
14
- per type (``opc_session_timeout_ms`` vs ``opc_session_timeout``, snap7 ``port`` vs
15
- ``tcp_port``, RabbitMQ ``RQ_USER`` vs ``user``). Rendering maps them back.
21
+ ``hub`` (key ``cloud``) and ``tsdb`` (key ``edge``) are **system** types: every device gets
22
+ them from the platform, with one writer each, and only their policy is edited. Their
23
+ endpoint is the edge's own environment.
16
24
  """
17
25
 
18
26
  from typing import Optional
19
27
 
20
28
  from .connection import ConnectionDirection, TransformKind
29
+ from .connection_policy import MODE_ALL, MODE_LIST, MODE_MAPPING
21
30
 
22
31
 
23
32
  _READ = ConnectionDirection.READ.value
24
33
  _WRITE = ConnectionDirection.WRITE.value
34
+ _ALL_MODES = [MODE_ALL, MODE_LIST, MODE_MAPPING]
25
35
 
26
36
 
27
37
  def _obj(properties: dict, required: list = None) -> dict:
@@ -31,6 +41,13 @@ def _obj(properties: dict, required: list = None) -> dict:
31
41
  return schema
32
42
 
33
43
 
44
+ def _array(items: dict, min_items: int = None) -> dict:
45
+ schema = {"type": "array", "items": items}
46
+ if min_items is not None:
47
+ schema["minItems"] = min_items
48
+ return schema
49
+
50
+
34
51
  def _secret(title: str, pem: bool = False) -> dict:
35
52
  field = {"type": "string", "title": title, "writeOnly": True}
36
53
  if pem:
@@ -44,38 +61,74 @@ def _pem(title: str) -> dict:
44
61
 
45
62
  _EMPTY = _obj({})
46
63
 
64
+ _FUNCTION = _obj({
65
+ "filepath": {"type": "string", "title": "Function file", "minLength": 1},
66
+ "function_name": {"type": "string", "title": "Function name", "minLength": 1},
67
+ }, required=["filepath", "function_name"])
68
+
69
+ _POLL = {"type": "integer", "title": "Poll interval (ms)", "minimum": 10, "default": 1000}
70
+
71
+
72
+ class ChannelType:
73
+ """
74
+ What a reader or writer of a type is.
75
+
76
+ :param spec: JSON Schema of its ``spec``.
77
+ :param policy_modes: the policy modes it accepts.
78
+ :param tag: what its native names are, for the form ("the OPC node id").
79
+ :param default_mode: the mode a new one starts with. Readers and the cloud/edge
80
+ writers start as an empty list: a wrong configuration must not flood the platform.
81
+ """
82
+
83
+ def __init__(self, spec: dict, policy_modes: list, tag: str, default_mode: str = MODE_LIST):
84
+ self.spec = spec
85
+ self.policy_modes = policy_modes
86
+ self.tag = tag
87
+ self.default_mode = default_mode
88
+
89
+ def describe(self) -> dict:
90
+ return {"spec": self.spec, "policyModes": list(self.policy_modes), "tag": self.tag,
91
+ "defaultMode": self.default_mode}
92
+
47
93
 
48
94
  class ConnectionType:
49
95
  """
50
- One supported connector type.
96
+ One supported connection type.
51
97
 
52
98
  :param name: identifier stored on ``Connection.connection_type``.
53
- :param directions: what mappings may do — read, write, or both.
54
- :param transforms: transform kinds a read mapping may carry.
55
- :param polled: mappings take a ``poll_interval_ms`` (else the source pushes).
56
- :param hardware_id_required: per direction, whether a mapping must name its
57
- datapoint (False where the source names it).
58
99
  :param config: schema for ``Connection.config``.
59
100
  :param secrets: schema for ``Connection.secrets`` (plaintext write form).
60
- :param mapping: per direction, ``{"address": schema, "options": schema}``.
101
+ :param reader: what its readers are, or None when it cannot read.
102
+ :param writer: what its writers are, or None when it cannot write.
103
+ :param system: created by the platform with the device (``cloud`` / ``edge``).
104
+ :param device_types: the device types it exists on (None: every type).
105
+ :param system_key: the key of the system connection, and of its writer.
61
106
  """
62
107
 
63
108
  def __init__(self, name: str, label: str, description: str,
64
- directions: list, transforms: list, polled: bool,
65
- hardware_id_required: dict,
66
- config: dict, secrets: dict, mapping: dict,
67
- supports_browse: bool = False):
109
+ config: dict, secrets: dict,
110
+ reader: ChannelType = None, writer: ChannelType = None,
111
+ supports_browse: bool = False, system: bool = False,
112
+ device_types: list = None, system_key: str = None):
68
113
  self.name = name
69
114
  self.label = label
70
115
  self.description = description
71
- self.directions = directions
72
- self.transforms = transforms
73
- self.polled = polled
74
- self.hardware_id_required = hardware_id_required
75
116
  self.config = config
76
117
  self.secrets = secrets
77
- self.mapping = mapping
118
+ self.reader = reader
119
+ self.writer = writer
78
120
  self.supports_browse = supports_browse
121
+ self.system = system
122
+ self.device_types = device_types
123
+ self.system_key = system_key
124
+
125
+ @property
126
+ def directions(self) -> list:
127
+ return ([_READ] if self.reader else []) + ([_WRITE] if self.writer else [])
128
+
129
+ def channel(self, direction) -> Optional[ChannelType]:
130
+ direction = getattr(direction, "value", direction)
131
+ return self.reader if direction == _READ else self.writer if direction == _WRITE else None
79
132
 
80
133
  @property
81
134
  def secret_fields(self) -> list:
@@ -87,37 +140,66 @@ class ConnectionType:
87
140
  "type": self.name,
88
141
  "label": self.label,
89
142
  "description": self.description,
90
- "directions": list(self.directions),
91
- "transforms": list(self.transforms),
92
- "polled": self.polled,
143
+ "system": self.system,
144
+ "deviceTypes": list(self.device_types) if self.device_types else None,
145
+ "directions": self.directions,
93
146
  "supportsBrowse": self.supports_browse,
94
147
  "secretFields": self.secret_fields,
95
148
  }
96
149
 
97
150
  def schema(self) -> dict:
98
- """Everything a form needs: the summary plus every JSON Schema."""
151
+ """Everything a form needs: the summary, the connection's schemas, and per direction
152
+ the reader's or writer's spec schema and policy modes."""
99
153
  out = self.describe()
100
- out["hardwareIdRequired"] = dict(self.hardware_id_required)
101
154
  out["config"] = self.config
102
155
  out["secrets"] = self.secrets
103
- out["mapping"] = {d: dict(s) for d, s in self.mapping.items()}
156
+ out["reader"] = self.reader.describe() if self.reader else None
157
+ out["writer"] = self.writer.describe() if self.writer else None
104
158
  return out
105
159
 
106
160
 
161
+ # ── Built-in: the cloud hub and the edge's own store ───────────────────────────
162
+
163
+ HUB = ConnectionType(
164
+ name="hub",
165
+ label="Cloud",
166
+ description="The platform's hub (Event Hub by default), reached with the edge's own "
167
+ "settings. Its writer chooses which datapoints leave the edge, and under which name.",
168
+ config=_EMPTY, secrets=_EMPTY,
169
+ writer=ChannelType(spec=_EMPTY, policy_modes=_ALL_MODES,
170
+ tag="the datapoint; in a mapping, the name it is sent to the cloud as"),
171
+ system=True, system_key="cloud",
172
+ )
173
+
174
+ TSDB = ConnectionType(
175
+ name="tsdb",
176
+ label="Edge store",
177
+ description="The edge's own time-series database, which its pipelines read. Its writer "
178
+ "chooses which datapoints the edge keeps.",
179
+ config=_EMPTY, secrets=_EMPTY,
180
+ writer=ChannelType(spec=_EMPTY, policy_modes=_ALL_MODES,
181
+ tag="the datapoint; in a mapping, the name it is stored as"),
182
+ system=True, system_key="edge", device_types=["ConnectivityAndPipelines"],
183
+ )
184
+
107
185
  # ── OPC-UA ─────────────────────────────────────────────────────────────────────
108
186
 
109
187
  _OPC_SECURITY_POLICIES = ["None", "Basic128Rsa15", "Basic256", "Basic256Sha256",
110
188
  "Aes128Sha256RsaOaep", "Aes256Sha256RsaPss"]
111
189
 
190
+ _OPC_GROUP_TUNING = {
191
+ "batch_size": {"type": "integer", "minimum": 1, "title": "Nodes per read"},
192
+ "max_concurrent_batches": {"type": "integer", "minimum": 1},
193
+ "publishing_interval": {"type": "integer", "minimum": 0, "default": 1000},
194
+ "sampling_interval": {"type": "integer", "minimum": 0, "default": 0},
195
+ "queue_size": {"type": "integer", "minimum": 1, "default": 1},
196
+ }
197
+
112
198
  OPC_UA = ConnectionType(
113
199
  name="opc-ua",
114
200
  label="OPC-UA",
115
- description="OPC-UA server — read by polling or subscription, write to nodes.",
116
- directions=[_READ, _WRITE],
117
- transforms=[],
118
- polled=True,
201
+ description="OPC-UA server — readers poll or subscribe to nodes, writers write to nodes.",
119
202
  supports_browse=True,
120
- hardware_id_required={_READ: False, _WRITE: True},
121
203
  config=_obj({
122
204
  "server_url": {"type": "string", "title": "Server URL", "pattern": "^opc\\.tcp://"},
123
205
  "username": {"type": "string", "title": "Username"},
@@ -130,39 +212,36 @@ OPC_UA = ConnectionType(
130
212
  "server_certificate": _pem("Server certificate"),
131
213
  "session_timeout_ms": {"type": "integer", "title": "Session timeout (ms)",
132
214
  "minimum": 1000, "default": 60000},
133
- "reconnect_interval_ms": {"type": "integer", "title": "Reconnect interval (ms)",
134
- "minimum": 1000, "default": 5000},
135
- "mode": {"type": "string", "title": "Read mode",
136
- "enum": ["polling", "subscription"], "default": "polling"},
137
- "name_source": {"type": "string", "title": "Name unmapped nodes by",
138
- "enum": ["name", "identifier"], "default": "name"},
139
- "prefix": {"type": "string", "title": "Prefix for unmapped nodes"},
140
215
  }, required=["server_url"]),
141
216
  secrets=_obj({
142
217
  "password": _secret("Password"),
143
218
  "client_private_key": _secret("Client private key", pem=True),
144
219
  }),
145
- mapping={
146
- _READ: {
147
- "address": _obj({
148
- "node_id": {"type": "string", "title": "Node id", "examples": ["ns=2;s=Temperature"]},
149
- "children": {"type": "boolean", "title": "Browse children", "default": False},
150
- }, required=["node_id"]),
151
- "options": _obj({
152
- "batch_size": {"type": "integer", "minimum": 1},
153
- "max_concurrent_batches": {"type": "integer", "minimum": 1},
154
- "publishing_interval": {"type": "integer", "minimum": 0, "default": 1000},
155
- "sampling_interval": {"type": "integer", "minimum": 0, "default": 0},
156
- "queue_size": {"type": "integer", "minimum": 1, "default": 1},
157
- }),
158
- },
159
- _WRITE: {
160
- "address": _obj({
161
- "node_id": {"type": "string", "title": "Node id"},
162
- }, required=["node_id"]),
163
- "options": _EMPTY,
164
- },
165
- },
220
+ reader=ChannelType(
221
+ spec=_obj({
222
+ "mode": {"type": "string", "title": "Read mode",
223
+ "enum": ["polling", "subscription"], "default": "polling"},
224
+ "name_source": {"type": "string", "title": "Name nodes by",
225
+ "enum": ["nodeid", "name", "identifier"], "default": "nodeid"},
226
+ "reconnect_interval_ms": {"type": "integer", "title": "Reconnect interval (ms)",
227
+ "minimum": 1000, "default": 5000},
228
+ "poll_interval_ms": _POLL,
229
+ "nodes_from_policy": {"type": "boolean", "title": "Read the nodes listed in the policy",
230
+ "default": False},
231
+ "nodes": _array(_obj({
232
+ "node_ids": _array({"type": "string", "minLength": 1}, min_items=1),
233
+ "children": {"type": "boolean", "title": "And everything under them", "default": False},
234
+ "children_depth": {"type": "integer", "minimum": 1},
235
+ "poll_interval_ms": _POLL,
236
+ **_OPC_GROUP_TUNING,
237
+ }, required=["node_ids"])),
238
+ **{k: v for k, v in _OPC_GROUP_TUNING.items()},
239
+ }),
240
+ policy_modes=_ALL_MODES,
241
+ tag="the OPC node id (name_source nodeid), or its display name / identifier",
242
+ ),
243
+ writer=ChannelType(spec=_EMPTY, policy_modes=[MODE_MAPPING],
244
+ tag="the node id each datapoint is written to", default_mode=MODE_MAPPING),
166
245
  )
167
246
 
168
247
  # ── MQTT ───────────────────────────────────────────────────────────────────────
@@ -170,17 +249,12 @@ OPC_UA = ConnectionType(
170
249
  MQTT = ConnectionType(
171
250
  name="mqtt",
172
251
  label="MQTT",
173
- description="MQTT broker — subscribe to topics, optional function to parse payloads.",
174
- directions=[_READ],
175
- transforms=[TransformKind.FUNCTION.value],
176
- polled=False,
177
- hardware_id_required={_READ: False},
252
+ description="MQTT broker — readers subscribe to topics, a function parses the payloads.",
178
253
  config=_obj({
179
254
  "host": {"type": "string", "title": "Broker host"},
180
255
  "port": {"type": "integer", "title": "Port", "default": 1883},
181
256
  "tls": {"type": "boolean", "title": "TLS", "default": False},
182
257
  "username": {"type": "string", "title": "Username"},
183
- "client_id": {"type": "string", "title": "Client id"},
184
258
  "keepalive": {"type": "integer", "title": "Keepalive (s)", "minimum": 1, "default": 60},
185
259
  "payload_format": {"type": "string", "enum": ["json", "raw"], "default": "json"},
186
260
  "reconnect_min": {"type": "number", "minimum": 0, "default": 1.0},
@@ -191,16 +265,18 @@ MQTT = ConnectionType(
191
265
  secrets=_obj({
192
266
  "password": _secret("Password"),
193
267
  }),
194
- mapping={
195
- _READ: {
196
- "address": _obj({
268
+ reader=ChannelType(
269
+ spec=_obj({
270
+ "topics": _array(_obj({
197
271
  "topic": {"type": "string", "title": "Topic", "examples": ["plant/line1/#"]},
198
- }, required=["topic"]),
199
- "options": _obj({
200
272
  "qos": {"type": "integer", "enum": [0, 1, 2], "default": 1},
201
- }),
202
- },
203
- },
273
+ }, required=["topic"]), min_items=1),
274
+ "client_id": {"type": "string", "title": "Client id"},
275
+ "function": _FUNCTION,
276
+ }, required=["topics"]),
277
+ policy_modes=_ALL_MODES,
278
+ tag="the HardwareId the function (or the payload) gives each value",
279
+ ),
204
280
  )
205
281
 
206
282
  # ── Modbus TCP ─────────────────────────────────────────────────────────────────
@@ -208,31 +284,29 @@ MQTT = ConnectionType(
208
284
  MODBUS = ConnectionType(
209
285
  name="modbus",
210
286
  label="Modbus TCP",
211
- description="Modbus TCP device — poll holding or input registers.",
212
- directions=[_READ],
213
- transforms=[TransformKind.EXPRESSION.value],
214
- polled=True,
215
- hardware_id_required={_READ: True},
287
+ description="Modbus TCP device — readers poll holding or input registers.",
216
288
  config=_obj({
217
289
  "ip_address": {"type": "string", "title": "IP address"},
218
290
  "port": {"type": "integer", "title": "Port", "default": 502},
219
291
  "unit_id": {"type": "integer", "title": "Unit id", "minimum": 0, "default": 1},
220
292
  }, required=["ip_address"]),
221
293
  secrets=_EMPTY,
222
- mapping={
223
- _READ: {
224
- "address": _obj({
294
+ reader=ChannelType(
295
+ spec=_obj({
296
+ "poll_interval_ms": _POLL,
297
+ "points": _array(_obj({
298
+ "name": {"type": "string", "title": "Point name", "minLength": 1},
225
299
  "address": {"type": "integer", "title": "Register address", "minimum": 0},
226
300
  "quantity": {"type": "integer", "title": "Register count", "minimum": 1},
227
- "register_type": {"type": "string", "enum": ["holding", "input"],
228
- "default": "holding"},
229
- }, required=["address", "quantity"]),
230
- "options": _obj({
301
+ "register_type": {"type": "string", "enum": ["holding", "input"], "default": "holding"},
231
302
  "pack": {"type": "string", "title": "struct pack format", "examples": [">HH"]},
232
303
  "unpack": {"type": "string", "title": "struct unpack format", "examples": [">f"]},
233
- }, required=["pack", "unpack"]),
234
- },
235
- },
304
+ "transform": {"type": "string", "title": "Expression on x", "examples": ["x / 100"]},
305
+ }, required=["name", "address", "quantity", "pack", "unpack"]), min_items=1),
306
+ }, required=["points"]),
307
+ policy_modes=_ALL_MODES,
308
+ tag="the point name",
309
+ ),
236
310
  )
237
311
 
238
312
  # ── Siemens S7 (snap7) ─────────────────────────────────────────────────────────
@@ -240,11 +314,7 @@ MODBUS = ConnectionType(
240
314
  SNAP7 = ConnectionType(
241
315
  name="snap7",
242
316
  label="Siemens S7",
243
- description="Siemens S7 PLC — poll data blocks.",
244
- directions=[_READ],
245
- transforms=[],
246
- polled=True,
247
- hardware_id_required={_READ: True},
317
+ description="Siemens S7 PLC — readers poll data blocks.",
248
318
  config=_obj({
249
319
  "address": {"type": "string", "title": "PLC address"},
250
320
  "rack": {"type": "integer", "minimum": 0, "default": 0},
@@ -252,19 +322,24 @@ SNAP7 = ConnectionType(
252
322
  "tcp_port": {"type": "integer", "title": "Port", "default": 102},
253
323
  }, required=["address"]),
254
324
  secrets=_EMPTY,
255
- mapping={
256
- _READ: {
257
- "address": _obj({
325
+ reader=ChannelType(
326
+ spec=_obj({
327
+ "poll_interval_ms": _POLL,
328
+ "blocks": _array(_obj({
258
329
  "db_number": {"type": "integer", "minimum": 0},
259
330
  "start_offset": {"type": "integer", "minimum": 0},
260
331
  "size": {"type": "integer", "minimum": 1},
261
- "index": {"type": "integer", "title": "Byte index in the block", "minimum": 0},
262
- "type": {"type": "string", "enum": ["dword", "word", "bool", "real", "int"]},
263
- "bit_position": {"type": "integer", "minimum": 0, "maximum": 7},
264
- }, required=["db_number", "start_offset", "size", "index", "type"]),
265
- "options": _EMPTY,
266
- },
267
- },
332
+ "points": _array(_obj({
333
+ "name": {"type": "string", "title": "Point name", "minLength": 1},
334
+ "type": {"type": "string", "enum": ["dword", "word", "bool", "real", "int"]},
335
+ "index": {"type": "integer", "title": "Byte index in the block", "minimum": 0},
336
+ "bit_position": {"type": "integer", "minimum": 0, "maximum": 7},
337
+ }, required=["name", "type", "index"]), min_items=1),
338
+ }, required=["db_number", "start_offset", "size", "points"]), min_items=1),
339
+ }, required=["blocks"]),
340
+ policy_modes=_ALL_MODES,
341
+ tag="the point name",
342
+ ),
268
343
  )
269
344
 
270
345
  # ── RabbitMQ (external queue) ──────────────────────────────────────────────────
@@ -272,11 +347,7 @@ SNAP7 = ConnectionType(
272
347
  RABBITMQ = ConnectionType(
273
348
  name="rabbitmq",
274
349
  label="RabbitMQ",
275
- description="External RabbitMQ broker — consume a queue, function to parse payloads.",
276
- directions=[_READ],
277
- transforms=[TransformKind.FUNCTION.value],
278
- polled=False,
279
- hardware_id_required={_READ: False},
350
+ description="External RabbitMQ broker — each reader consumes a queue, a function parses it.",
280
351
  config=_obj({
281
352
  "host": {"type": "string", "title": "Host"},
282
353
  "port": {"type": "integer", "title": "Port", "default": 5671},
@@ -287,23 +358,26 @@ RABBITMQ = ConnectionType(
287
358
  secrets=_obj({
288
359
  "password": _secret("Password"),
289
360
  }),
290
- mapping={
291
- _READ: {
292
- "address": _obj({
293
- "queue": {"type": "string", "title": "Queue", "default": "messages"},
294
- }, required=["queue"]),
295
- "options": _obj({
296
- "prefetch": {"type": "integer", "minimum": 1},
297
- "error_queue": {"type": "string"},
298
- }),
299
- },
300
- },
361
+ reader=ChannelType(
362
+ spec=_obj({
363
+ "queue": {"type": "string", "title": "Queue", "default": "messages"},
364
+ "prefetch": {"type": "integer", "minimum": 1},
365
+ "error_queue": {"type": "string"},
366
+ "function": _FUNCTION,
367
+ }, required=["queue"]),
368
+ policy_modes=_ALL_MODES,
369
+ tag="the HardwareId the function gives each value",
370
+ ),
301
371
  )
302
372
 
303
373
 
304
- CONNECTION_TYPES = {t.name: t for t in (OPC_UA, MQTT, MODBUS, SNAP7, RABBITMQ)}
374
+ CONNECTION_TYPES = {t.name: t for t in (HUB, TSDB, OPC_UA, MQTT, MODBUS, SNAP7, RABBITMQ)}
375
+
376
+ #: The types a device gets from the platform, by the key of their connection.
377
+ SYSTEM_TYPES = {t.system_key: t for t in CONNECTION_TYPES.values() if t.system}
305
378
 
306
- # Transform payloads, whatever the type (the type decides which kinds it accepts).
379
+ # Kept for the forms that still name the two transform kinds; a Modbus point carries its
380
+ # expression, an MQTT or RabbitMQ reader its function.
307
381
  TRANSFORM_SCHEMAS = {
308
382
  TransformKind.EXPRESSION.value: _obj({
309
383
  "kind": {"const": TransformKind.EXPRESSION.value},
wizata_dsapi/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2.2.0.dev20"
1
+ __version__ = "2.2.0.dev21"
@@ -3104,6 +3104,37 @@ class WizataDSAPIClient(ApiInterface, ApiDtoInterface):
3104
3104
  body={"packages": list(packages or [])}, timeout=timeout)
3105
3105
  return response.json()
3106
3106
 
3107
+ def send_edge_browse_result(self, edge_device_id: str, page: dict, timeout=(5, 60)) -> dict:
3108
+ """
3109
+ One page of an OPC browse the edge ran (DEV-6013): on a platform request
3110
+ (``requestId``), or run by hand on a manual edge (``requestId`` null). Pages hold
3111
+ at most 1000 nodes; the last one says ``final: true``.
3112
+
3113
+ :param page: ``{requestId, connectionId, page, final, status, error, truncated,
3114
+ startedAt, finishedAt, server, roots, depth, nodes: [...]}``.
3115
+ :param timeout: (connect, read) seconds.
3116
+ :return: the platform's answer.
3117
+ """
3118
+ response = self._edge_request("POST", self._edge_route(edge_device_id, "browse"),
3119
+ body=page, expected=(200, 201), timeout=timeout)
3120
+ return response.json()
3121
+
3122
+ def send_edge_seen_names(self, edge_device_id: str, report: dict, timeout=(5, 30)) -> dict:
3123
+ """
3124
+ The names each reader of the edge produced, and what its policy did with them
3125
+ (DEV-6013): names, counts and times, never values. A policy keeps data from
3126
+ leaving, and the report must not carry what it dropped.
3127
+
3128
+ :param report: ``{writtenAt, full, readers: [{readerId, connectionId, type,
3129
+ policyMode, policyDigest, overflow, invalid, names: [{name, hardwareId,
3130
+ outcome, count, firstSeen, lastSeen, valueType}]}]}``.
3131
+ :param timeout: (connect, read) seconds.
3132
+ :return: the platform's answer.
3133
+ """
3134
+ response = self._edge_request("POST", self._edge_route(edge_device_id, "seen"),
3135
+ body=report, expected=(200, 201), timeout=timeout)
3136
+ return response.json()
3137
+
3107
3138
 
3108
3139
  def api() -> WizataDSAPIClient:
3109
3140
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wizata_dsapi
3
- Version: 2.2.0.dev20
3
+ Version: 2.2.0.dev21
4
4
  Summary: Wizata Data Science Toolkit
5
5
  Author: Wizata S.A.
6
6
  Author-email: info@wizata.com
@@ -1,11 +1,13 @@
1
- wizata_dsapi/__init__.py,sha256=ASXZxRPiRNt0-QspmCMZKBpqLdVaSKgggmVlbPH2NoU,4219
1
+ wizata_dsapi/__init__.py,sha256=I6R8Q2VGjMTZvV5ZiM1hkpQi1DyP4I04e95ljSjZ9SE,4516
2
2
  wizata_dsapi/api_config.py,sha256=zOgvpkB3FE2ny_2vAui7B1hcAMFCLvfb2ep3m-b7dIw,8270
3
3
  wizata_dsapi/api_dto.py,sha256=F9HjNa_vqYhdEMZwwk8E3SmCxuMBcxFnMp1eE9dxLvg,2910
4
4
  wizata_dsapi/api_interface.py,sha256=mCq0-rlwWL4GKB2QnxSPuyTecYOXXknmPOlQe_SEf2I,14670
5
5
  wizata_dsapi/bucket.py,sha256=Zz9olv-pymikAutGitSuGWrAPiawOTW86JDDHG4ugTc,1150
6
6
  wizata_dsapi/business_label.py,sha256=u0TVfUNfoR9qSv8lzpf6rNjlg3G9xTiz6itefcKfeak,4151
7
- wizata_dsapi/connection.py,sha256=C1VcA1oFqRLtiJN8jHkwlJobH5Lh2OCohEN_2mLP41c,12200
8
- wizata_dsapi/connection_types.py,sha256=nEQRp5-hGKGmOfZfKqocM-THuw3nQYJIqrX2BMK-mUs,13986
7
+ wizata_dsapi/connection.py,sha256=sngpF2rfcR1N5IT9wqccv07IwIDUM85k_6vnNjzThGE,17027
8
+ wizata_dsapi/connection_discovery.py,sha256=1CHEFQNE_IGtbbnyAfToZuXmP77PIjJJq_ASRW2mcpg,9055
9
+ wizata_dsapi/connection_policy.py,sha256=oU1yukVMdCZGy4tvj0Ei3u00xBDJ_0ZEMQ7WVsocroE,8434
10
+ wizata_dsapi/connection_types.py,sha256=ffpauxzzRBg0k4j5Amy-I3zeBZ-Q6EnmyZ-6Jo-FQlI,18358
9
11
  wizata_dsapi/context.py,sha256=1t0wCzRnBEzch7fx7jG4Oj3Ezrjv9getJtCrqEpn-pc,13188
10
12
  wizata_dsapi/dashboard.py,sha256=NY4m-1EpMRoKDHem0EwsRF0QQzal3bvrm9gQv3BNizk,3841
11
13
  wizata_dsapi/dataframe_toolkit.py,sha256=7D8JrhsFSGXeO3mRAd2e2cLmdjbXRjk04IuaMG8F974,9078
@@ -48,10 +50,10 @@ wizata_dsapi/trigger.py,sha256=waOX2AhrLvFzNFRQ535j6iemgg_QJP2QsWGL2ipljFg,8848
48
50
  wizata_dsapi/twin.py,sha256=8AnrxSaL7dMrILXJ3-GGmmUz078j3qqwzDTe6i86xqA,12672
49
51
  wizata_dsapi/twinregistration.py,sha256=iXt55AVGAQNl0jmxa3ZVynPqlSsIunFf4sLfp-MWTw0,13244
50
52
  wizata_dsapi/user.py,sha256=-hchKAMZULu9P00RUHDqu_5AT4NI5BYOJd8XCBrt3eQ,1950
51
- wizata_dsapi/version.py,sha256=Ej92cnhM5g3Pwri9IPc8X2C0NhC-UawJM0AUXdnAxLM,28
53
+ wizata_dsapi/version.py,sha256=YrgaspNwsrXytFOHsMVGn4pLI4bQSL62FdMaB7nvDBs,28
52
54
  wizata_dsapi/wizard_function.py,sha256=RbM7W7Gf-6Rhp_1dU9DBYkHaciknGAGvuAndhAS_vyo,942
53
55
  wizata_dsapi/wizard_request.py,sha256=v6BaqKLKvTWmUSo0_gda9FabAQz5x_-GOH1Av50GzFo,3762
54
- wizata_dsapi/wizata_dsapi_client.py,sha256=uoeUsaob9CsR1VojBuyM5Yq0wqUVDkCKorrb8kE4uTg,130806
56
+ wizata_dsapi/wizata_dsapi_client.py,sha256=0dEdcHB0sUcIRktvvYe36DI8kEtqrvFnmikrIR4gWes,132510
55
57
  wizata_dsapi/words.py,sha256=tV8CqzCqODZCV7PgBxBF5exBxeF_ya9t5DiUy-cg6Sg,1535
56
58
  wizata_dsapi/models/__init__.py,sha256=O3Tusyhl8hFb-5QaEBY7DJISRB8Gf_Xbxxr-aFDR1_U,380
57
59
  wizata_dsapi/models/common.py,sha256=5BC3Dc6kMvqPGxHgoCuGVOynu_F1MWh7EJSzXIICSSg,27196
@@ -60,8 +62,8 @@ wizata_dsapi/plots/common.py,sha256=7F4WMBaddubnCpdSafgaI9U9Sz0Sngg0VREP_vnIMno,
60
62
  wizata_dsapi/plots/theme.py,sha256=D4sSgEwabXVxF3z1GV8DivXJbbY61KgxXAWfjUbNDAc,3594
61
63
  wizata_dsapi/scripts/__init__.py,sha256=9fRdTMuuXcqMkIsUPf5dwFsTWwBzKteQdAMVkyy2acQ,212
62
64
  wizata_dsapi/scripts/common.py,sha256=8LJK23Za0Yy4Dda1feKpKELav8806MH-hxFJYrklVOY,14758
63
- wizata_dsapi-2.2.0.dev20.dist-info/licenses/LICENSE.txt,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
64
- wizata_dsapi-2.2.0.dev20.dist-info/METADATA,sha256=xKiGgGaanscGQRqW-QYSjLGfGcqI-jq52HHKBJcFTpw,14038
65
- wizata_dsapi-2.2.0.dev20.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
66
- wizata_dsapi-2.2.0.dev20.dist-info/top_level.txt,sha256=-OeTJbEnh5DuWyTOHtvw0Dw3LRg3G27TNS6W4ZtfwPs,13
67
- wizata_dsapi-2.2.0.dev20.dist-info/RECORD,,
65
+ wizata_dsapi-2.2.0.dev21.dist-info/licenses/LICENSE.txt,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
66
+ wizata_dsapi-2.2.0.dev21.dist-info/METADATA,sha256=rv_Ua4W6BNoghD_NujFiFQ39BEu0CnIfN6AA6b1vWEI,14038
67
+ wizata_dsapi-2.2.0.dev21.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
68
+ wizata_dsapi-2.2.0.dev21.dist-info/top_level.txt,sha256=-OeTJbEnh5DuWyTOHtvw0Dw3LRg3G27TNS6W4ZtfwPs,13
69
+ wizata_dsapi-2.2.0.dev21.dist-info/RECORD,,