Skip to content

Commit 430d041

Browse files
authored
Merge pull request #6 from fa-yoshinobu/codex/slmp-q-runtime-ranges
Codex/slmp q runtime ranges
2 parents 840c28b + 35f091e commit 430d041

4 files changed

Lines changed: 313 additions & 8 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# G/HG Extended Device Coverage Report
2+
3+
- Date: 2026-04-30 20:42:10
4+
- Host: 192.168.250.100
5+
- Port: 1025
6+
- Transports: tcp
7+
- Series: iqr
8+
- Targets: SELF(network=0x00, station=0xFF, module_io=0x03FF, multidrop=0x00)
9+
- Devices: U0\G10, U0\G30
10+
- Point counts: 1, 4
11+
- Direct memory values: 0xFA
12+
- Mode: read_only
13+
- Restore enabled: yes
14+
- Preferred write base: 0x001E
15+
- Summary: OK=4, NG=0, SKIP=0
16+
17+
| Item | Status | Detail |
18+
|---|---|---|
19+
| SELF U0\G10 points=1 direct=0xFA | OK | device=U0\G10, points=1, before=[0x0000], mode=read_only |
20+
| SELF U0\G10 points=4 direct=0xFA | OK | device=U0\G10, points=4, before=[0x0000, 0x0000, 0x0000, 0x0000], mode=read_only |
21+
| SELF U0\G30 points=1 direct=0xFA | OK | device=U0\G30, points=1, before=[0x0000], mode=read_only |
22+
| SELF U0\G30 points=4 direct=0xFA | OK | device=U0\G30, points=4, before=[0x0000, 0x0000, 0x0000, 0x0000], mode=read_only |
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Mixed Block Comparison Report
2+
3+
- Date: 2026-04-30 20:42:30
4+
- Host: 192.168.250.100
5+
- Port: 1025
6+
- Transport: tcp
7+
- Series: iqr
8+
- Model: R08CPU
9+
- Target: network=0x00, station=0xFF, module_io=0x03FF, multidrop=0x00
10+
- Word block: D300 x2 -> [0x87F7, 0x80BE]
11+
- Bit block: M200 x1 packed -> [0x6DFE]
12+
- Mixed write options: split_mixed_blocks=False, retry_mixed_on_error=True
13+
- Keep written value: False
14+
- First-pass comparison recommendation: keep both mixed-write fallback options disabled so the first PLC response is preserved
15+
- Note: if retry_mixed_on_error=True triggers an internal retry, the reported memory-changed state is the post-call state, not an observation between the first failed request and the retry
16+
17+
| Scenario | Status | End codes | Memory changed | Trace count | Notes |
18+
|---|---|---|---|---|---|
19+
| readBlock words+bits | OK | 0x0000 | n/a | 1 | words=[0x0000, 0x0000], bits=[0x0000] |
20+
| writeBlock words only | OK | 0x0000 | yes | 1 | after=[0x87F7, 0x80BE], restore=OK |
21+
| writeBlock bits only | OK | 0x0000 | yes | 1 | after=[0x6DFE], restore=OK |
22+
| writeBlock mixed | OK | 0xC05B -> 0x0000 -> 0x0000 | yes | 3 | after_words=[0x87F7, 0x80BE], after_bits=[0x6DFE], request_count=3 |
23+
24+
## readBlock words+bits
25+
26+
- API: read_block(word_blocks=[('D300', 2)], bit_blocks=[('M200', 1)], split_mixed_blocks=False)
27+
- Devices: word=D300 x2, bit=M200 x1 packed
28+
- Warnings: none
29+
- Error: none
30+
- Returned words: [0x0000, 0x0000]
31+
- Returned bits: [0x0000]
32+
33+
```text
34+
attempt 1: end_code=0x0000
35+
request: 54 00 03 00 00 00 00 FF FF 03 00 18 00 10 00 06 04 02 00 01 01 2C 01 00 00 A8 00 02 00 C8 00 00 00 90 00 01 00
36+
response: D4 00 03 00 00 00 00 FF FF 03 00 08 00 00 00 00 00 00 00 00 00
37+
```
38+
39+
## writeBlock words only
40+
41+
- API: write_block(word_blocks=[('D300', [0x87F7, 0x80BE])], bit_blocks=[])
42+
- Before words: [0x0000, 0x0000]
43+
- Test words: [0x87F7, 0x80BE]
44+
- After words: [0x87F7, 0x80BE]
45+
- Restore status: OK
46+
- Restored words: [0x0000, 0x0000]
47+
- Warnings: none
48+
- Error: none
49+
50+
```text
51+
attempt 1: end_code=0x0000
52+
request: 54 00 04 00 00 00 00 FF FF 03 00 14 00 10 00 06 14 02 00 01 00 2C 01 00 00 A8 00 02 00 F7 87 BE 80
53+
response: D4 00 04 00 00 00 00 FF FF 03 00 02 00 00 00
54+
```
55+
56+
## writeBlock bits only
57+
58+
- API: write_block(word_blocks=[], bit_blocks=[('M200', [0x6DFE])])
59+
- Before bits: [0x0000]
60+
- Test bits: [0x6DFE]
61+
- After bits: [0x6DFE]
62+
- Restore status: OK
63+
- Restored bits: [0x0000]
64+
- Warnings: none
65+
- Error: none
66+
67+
```text
68+
attempt 1: end_code=0x0000
69+
request: 54 00 08 00 00 00 00 FF FF 03 00 12 00 10 00 06 14 02 00 00 01 C8 00 00 00 90 00 01 00 FE 6D
70+
response: D4 00 08 00 00 00 00 FF FF 03 00 02 00 00 00
71+
```
72+
73+
## writeBlock mixed
74+
75+
- API: write_block(word_blocks=[('D300', [0x87F7, 0x80BE])], bit_blocks=[('M200', [0x6DFE])], split_mixed_blocks=False, retry_mixed_on_error=True)
76+
- Before words: [0x0000, 0x0000]
77+
- Before bits: [0x0000]
78+
- Test words: [0x87F7, 0x80BE]
79+
- Test bits: [0x6DFE]
80+
- After words: [0x87F7, 0x80BE]
81+
- After bits: [0x6DFE]
82+
- Request count: 3
83+
- Restore status: OK
84+
- Restored words: [0x0000, 0x0000]
85+
- Restored bits: [0x0000]
86+
- Warnings: mixed block write was rejected with 0xC05B; retrying as separate word-only and bit-only block writes
87+
- Error: none
88+
89+
```text
90+
attempt 1: end_code=0xC05B
91+
request: 54 00 0C 00 00 00 00 FF FF 03 00 1E 00 10 00 06 14 02 00 01 01 2C 01 00 00 A8 00 02 00 C8 00 00 00 90 00 01 00 F7 87 BE 80 FE 6D
92+
response: D4 00 0C 00 00 00 00 FF FF 03 00 0B 00 5B C0 00 FF FF 03 00 06 14 02 00
93+
94+
attempt 2: end_code=0x0000
95+
request: 54 00 0D 00 00 00 00 FF FF 03 00 14 00 10 00 06 14 02 00 01 00 2C 01 00 00 A8 00 02 00 F7 87 BE 80
96+
response: D4 00 0D 00 00 00 00 FF FF 03 00 02 00 00 00
97+
98+
attempt 3: end_code=0x0000
99+
request: 54 00 0E 00 00 00 00 FF FF 03 00 12 00 10 00 06 14 02 00 00 01 C8 00 00 00 90 00 01 00 FE 6D
100+
response: D4 00 0E 00 00 00 00 FF FF 03 00 02 00 00 00
101+
```

slmp/device_ranges.py

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from collections.abc import Mapping
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, replace
77
from enum import Enum
88
from typing import Any, cast
99

@@ -133,6 +133,14 @@ class _RangeProfile:
133133
"SD",
134134
)
135135

136+
_MAX_RUNTIME_RANGE_PROBE_COUNT = 1_048_576
137+
_ZR_RUNTIME_FAMILIES = {
138+
SlmpDeviceRangeFamily.QCpu,
139+
SlmpDeviceRangeFamily.LCpu,
140+
SlmpDeviceRangeFamily.QnU,
141+
SlmpDeviceRangeFamily.QnUDV,
142+
}
143+
136144
_ROWS: dict[str, _RangeRow] = {
137145
"X": _RangeRow(SlmpDeviceRangeCategory.Bit, (("X", True),), SlmpDeviceRangeNotation.Base16),
138146
"Y": _RangeRow(SlmpDeviceRangeCategory.Bit, (("Y", True),), SlmpDeviceRangeNotation.Base16),
@@ -411,7 +419,7 @@ def _undefined(notes: str) -> _RangeValueSpec:
411419
"LT": _unsupported("Not supported on LCPU."),
412420
"LST": _unsupported("Not supported on LCPU."),
413421
"LC": _unsupported("Not supported on LCPU."),
414-
"Z": _word(305, "SD305", "Requires ZZ = FFFFh for the reported upper bound."),
422+
"Z": _fixed(20, "Fixed family limit"),
415423
"LZ": _unsupported("Not supported on LCPU."),
416424
"ZR": _dword(306, "SD306-SD307 (32-bit)"),
417425
"RD": _unsupported("Not supported on LCPU."),
@@ -443,7 +451,7 @@ def _undefined(notes: str) -> _RangeValueSpec:
443451
"LT": _unsupported("Not supported on QnU."),
444452
"LST": _unsupported("Not supported on QnU."),
445453
"LC": _unsupported("Not supported on QnU."),
446-
"Z": _word(305, "SD305", "Requires ZZ = FFFFh for the reported upper bound."),
454+
"Z": _fixed(20, "Fixed family limit"),
447455
"LZ": _unsupported("Not supported on QnU."),
448456
"ZR": _dword(306, "SD306-SD307 (32-bit)"),
449457
"RD": _unsupported("Not supported on QnU."),
@@ -475,7 +483,7 @@ def _undefined(notes: str) -> _RangeValueSpec:
475483
"LT": _unsupported("Not supported on QnUDV."),
476484
"LST": _unsupported("Not supported on QnUDV."),
477485
"LC": _unsupported("Not supported on QnUDV."),
478-
"Z": _word(305, "SD305", "Requires ZZ = FFFFh for the reported upper bound."),
486+
"Z": _fixed(20, "Fixed family limit"),
479487
"LZ": _unsupported("Not supported on QnUDV."),
480488
"ZR": _dword(306, "SD306-SD307 (32-bit)"),
481489
"RD": _unsupported("Not supported on QnUDV."),
@@ -568,7 +576,8 @@ def read_device_range_catalog_for_family_sync(
568576
client.read_devices(DeviceRef("SD", profile.register_start), profile.register_count, bit_unit=False),
569577
)
570578
registers = {profile.register_start + index: int(value) for index, value in enumerate(words)}
571-
return build_device_range_catalog_for_family(normalized_family, registers)
579+
catalog = build_device_range_catalog_for_family(normalized_family, registers)
580+
return _resolve_runtime_limits_sync(client, catalog)
572581

573582

574583
async def read_device_range_catalog_for_family(
@@ -584,7 +593,164 @@ async def read_device_range_catalog_for_family(
584593
await client.read_devices(DeviceRef("SD", profile.register_start), profile.register_count, bit_unit=False),
585594
)
586595
registers = {profile.register_start + index: int(value) for index, value in enumerate(words)}
587-
return build_device_range_catalog_for_family(normalized_family, registers)
596+
catalog = build_device_range_catalog_for_family(normalized_family, registers)
597+
return await _resolve_runtime_limits_async(client, catalog)
598+
599+
600+
def _resolve_runtime_limits_sync(client: Any, catalog: SlmpDeviceRangeCatalog) -> SlmpDeviceRangeCatalog:
601+
if catalog.family not in _ZR_RUNTIME_FAMILIES:
602+
return catalog
603+
604+
if catalog.family is SlmpDeviceRangeFamily.QCpu:
605+
z_count = 16 if _can_read_one_word_sync(client, "Z15") else 10
606+
catalog = _replace_fixed_point_count(
607+
catalog,
608+
"Z",
609+
z_count,
610+
"Runtime access check",
611+
"QCPU Z register count is selected by probing Z15.",
612+
)
613+
614+
zr_count = _resolve_readable_point_count_sync(client, "ZR")
615+
catalog = _replace_fixed_point_count(
616+
catalog,
617+
"ZR",
618+
zr_count,
619+
"Runtime access check",
620+
"ZR register count is selected by probing readable ZR addresses.",
621+
)
622+
return _replace_fixed_point_count(
623+
catalog,
624+
"R",
625+
min(zr_count, 32768),
626+
"Runtime access check",
627+
"R register count follows the probed ZR count and is capped at R32767.",
628+
)
629+
630+
631+
async def _resolve_runtime_limits_async(client: Any, catalog: SlmpDeviceRangeCatalog) -> SlmpDeviceRangeCatalog:
632+
if catalog.family not in _ZR_RUNTIME_FAMILIES:
633+
return catalog
634+
635+
if catalog.family is SlmpDeviceRangeFamily.QCpu:
636+
z_count = 16 if await _can_read_one_word_async(client, "Z15") else 10
637+
catalog = _replace_fixed_point_count(
638+
catalog,
639+
"Z",
640+
z_count,
641+
"Runtime access check",
642+
"QCPU Z register count is selected by probing Z15.",
643+
)
644+
645+
zr_count = await _resolve_readable_point_count_async(client, "ZR")
646+
catalog = _replace_fixed_point_count(
647+
catalog,
648+
"ZR",
649+
zr_count,
650+
"Runtime access check",
651+
"ZR register count is selected by probing readable ZR addresses.",
652+
)
653+
return _replace_fixed_point_count(
654+
catalog,
655+
"R",
656+
min(zr_count, 32768),
657+
"Runtime access check",
658+
"R register count follows the probed ZR count and is capped at R32767.",
659+
)
660+
661+
662+
def _resolve_readable_point_count_sync(client: Any, device: str) -> int:
663+
if not _can_read_one_word_sync(client, f"{device}0"):
664+
return 0
665+
666+
upper_limit = _MAX_RUNTIME_RANGE_PROBE_COUNT - 1
667+
low = 0
668+
high = 1
669+
while high < upper_limit and _can_read_one_word_sync(client, f"{device}{high}"):
670+
low = high
671+
high = min(upper_limit, (high * 2) + 1)
672+
673+
if high == upper_limit and _can_read_one_word_sync(client, f"{device}{high}"):
674+
return _MAX_RUNTIME_RANGE_PROBE_COUNT
675+
676+
left = low + 1
677+
right = high - 1
678+
while left <= right:
679+
mid = left + ((right - left) // 2)
680+
if _can_read_one_word_sync(client, f"{device}{mid}"):
681+
low = mid
682+
left = mid + 1
683+
else:
684+
right = mid - 1
685+
686+
return low + 1
687+
688+
689+
async def _resolve_readable_point_count_async(client: Any, device: str) -> int:
690+
if not await _can_read_one_word_async(client, f"{device}0"):
691+
return 0
692+
693+
upper_limit = _MAX_RUNTIME_RANGE_PROBE_COUNT - 1
694+
low = 0
695+
high = 1
696+
while high < upper_limit and await _can_read_one_word_async(client, f"{device}{high}"):
697+
low = high
698+
high = min(upper_limit, (high * 2) + 1)
699+
700+
if high == upper_limit and await _can_read_one_word_async(client, f"{device}{high}"):
701+
return _MAX_RUNTIME_RANGE_PROBE_COUNT
702+
703+
left = low + 1
704+
right = high - 1
705+
while left <= right:
706+
mid = left + ((right - left) // 2)
707+
if await _can_read_one_word_async(client, f"{device}{mid}"):
708+
low = mid
709+
left = mid + 1
710+
else:
711+
right = mid - 1
712+
713+
return low + 1
714+
715+
716+
def _can_read_one_word_sync(client: Any, address: str) -> bool:
717+
try:
718+
client.read_devices(address, 1, bit_unit=False)
719+
return True
720+
except SlmpError:
721+
return False
722+
723+
724+
async def _can_read_one_word_async(client: Any, address: str) -> bool:
725+
try:
726+
await client.read_devices(address, 1, bit_unit=False)
727+
return True
728+
except SlmpError:
729+
return False
730+
731+
732+
def _replace_fixed_point_count(
733+
catalog: SlmpDeviceRangeCatalog,
734+
device: str,
735+
point_count: int,
736+
source: str,
737+
note: str,
738+
) -> SlmpDeviceRangeCatalog:
739+
upper_bound = None if point_count <= 0 else point_count - 1
740+
entries = [
741+
replace(
742+
entry,
743+
upper_bound=upper_bound,
744+
point_count=point_count,
745+
address_range=_format_address_range(entry.device, entry.notation, upper_bound),
746+
source=source,
747+
notes=note if not entry.notes else f"{entry.notes} {note}",
748+
)
749+
if entry.device == device
750+
else entry
751+
for entry in catalog.entries
752+
]
753+
return replace(catalog, entries=entries)
588754

589755

590756
def _evaluate_point_count(spec: _RangeValueSpec, registers: Mapping[int, int]) -> int | None:

0 commit comments

Comments
 (0)