slmp-connect-python 0.1.4__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.
slmp/utils.py ADDED
@@ -0,0 +1,893 @@
1
+ """High-level utility helpers for the SLMP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import struct
7
+ import time
8
+ from collections.abc import AsyncIterator, Iterator
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ from .constants import DEVICE_CODES, DeviceUnit
13
+ from .core import DeviceRef, parse_device
14
+
15
+ if TYPE_CHECKING:
16
+ from .async_client import AsyncSlmpClient
17
+ from .client import SlmpClient
18
+
19
+
20
+ _WORD_DTYPES = frozenset({"U", "S"})
21
+ _DWORD_DTYPES = frozenset({"D", "L", "F"})
22
+ _UNBATCHED_DEVICE_CODES = frozenset({"G", "HG"})
23
+ _DEFAULT_DWORD_DEVICE_CODES = frozenset({"LTN", "LSTN", "LCN"})
24
+ _LONG_TIMER_READ_FAMILIES: dict[str, tuple[str, str]] = {
25
+ "LTN": ("LTN", "current"),
26
+ "LTS": ("LTN", "contact"),
27
+ "LTC": ("LTN", "coil"),
28
+ "LSTN": ("LSTN", "current"),
29
+ "LSTS": ("LSTN", "contact"),
30
+ "LSTC": ("LSTN", "coil"),
31
+ "LCN": ("LCN", "current"),
32
+ "LCS": ("LCN", "contact"),
33
+ "LCC": ("LCN", "coil"),
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _ReadPlanEntry:
39
+ address: str
40
+ device: DeviceRef
41
+ dtype: str
42
+ bit_index: int | None
43
+ batch_kind: str | None
44
+ long_timer_read: tuple[str, str] | None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _ReadPlan:
49
+ entries: tuple[_ReadPlanEntry, ...]
50
+ word_devices: tuple[DeviceRef, ...]
51
+ dword_devices: tuple[DeviceRef, ...]
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Typed single-device read / write (async)
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ async def read_typed(
60
+ client: AsyncSlmpClient,
61
+ device: str | DeviceRef,
62
+ dtype: str,
63
+ ) -> int | float:
64
+ """Read one logical value and convert it to a Python scalar.
65
+
66
+ Args:
67
+ client: Connected high-level or raw async SLMP client.
68
+ device: Starting device address as a string such as ``"D100"`` or as
69
+ a parsed :class:`DeviceRef`.
70
+ dtype: Application type code. Supported values are ``"BIT"``,
71
+ ``"U"``, ``"S"``, ``"D"``, ``"L"``, and ``"F"``.
72
+
73
+ Returns:
74
+ ``bool`` for ``BIT``, otherwise ``int`` or ``float``.
75
+ """
76
+ ref = parse_device(device) if isinstance(device, str) else device
77
+ key = dtype.upper()
78
+ long_read = _get_long_timer_read(ref)
79
+ if long_read is not None:
80
+ return await _read_long_family_value(client, ref, key, long_read)
81
+ if key == "BIT":
82
+ values = await client.read_devices(ref, 1, bit_unit=True)
83
+ return bool(values[0])
84
+ if key in ("D", "L", "F"):
85
+ words = await client.read_devices(ref, 2, bit_unit=False)
86
+ raw = struct.pack("<HH", words[0], words[1])
87
+ if key == "F":
88
+ return cast(float, struct.unpack("<f", raw)[0])
89
+ elif key == "L":
90
+ return cast(int, struct.unpack("<i", raw)[0])
91
+ else:
92
+ return cast(int, struct.unpack("<I", raw)[0])
93
+ else:
94
+ words = await client.read_devices(ref, 1, bit_unit=False)
95
+ if key == "S":
96
+ return cast(int, struct.unpack("<h", struct.pack("<H", words[0]))[0])
97
+ return int(words[0])
98
+
99
+
100
+ async def write_typed(
101
+ client: AsyncSlmpClient,
102
+ device: str | DeviceRef,
103
+ dtype: str,
104
+ value: int | float,
105
+ ) -> None:
106
+ """Write one logical value using the requested application type.
107
+
108
+ Args:
109
+ client: Connected high-level or raw async SLMP client.
110
+ device: Starting device address.
111
+ dtype: Type code accepted by :func:`read_typed`.
112
+ value: Application value to encode and write.
113
+ """
114
+ key = dtype.upper()
115
+ if key == "BIT":
116
+ await client.write_devices(device, [bool(value)], bit_unit=True)
117
+ return
118
+ if key == "F":
119
+ raw = struct.pack("<f", float(value))
120
+ elif key == "L":
121
+ raw = struct.pack("<i", int(value))
122
+ elif key == "D":
123
+ raw = struct.pack("<I", int(value))
124
+ else:
125
+ await client.write_devices(device, [int(value) & 0xFFFF], bit_unit=False)
126
+ return
127
+ words = list(struct.unpack("<HH", raw))
128
+ await client.write_devices(device, words, bit_unit=False)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Typed single-device read / write (sync)
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def read_typed_sync(
137
+ client: SlmpClient,
138
+ device: str | DeviceRef,
139
+ dtype: str,
140
+ ) -> int | float:
141
+ """Synchronously read one logical value as a Python scalar."""
142
+ ref = parse_device(device) if isinstance(device, str) else device
143
+ key = dtype.upper()
144
+ long_read = _get_long_timer_read(ref)
145
+ if long_read is not None:
146
+ return _read_long_family_value_sync(client, ref, key, long_read)
147
+ if key == "BIT":
148
+ values = client.read_devices(ref, 1, bit_unit=True)
149
+ return bool(values[0])
150
+ if key in ("D", "L", "F"):
151
+ words = client.read_devices(ref, 2, bit_unit=False)
152
+ raw = struct.pack("<HH", words[0], words[1])
153
+ if key == "F":
154
+ return cast(float, struct.unpack("<f", raw)[0])
155
+ elif key == "L":
156
+ return cast(int, struct.unpack("<i", raw)[0])
157
+ else:
158
+ return cast(int, struct.unpack("<I", raw)[0])
159
+ else:
160
+ words = client.read_devices(ref, 1, bit_unit=False)
161
+ if key == "S":
162
+ return cast(int, struct.unpack("<h", struct.pack("<H", words[0]))[0])
163
+ return int(words[0])
164
+
165
+
166
+ def write_typed_sync(
167
+ client: SlmpClient,
168
+ device: str | DeviceRef,
169
+ dtype: str,
170
+ value: int | float,
171
+ ) -> None:
172
+ """Synchronously write one logical value using the requested type."""
173
+ key = dtype.upper()
174
+ if key == "BIT":
175
+ client.write_devices(device, [bool(value)], bit_unit=True)
176
+ return
177
+ if key == "F":
178
+ raw = struct.pack("<f", float(value))
179
+ elif key == "L":
180
+ raw = struct.pack("<i", int(value))
181
+ elif key == "D":
182
+ raw = struct.pack("<I", int(value))
183
+ else:
184
+ client.write_devices(device, [int(value) & 0xFFFF], bit_unit=False)
185
+ return
186
+ words = list(struct.unpack("<HH", raw))
187
+ client.write_devices(device, words, bit_unit=False)
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Bit-in-word (async + sync)
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ async def write_bit_in_word(
196
+ client: AsyncSlmpClient,
197
+ device: str | DeviceRef,
198
+ bit_index: int,
199
+ value: bool,
200
+ ) -> None:
201
+ """Set or clear one bit inside one word device.
202
+
203
+ This helper is only for word devices such as ``D50``. Direct bit devices
204
+ such as ``M1000`` should be written with :func:`write_typed` using
205
+ ``"BIT"``.
206
+ """
207
+ if not 0 <= bit_index <= 15:
208
+ raise ValueError(f"bit_index must be 0-15, got {bit_index}")
209
+ words = await client.read_devices(device, 1, bit_unit=False)
210
+ current = int(words[0])
211
+ if value:
212
+ current |= 1 << bit_index
213
+ else:
214
+ current &= ~(1 << bit_index)
215
+ await client.write_devices(device, [current & 0xFFFF], bit_unit=False)
216
+
217
+
218
+ def write_bit_in_word_sync(
219
+ client: SlmpClient,
220
+ device: str | DeviceRef,
221
+ bit_index: int,
222
+ value: bool,
223
+ ) -> None:
224
+ """Synchronously set or clear one bit inside one word device."""
225
+ if not 0 <= bit_index <= 15:
226
+ raise ValueError(f"bit_index must be 0-15, got {bit_index}")
227
+ words = client.read_devices(device, 1, bit_unit=False)
228
+ current = int(words[0])
229
+ if value:
230
+ current |= 1 << bit_index
231
+ else:
232
+ current &= ~(1 << bit_index)
233
+ client.write_devices(device, [current & 0xFFFF], bit_unit=False)
234
+
235
+
236
+ async def read_bits(
237
+ client: AsyncSlmpClient,
238
+ device: str | DeviceRef,
239
+ count: int,
240
+ ) -> list[bool]:
241
+ """Read a contiguous bit-device range as booleans."""
242
+ return [bool(v) for v in await client.read_devices(device, count, bit_unit=True)]
243
+
244
+
245
+ def read_bits_sync(
246
+ client: SlmpClient,
247
+ device: str | DeviceRef,
248
+ count: int,
249
+ ) -> list[bool]:
250
+ """Synchronously read a contiguous bit-device range as booleans."""
251
+ return [bool(v) for v in client.read_devices(device, count, bit_unit=True)]
252
+
253
+
254
+ async def write_bits(
255
+ client: AsyncSlmpClient,
256
+ device: str | DeviceRef,
257
+ values: list[bool],
258
+ ) -> None:
259
+ """Write a contiguous bit-device range from booleans."""
260
+ await client.write_devices(device, [bool(v) for v in values], bit_unit=True)
261
+
262
+
263
+ def write_bits_sync(
264
+ client: SlmpClient,
265
+ device: str | DeviceRef,
266
+ values: list[bool],
267
+ ) -> None:
268
+ """Synchronously write a contiguous bit-device range from booleans."""
269
+ client.write_devices(device, [bool(v) for v in values], bit_unit=True)
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Named-device read (async + sync)
274
+ # ---------------------------------------------------------------------------
275
+
276
+
277
+ async def read_named(
278
+ client: AsyncSlmpClient,
279
+ addresses: list[str],
280
+ ) -> dict[str, int | float | bool]:
281
+ """Read a mixed logical snapshot by address string.
282
+
283
+ Args:
284
+ client: Connected async SLMP client.
285
+ addresses: Address list such as ``"D100"``, ``"D200:F"``,
286
+ ``"D300:L"``, ``"D50.3"``, or direct bit devices like ``"M1000"``.
287
+
288
+ Returns:
289
+ A dictionary keyed by the original address strings.
290
+
291
+ Notes:
292
+ The address list is compiled once, then grouped into random reads where
293
+ possible. Use ``.bit`` notation only with word devices.
294
+ """
295
+ plan = _compile_read_plan(addresses)
296
+ return await _read_named_with_plan(client, plan)
297
+
298
+
299
+ def read_named_sync(
300
+ client: SlmpClient,
301
+ addresses: list[str],
302
+ ) -> dict[str, int | float | bool]:
303
+ """Synchronously read a mixed logical snapshot by address string."""
304
+ plan = _compile_read_plan(addresses)
305
+ return _read_named_with_plan_sync(client, plan)
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Named-device write (async + sync)
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ async def write_named(
314
+ client: AsyncSlmpClient,
315
+ updates: dict[str, int | float | bool],
316
+ ) -> None:
317
+ """Write a mixed logical snapshot by address string.
318
+
319
+ ``D50.3`` updates one bit inside one word. Direct bit devices such as
320
+ ``M1000`` are normalized to ``"BIT"`` writes.
321
+ """
322
+ for address, value in updates.items():
323
+ base, dtype, bit_idx = _parse_address(address)
324
+ if dtype == "BIT_IN_WORD":
325
+ _validate_bit_in_word_target(address, parse_device(base))
326
+ await write_bit_in_word(client, base, bit_idx or 0, bool(value))
327
+ else:
328
+ device = parse_device(base)
329
+ resolved_dtype = _resolve_dtype_for_address(address, device, dtype, bit_idx)
330
+ _validate_long_timer_entry(address, device, resolved_dtype)
331
+ await write_typed(client, base, resolved_dtype, value)
332
+
333
+
334
+ def write_named_sync(
335
+ client: SlmpClient,
336
+ updates: dict[str, int | float | bool],
337
+ ) -> None:
338
+ """Synchronously write a mixed logical snapshot by address string."""
339
+ for address, value in updates.items():
340
+ base, dtype, bit_idx = _parse_address(address)
341
+ if dtype == "BIT_IN_WORD":
342
+ _validate_bit_in_word_target(address, parse_device(base))
343
+ write_bit_in_word_sync(client, base, bit_idx or 0, bool(value))
344
+ else:
345
+ device = parse_device(base)
346
+ resolved_dtype = _resolve_dtype_for_address(address, device, dtype, bit_idx)
347
+ _validate_long_timer_entry(address, device, resolved_dtype)
348
+ write_typed_sync(client, base, resolved_dtype, value)
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Address parser (shared)
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ def _parse_address(address: str) -> tuple[str, str, int | None]:
357
+ """Parse extended address notation.
358
+
359
+ Returns (base_device, dtype, bit_index).
360
+ """
361
+ if ":" in address:
362
+ base, dtype = address.split(":", 1)
363
+ return base.strip(), dtype.strip().upper(), None
364
+ if "." in address:
365
+ base, bit_str = address.split(".", 1)
366
+ try:
367
+ return base.strip(), "BIT_IN_WORD", int(bit_str, 16)
368
+ except ValueError:
369
+ pass
370
+ return address.strip(), "U", None
371
+
372
+
373
+ def _is_batchable_word_device(device: DeviceRef) -> bool:
374
+ code = DEVICE_CODES.get(device.code)
375
+ return code is not None and code.unit == DeviceUnit.WORD and device.code not in _UNBATCHED_DEVICE_CODES
376
+
377
+
378
+ def _address_has_explicit_dtype(address: str) -> bool:
379
+ return ":" in address
380
+
381
+
382
+ def _normalize_dtype_for_device(device: DeviceRef, dtype: str) -> str:
383
+ code = DEVICE_CODES.get(device.code)
384
+ if code is not None and code.unit == DeviceUnit.BIT and dtype == "U":
385
+ return "BIT"
386
+ return dtype
387
+
388
+
389
+ def _resolve_dtype_for_address(address: str, device: DeviceRef, dtype: str, bit_index: int | None) -> str:
390
+ normalized = _normalize_dtype_for_device(device, dtype or "U")
391
+ if not _address_has_explicit_dtype(address) and bit_index is None and device.code in _DEFAULT_DWORD_DEVICE_CODES:
392
+ return "D"
393
+ return normalized
394
+
395
+
396
+ def _get_long_timer_read(device: DeviceRef) -> tuple[str, str] | None:
397
+ return _LONG_TIMER_READ_FAMILIES.get(device.code)
398
+
399
+
400
+ def _validate_long_timer_entry(address: str, device: DeviceRef, dtype: str) -> None:
401
+ long_read = _get_long_timer_read(device)
402
+ if long_read is None:
403
+ return
404
+ _, role = long_read
405
+ if role == "current":
406
+ if dtype not in {"D", "L"}:
407
+ raise ValueError(
408
+ f"Address '{address}' uses a 32-bit long current value. Use the plain form or ':D' / ':L'."
409
+ )
410
+ return
411
+ if dtype != "BIT":
412
+ raise ValueError(
413
+ f"Address '{address}' is a long timer state device. Use the plain device form without a dtype override."
414
+ )
415
+
416
+
417
+ def _validate_bit_in_word_target(address: str, device: DeviceRef) -> None:
418
+ code = DEVICE_CODES.get(device.code)
419
+ if code is None or code.unit != DeviceUnit.WORD:
420
+ raise ValueError(
421
+ f"Address '{address}' uses '.bit' notation, which is only valid for word devices. "
422
+ "Address bit devices directly, for example 'M1000' instead of 'M1000.0'."
423
+ )
424
+
425
+
426
+ def _coerce_long_current_value(current_value: int, dtype: str) -> int:
427
+ if dtype == "L":
428
+ return cast(int, struct.unpack("<i", struct.pack("<I", int(current_value) & 0xFFFFFFFF))[0])
429
+ return int(current_value)
430
+
431
+
432
+ def _decode_long_family_words(words: list[int]) -> tuple[int, bool, bool]:
433
+ current_value = int(words[0]) | (int(words[1]) << 16)
434
+ status_word = int(words[2]) & 0xFFFF
435
+ return current_value, bool(status_word & 0x0002), bool(status_word & 0x0001)
436
+
437
+
438
+ async def _read_long_family_point(
439
+ client: AsyncSlmpClient,
440
+ prefix: str,
441
+ head_no: int,
442
+ ) -> tuple[int, bool, bool]:
443
+ if prefix == "LTN":
444
+ timer = (await client.read_long_timer(head_no=head_no, points=1))[0]
445
+ return int(timer.current_value), bool(timer.contact), bool(timer.coil)
446
+ if prefix == "LSTN":
447
+ timer = (await client.read_long_retentive_timer(head_no=head_no, points=1))[0]
448
+ return int(timer.current_value), bool(timer.contact), bool(timer.coil)
449
+ words = await client.read_devices(DeviceRef("LCN", head_no), 4, bit_unit=False)
450
+ return _decode_long_family_words(list(words))
451
+
452
+
453
+ def _read_long_family_point_sync(
454
+ client: SlmpClient,
455
+ prefix: str,
456
+ head_no: int,
457
+ ) -> tuple[int, bool, bool]:
458
+ if prefix == "LTN":
459
+ timer = client.read_long_timer(head_no=head_no, points=1)[0]
460
+ return int(timer.current_value), bool(timer.contact), bool(timer.coil)
461
+ if prefix == "LSTN":
462
+ timer = client.read_long_retentive_timer(head_no=head_no, points=1)[0]
463
+ return int(timer.current_value), bool(timer.contact), bool(timer.coil)
464
+ words = client.read_devices(DeviceRef("LCN", head_no), 4, bit_unit=False)
465
+ return _decode_long_family_words(list(words))
466
+
467
+
468
+ async def _read_long_family_value(
469
+ client: AsyncSlmpClient,
470
+ device: DeviceRef,
471
+ dtype: str,
472
+ long_read: tuple[str, str],
473
+ ) -> int | bool:
474
+ prefix, role = long_read
475
+ current_value, contact, coil = await _read_long_family_point(client, prefix, device.number)
476
+ if role == "current":
477
+ return _coerce_long_current_value(current_value, dtype)
478
+ if role == "contact":
479
+ return contact
480
+ return coil
481
+
482
+
483
+ def _read_long_family_value_sync(
484
+ client: SlmpClient,
485
+ device: DeviceRef,
486
+ dtype: str,
487
+ long_read: tuple[str, str],
488
+ ) -> int | bool:
489
+ prefix, role = long_read
490
+ current_value, contact, coil = _read_long_family_point_sync(client, prefix, device.number)
491
+ if role == "current":
492
+ return _coerce_long_current_value(current_value, dtype)
493
+ if role == "contact":
494
+ return contact
495
+ return coil
496
+
497
+
498
+ def _compile_read_plan(addresses: list[str]) -> _ReadPlan:
499
+ entries: list[_ReadPlanEntry] = []
500
+ word_devices: list[DeviceRef] = []
501
+ dword_devices: list[DeviceRef] = []
502
+ seen_words: set[DeviceRef] = set()
503
+ seen_dwords: set[DeviceRef] = set()
504
+
505
+ for address in addresses:
506
+ base, dtype, bit_index = _parse_address(address)
507
+ device = parse_device(base)
508
+ dtype = _resolve_dtype_for_address(address, device, dtype, bit_index)
509
+ _validate_long_timer_entry(address, device, dtype)
510
+ batch_kind: str | None = None
511
+ long_timer_read = _get_long_timer_read(device)
512
+
513
+ if long_timer_read is not None:
514
+ batch_kind = "LONG_TIMER"
515
+ elif dtype == "BIT_IN_WORD":
516
+ _validate_bit_in_word_target(address, device)
517
+ if _is_batchable_word_device(device):
518
+ batch_kind = "WORD"
519
+ if device not in seen_words:
520
+ word_devices.append(device)
521
+ seen_words.add(device)
522
+ elif dtype in _WORD_DTYPES:
523
+ if _is_batchable_word_device(device):
524
+ batch_kind = "WORD"
525
+ if device not in seen_words:
526
+ word_devices.append(device)
527
+ seen_words.add(device)
528
+ elif dtype in _DWORD_DTYPES:
529
+ if _is_batchable_word_device(device):
530
+ batch_kind = "DWORD"
531
+ if device not in seen_dwords:
532
+ dword_devices.append(device)
533
+ seen_dwords.add(device)
534
+
535
+ entries.append(_ReadPlanEntry(address, device, dtype, bit_index, batch_kind, long_timer_read))
536
+
537
+ return _ReadPlan(tuple(entries), tuple(word_devices), tuple(dword_devices))
538
+
539
+
540
+ def _decode_word_value(value: int, dtype: str) -> int:
541
+ if dtype == "S":
542
+ return cast(int, struct.unpack("<h", struct.pack("<H", value & 0xFFFF))[0])
543
+ return int(value)
544
+
545
+
546
+ def _decode_dword_value(value: int, dtype: str) -> int | float:
547
+ raw = struct.pack("<I", value & 0xFFFFFFFF)
548
+ if dtype == "F":
549
+ return cast(float, struct.unpack("<f", raw)[0])
550
+ if dtype == "L":
551
+ return cast(int, struct.unpack("<i", raw)[0])
552
+ return int(value)
553
+
554
+
555
+ async def _read_random_maps(
556
+ client: AsyncSlmpClient,
557
+ plan: _ReadPlan,
558
+ ) -> tuple[dict[str, int], dict[str, int]]:
559
+ word_values: dict[str, int] = {}
560
+ dword_values: dict[str, int] = {}
561
+ word_devices = list(plan.word_devices)
562
+ dword_devices = list(plan.dword_devices)
563
+ word_index = 0
564
+ dword_index = 0
565
+
566
+ while word_index < len(word_devices) or dword_index < len(dword_devices):
567
+ word_chunk = word_devices[word_index : word_index + 0xFF]
568
+ dword_chunk = dword_devices[dword_index : dword_index + 0xFF]
569
+ word_index += len(word_chunk)
570
+ dword_index += len(dword_chunk)
571
+ if not word_chunk and not dword_chunk:
572
+ break
573
+ result = await client.read_random(word_devices=word_chunk, dword_devices=dword_chunk)
574
+ word_values.update(result.word)
575
+ dword_values.update(result.dword)
576
+
577
+ return word_values, dword_values
578
+
579
+
580
+ def _read_random_maps_sync(
581
+ client: SlmpClient,
582
+ plan: _ReadPlan,
583
+ ) -> tuple[dict[str, int], dict[str, int]]:
584
+ word_values: dict[str, int] = {}
585
+ dword_values: dict[str, int] = {}
586
+ word_devices = list(plan.word_devices)
587
+ dword_devices = list(plan.dword_devices)
588
+ word_index = 0
589
+ dword_index = 0
590
+
591
+ while word_index < len(word_devices) or dword_index < len(dword_devices):
592
+ word_chunk = word_devices[word_index : word_index + 0xFF]
593
+ dword_chunk = dword_devices[dword_index : dword_index + 0xFF]
594
+ word_index += len(word_chunk)
595
+ dword_index += len(dword_chunk)
596
+ if not word_chunk and not dword_chunk:
597
+ break
598
+ result = client.read_random(word_devices=word_chunk, dword_devices=dword_chunk)
599
+ word_values.update(result.word)
600
+ dword_values.update(result.dword)
601
+
602
+ return word_values, dword_values
603
+
604
+
605
+ async def _read_named_with_plan(
606
+ client: AsyncSlmpClient,
607
+ plan: _ReadPlan,
608
+ ) -> dict[str, int | float | bool]:
609
+ result: dict[str, int | float | bool] = {}
610
+ word_values, dword_values = await _read_random_maps(client, plan)
611
+ long_timer_cache: dict[tuple[str, int], Any] = {}
612
+
613
+ for entry in plan.entries:
614
+ if entry.batch_kind == "LONG_TIMER":
615
+ assert entry.long_timer_read is not None
616
+ prefix, role = entry.long_timer_read
617
+ cache_key = (prefix, entry.device.number)
618
+ if cache_key not in long_timer_cache:
619
+ long_timer_cache[cache_key] = await _read_long_family_point(client, prefix, entry.device.number)
620
+ current_value, contact, coil = long_timer_cache[cache_key]
621
+ if role == "current":
622
+ result[entry.address] = _coerce_long_current_value(current_value, entry.dtype)
623
+ elif role == "contact":
624
+ result[entry.address] = bool(contact)
625
+ else:
626
+ result[entry.address] = bool(coil)
627
+ continue
628
+ if entry.batch_kind == "WORD":
629
+ word = word_values[str(entry.device)]
630
+ if entry.dtype == "BIT_IN_WORD":
631
+ result[entry.address] = bool((word >> (entry.bit_index or 0)) & 1)
632
+ else:
633
+ result[entry.address] = _decode_word_value(word, entry.dtype)
634
+ continue
635
+ if entry.batch_kind == "DWORD":
636
+ result[entry.address] = _decode_dword_value(dword_values[str(entry.device)], entry.dtype)
637
+ continue
638
+ if entry.dtype == "BIT_IN_WORD":
639
+ words = await client.read_devices(entry.device, 1, bit_unit=False)
640
+ result[entry.address] = bool((words[0] >> (entry.bit_index or 0)) & 1)
641
+ else:
642
+ result[entry.address] = await read_typed(client, entry.device, entry.dtype or "U")
643
+
644
+ return result
645
+
646
+
647
+ def _read_named_with_plan_sync(
648
+ client: SlmpClient,
649
+ plan: _ReadPlan,
650
+ ) -> dict[str, int | float | bool]:
651
+ result: dict[str, int | float | bool] = {}
652
+ word_values, dword_values = _read_random_maps_sync(client, plan)
653
+ long_timer_cache: dict[tuple[str, int], Any] = {}
654
+
655
+ for entry in plan.entries:
656
+ if entry.batch_kind == "LONG_TIMER":
657
+ assert entry.long_timer_read is not None
658
+ prefix, role = entry.long_timer_read
659
+ cache_key = (prefix, entry.device.number)
660
+ if cache_key not in long_timer_cache:
661
+ long_timer_cache[cache_key] = _read_long_family_point_sync(client, prefix, entry.device.number)
662
+ current_value, contact, coil = long_timer_cache[cache_key]
663
+ if role == "current":
664
+ result[entry.address] = _coerce_long_current_value(current_value, entry.dtype)
665
+ elif role == "contact":
666
+ result[entry.address] = bool(contact)
667
+ else:
668
+ result[entry.address] = bool(coil)
669
+ continue
670
+ if entry.batch_kind == "WORD":
671
+ word = word_values[str(entry.device)]
672
+ if entry.dtype == "BIT_IN_WORD":
673
+ result[entry.address] = bool((word >> (entry.bit_index or 0)) & 1)
674
+ else:
675
+ result[entry.address] = _decode_word_value(word, entry.dtype)
676
+ continue
677
+ if entry.batch_kind == "DWORD":
678
+ result[entry.address] = _decode_dword_value(dword_values[str(entry.device)], entry.dtype)
679
+ continue
680
+ if entry.dtype == "BIT_IN_WORD":
681
+ words = client.read_devices(entry.device, 1, bit_unit=False)
682
+ result[entry.address] = bool((words[0] >> (entry.bit_index or 0)) & 1)
683
+ else:
684
+ result[entry.address] = read_typed_sync(client, entry.device, entry.dtype or "U")
685
+
686
+ return result
687
+
688
+
689
+ # ---------------------------------------------------------------------------
690
+ # Polling (async + sync)
691
+ # ---------------------------------------------------------------------------
692
+
693
+
694
+ async def poll(
695
+ client: AsyncSlmpClient,
696
+ addresses: list[str],
697
+ interval: float,
698
+ ) -> AsyncIterator[dict[str, int | float | bool]]:
699
+ """Continuously yield mixed snapshots at a fixed interval.
700
+
701
+ The address list is compiled once and reused for every cycle.
702
+ """
703
+ plan = _compile_read_plan(addresses)
704
+ while True:
705
+ yield await _read_named_with_plan(client, plan)
706
+ await asyncio.sleep(interval)
707
+
708
+
709
+ def poll_sync(
710
+ client: SlmpClient,
711
+ addresses: list[str],
712
+ interval: float,
713
+ ) -> Iterator[dict[str, int | float | bool]]:
714
+ """Synchronously yield mixed snapshots at a fixed interval."""
715
+ plan = _compile_read_plan(addresses)
716
+ while True:
717
+ yield _read_named_with_plan_sync(client, plan)
718
+ time.sleep(interval)
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Chunked reads (async)
723
+ # ---------------------------------------------------------------------------
724
+
725
+
726
+ async def read_words(
727
+ client: AsyncSlmpClient,
728
+ device: str | DeviceRef,
729
+ count: int,
730
+ max_per_request: int = 960,
731
+ *,
732
+ allow_split: bool = False,
733
+ ) -> list[int]:
734
+ """Read a contiguous word-device range with optional chunk splitting.
735
+
736
+ Chunk boundaries stay aligned to 2-word boundaries so 32-bit values are
737
+ not torn across split requests.
738
+ """
739
+ from .core import DeviceRef, parse_device
740
+
741
+ # Always use an even effective_max to keep DWord boundaries aligned.
742
+ effective_max = (max_per_request // 2) * 2
743
+ if effective_max <= 0:
744
+ raise ValueError("max_per_request must be at least 2")
745
+
746
+ if not allow_split:
747
+ if count > effective_max:
748
+ raise ValueError(
749
+ f"count {count} exceeds max_per_request {effective_max};"
750
+ " pass allow_split=True to split the read across multiple requests"
751
+ )
752
+ ref = parse_device(device) if isinstance(device, str) else device
753
+ return list(await client.read_devices(ref, count, bit_unit=False))
754
+
755
+ ref = parse_device(device) if isinstance(device, str) else device
756
+ result: list[int] = []
757
+ remaining = count
758
+ offset = 0
759
+ while remaining > 0:
760
+ chunk = min(remaining, effective_max)
761
+ chunk_ref = DeviceRef(ref.code, ref.number + offset)
762
+ words = await client.read_devices(chunk_ref, chunk, bit_unit=False)
763
+ result.extend(words)
764
+ offset += chunk
765
+ remaining -= chunk
766
+ return result
767
+
768
+
769
+ async def read_dwords(
770
+ client: AsyncSlmpClient,
771
+ device: str | DeviceRef,
772
+ count: int,
773
+ max_dwords_per_request: int = 480,
774
+ *,
775
+ allow_split: bool = False,
776
+ ) -> list[int]:
777
+ """Read a contiguous DWord range as unsigned 32-bit integers."""
778
+ words = await read_words(
779
+ client,
780
+ device,
781
+ count * 2,
782
+ max_per_request=max_dwords_per_request * 2,
783
+ allow_split=allow_split,
784
+ )
785
+ result: list[int] = []
786
+ for i in range(count):
787
+ raw = struct.pack("<HH", words[i * 2], words[i * 2 + 1])
788
+ result.append(struct.unpack("<I", raw)[0])
789
+ return result
790
+
791
+
792
+ # ---------------------------------------------------------------------------
793
+ # Chunked reads (sync)
794
+ # ---------------------------------------------------------------------------
795
+
796
+
797
+ def read_words_sync(
798
+ client: SlmpClient,
799
+ device: str | DeviceRef,
800
+ count: int,
801
+ max_per_request: int = 960,
802
+ *,
803
+ allow_split: bool = False,
804
+ ) -> list[int]:
805
+ """Synchronously read a contiguous word-device range."""
806
+ from .core import DeviceRef, parse_device
807
+
808
+ effective_max = (max_per_request // 2) * 2
809
+ if effective_max <= 0:
810
+ raise ValueError("max_per_request must be at least 2")
811
+
812
+ if not allow_split:
813
+ if count > effective_max:
814
+ raise ValueError(
815
+ f"count {count} exceeds max_per_request {effective_max};"
816
+ " pass allow_split=True to split the read across multiple requests"
817
+ )
818
+ ref = parse_device(device) if isinstance(device, str) else device
819
+ return list(client.read_devices(ref, count, bit_unit=False))
820
+
821
+ ref = parse_device(device) if isinstance(device, str) else device
822
+ result: list[int] = []
823
+ remaining = count
824
+ offset = 0
825
+ while remaining > 0:
826
+ chunk = min(remaining, effective_max)
827
+ chunk_ref = DeviceRef(ref.code, ref.number + offset)
828
+ words = client.read_devices(chunk_ref, chunk, bit_unit=False)
829
+ result.extend(words)
830
+ offset += chunk
831
+ remaining -= chunk
832
+ return result
833
+
834
+
835
+ def read_dwords_sync(
836
+ client: SlmpClient,
837
+ device: str | DeviceRef,
838
+ count: int,
839
+ max_dwords_per_request: int = 480,
840
+ *,
841
+ allow_split: bool = False,
842
+ ) -> list[int]:
843
+ """Synchronously read a contiguous DWord range."""
844
+ words = read_words_sync(
845
+ client,
846
+ device,
847
+ count * 2,
848
+ max_per_request=max_dwords_per_request * 2,
849
+ allow_split=allow_split,
850
+ )
851
+ result: list[int] = []
852
+ for i in range(count):
853
+ raw = struct.pack("<HH", words[i * 2], words[i * 2 + 1])
854
+ result.append(struct.unpack("<I", raw)[0])
855
+ return result
856
+
857
+
858
+ # ---------------------------------------------------------------------------
859
+ # Queued client
860
+ # ---------------------------------------------------------------------------
861
+
862
+
863
+ class QueuedAsyncSlmpClient:
864
+ """Serialize all async calls on one shared SLMP connection.
865
+
866
+ The wrapper exposes the same methods as :class:`AsyncSlmpClient`, but every
867
+ coroutine call is executed under one lock. Use it when one connection is
868
+ shared by polling, snapshot, and write tasks.
869
+ """
870
+
871
+ def __init__(self, inner: AsyncSlmpClient) -> None:
872
+ self._inner = inner
873
+ self._lock = asyncio.Lock()
874
+
875
+ def __getattr__(self, name: str) -> Any:
876
+ attr = getattr(self._inner, name)
877
+ if asyncio.iscoroutinefunction(attr):
878
+
879
+ async def _locked(*args: Any, **kwargs: Any) -> Any:
880
+ async with self._lock:
881
+ return await attr(*args, **kwargs)
882
+
883
+ return _locked
884
+ return attr
885
+
886
+ async def __aenter__(self) -> QueuedAsyncSlmpClient:
887
+ async with self._lock:
888
+ await self._inner.connect()
889
+ return self
890
+
891
+ async def __aexit__(self, *_: object) -> None:
892
+ async with self._lock:
893
+ await self._inner.close()