slmp-connect-python 0.1.14__tar.gz → 0.1.15__tar.gz

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.
Files changed (29) hide show
  1. {slmp_connect_python-0.1.14/slmp_connect_python.egg-info → slmp_connect_python-0.1.15}/PKG-INFO +8 -1
  2. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/README.md +23 -16
  3. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/pyproject.toml +1 -3
  4. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/cli.py +0 -772
  5. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/core.py +1 -1
  6. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15/slmp_connect_python.egg-info}/PKG-INFO +8 -1
  7. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp_connect_python.egg-info/entry_points.txt +0 -2
  8. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_slmp.py +13 -182
  9. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/LICENSE +0 -0
  10. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/setup.cfg +0 -0
  11. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/__init__.py +0 -0
  12. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/async_client.py +0 -0
  13. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/client.py +0 -0
  14. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/constants.py +0 -0
  15. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/device_ranges.py +0 -0
  16. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/errors.py +0 -0
  17. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/py.typed +0 -0
  18. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp/utils.py +0 -0
  19. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp_connect_python.egg-info/SOURCES.txt +0 -0
  20. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp_connect_python.egg-info/dependency_links.txt +0 -0
  21. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp_connect_python.egg-info/requires.txt +0 -0
  22. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/slmp_connect_python.egg-info/top_level.txt +0 -0
  23. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_async_client.py +0 -0
  24. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_bugs_and_edges.py +0 -0
  25. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_cpu_operation_state.py +0 -0
  26. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_device_ranges.py +0 -0
  27. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_device_vectors.py +0 -0
  28. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_shared_spec.py +0 -0
  29. {slmp_connect_python-0.1.14 → slmp_connect_python-0.1.15}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slmp-connect-python
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: SLMP Connect Python: client library for Mitsubishi SLMP binary communication
5
5
  Author: fa-yoshinobu
6
6
  License-Expression: MIT
@@ -43,6 +43,13 @@ Dynamic: license-file
43
43
 
44
44
  ![Illustration](https://raw.githubusercontent.com/fa-yoshinobu/plc-comm-slmp-python/main/docsrc/assets/melsec.png)
45
45
 
46
+ [![Automated Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml)
47
+ [![Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml)
48
+
49
+ [![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)](https://www.python.org/)
50
+ [![MkDocs](https://img.shields.io/badge/MkDocs-526CFE?logo=materialformkdocs&logoColor=white)](https://www.mkdocs.org/)
51
+ [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222222?logo=githubpages&logoColor=white)](https://pages.github.com/)
52
+
46
53
  High-level SLMP helpers for Mitsubishi PLC communication over Binary 3E and 4E frames.
47
54
 
48
55
  This repository treats the high-level helper layer as the recommended user surface:
@@ -9,6 +9,13 @@
9
9
 
10
10
  ![Illustration](https://raw.githubusercontent.com/fa-yoshinobu/plc-comm-slmp-python/main/docsrc/assets/melsec.png)
11
11
 
12
+ [![Automated Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml)
13
+ [![Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml)
14
+
15
+ [![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)](https://www.python.org/)
16
+ [![MkDocs](https://img.shields.io/badge/MkDocs-526CFE?logo=materialformkdocs&logoColor=white)](https://www.mkdocs.org/)
17
+ [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222222?logo=githubpages&logoColor=white)](https://pages.github.com/)
18
+
12
19
  High-level SLMP helpers for Mitsubishi PLC communication over Binary 3E and 4E frames.
13
20
 
14
21
  This repository treats the high-level helper layer as the recommended user surface:
@@ -16,11 +23,11 @@ This repository treats the high-level helper layer as the recommended user surfa
16
23
  - `SlmpConnectionOptions`
17
24
  - `open_and_connect` / `open_and_connect_sync`
18
25
  - `AsyncSlmpClient`
19
- - `QueuedAsyncSlmpClient`
20
- - `SlmpClient`
21
- - `normalize_address`
22
- - `parse_address` / `try_parse_address` / `format_address`
23
- - `read_typed` / `write_typed`
26
+ - `QueuedAsyncSlmpClient`
27
+ - `SlmpClient`
28
+ - `normalize_address`
29
+ - `parse_address` / `try_parse_address` / `format_address`
30
+ - `read_typed` / `write_typed`
24
31
  - `read_words_single_request` / `read_dwords_single_request`
25
32
  - `read_words_chunked` / `read_dwords_chunked`
26
33
  - `write_bit_in_word`
@@ -148,17 +155,17 @@ Maintainer-only notes and retained evidence live under `internal_docs/`.
148
155
 
149
156
  ### Address Normalization
150
157
 
151
- ```python
152
- from slmp import format_address, normalize_address, parse_address
153
-
154
- print(normalize_address("x20")) # X20
155
- print(normalize_address("d200")) # D200
156
- print(normalize_address("x100", plc_family="iq-f")) # X100
157
-
158
- parsed = parse_address("d200:f")
159
- print(parsed.base_device, parsed.dtype) # D200 F
160
- print(format_address(parsed)) # D200:F
161
- ```
158
+ ```python
159
+ from slmp import format_address, normalize_address, parse_address
160
+
161
+ print(normalize_address("x20")) # X20
162
+ print(normalize_address("d200")) # D200
163
+ print(normalize_address("x100", plc_family="iq-f")) # X100
164
+
165
+ parsed = parse_address("d200:f")
166
+ print(parsed.base_device, parsed.dtype) # D200 F
167
+ print(format_address(parsed)) # D200:F
168
+ ```
162
169
 
163
170
  ### Single Typed Values
164
171
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "slmp-connect-python"
7
- version = "0.1.14"
7
+ version = "0.1.15"
8
8
  description = "SLMP Connect Python: client library for Mitsubishi SLMP binary communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -46,8 +46,6 @@ dev = [
46
46
 
47
47
  [project.scripts]
48
48
  slmp-connection-check = "slmp.cli:connection_check_main"
49
- slmp-compatibility-probe = "slmp.cli:compatibility_probe_main"
50
- slmp-compatibility-matrix-render = "slmp.cli:compatibility_matrix_render_main"
51
49
  slmp-device-range-probe = "slmp.cli:device_range_probe_main"
52
50
  slmp-register-boundary-probe = "slmp.cli:register_boundary_probe_main"
53
51
  slmp-other-station-check = "slmp.cli:other_station_check_main"
@@ -270,63 +270,6 @@ def _project_root() -> Path:
270
270
  return Path(__file__).resolve().parents[1]
271
271
 
272
272
 
273
- def _compatibility_default_output(*, plc_label: str, filename: str) -> str:
274
- return _default_report_output(series="compatibility", model=plc_label, filename=filename)
275
-
276
-
277
- def _compatibility_selected_specs(
278
- *,
279
- command_codes: Sequence[str] | None,
280
- include_write_restore: bool,
281
- include_remote_control: bool,
282
- include_maintenance: bool,
283
- ) -> list[CompatibilityCommandSpec]:
284
- enabled_risks = {COMPATIBILITY_SAFE_RISK_GROUP}
285
- if include_write_restore:
286
- enabled_risks.add(COMPATIBILITY_WRITE_RESTORE_RISK_GROUP)
287
- if include_remote_control:
288
- enabled_risks.add(COMPATIBILITY_REMOTE_RISK_GROUP)
289
- if include_maintenance:
290
- enabled_risks.add(COMPATIBILITY_MAINTENANCE_RISK_GROUP)
291
-
292
- selected_codes = set(command_codes) if command_codes else None
293
- specs: list[CompatibilityCommandSpec] = []
294
- for spec in COMPATIBILITY_COMMAND_SPECS:
295
- if spec.risk_group not in enabled_risks:
296
- continue
297
- if selected_codes is not None and spec.code not in selected_codes:
298
- continue
299
- specs.append(spec)
300
- return specs
301
-
302
-
303
- def _compatibility_summarize_subresults(subresults: Sequence[tuple[str, str, str]]) -> str:
304
- statuses = {status for _, status, _ in subresults}
305
- if not statuses:
306
- return "SKIP"
307
- if statuses == {"SKIP"}:
308
- return "SKIP"
309
- if "NG" in statuses and "OK" in statuses:
310
- return "PARTIAL"
311
- if "NG" in statuses and "PARTIAL" in statuses:
312
- return "PARTIAL"
313
- if "PARTIAL" in statuses:
314
- return "PARTIAL"
315
- if "OK" in statuses:
316
- return "OK"
317
- return "NG"
318
-
319
-
320
- def _compatibility_format_subresults(subresults: Sequence[tuple[str, str, str]]) -> str:
321
- parts: list[str] = []
322
- for name, status, detail in subresults:
323
- if detail:
324
- parts.append(f"{name}={status} ({detail})")
325
- else:
326
- parts.append(f"{name}={status}")
327
- return "; ".join(parts)
328
-
329
-
330
273
  def _compatibility_matrix_cell_status(results: Sequence[Mapping[str, object]]) -> str:
331
274
  executed_statuses = [str(row.get("status", "")).upper() for row in results if str(row.get("status", ""))]
332
275
  if not executed_statuses:
@@ -970,8 +913,6 @@ def _render_compatibility_matrix_markdown(
970
913
  def _default_regression_help_scripts() -> tuple[str, ...]:
971
914
  return (
972
915
  "slmp_connection_check.py",
973
- "slmp_compatibility_probe.py",
974
- "slmp_compatibility_matrix_render.py",
975
916
  "slmp_device_range_probe.py",
976
917
  "slmp_register_boundary_probe.py",
977
918
  "slmp_device_access_matrix_sync.py",
@@ -1815,467 +1756,6 @@ def _read_type_name_with_frame_and_series(
1815
1756
  return client.read_type_name()
1816
1757
 
1817
1758
 
1818
- def _compatibility_subprobe(
1819
- name: str,
1820
- func: Callable[[], str],
1821
- ) -> tuple[str, str, str]:
1822
- try:
1823
- return (name, "OK", func())
1824
- except Exception as exc: # noqa: BLE001
1825
- return (name, "NG", str(exc))
1826
-
1827
-
1828
- def _compatibility_request_subprobe(
1829
- client: _StandardSlmpClient,
1830
- *,
1831
- name: str,
1832
- command: int | Command,
1833
- payload: bytes = b"",
1834
- subcommand: int = 0x0000,
1835
- ) -> tuple[str, str, str]:
1836
- try:
1837
- resp = client.request(command, subcommand, payload, raise_on_error=False)
1838
- except Exception as exc: # noqa: BLE001
1839
- return (name, "NG", str(exc))
1840
- status = "OK" if resp.end_code == 0 else "NG"
1841
- return (name, status, f"end_code=0x{resp.end_code:04X}, data_len={len(resp.data)}")
1842
-
1843
-
1844
- def _compatibility_probe_direct_word_write_restore(
1845
- client: _StandardSlmpClient,
1846
- *,
1847
- device: str,
1848
- preferred_write_value: int,
1849
- series: PLCSeries,
1850
- ) -> str:
1851
- before = int(client.read_devices(device, 1, bit_unit=False, series=series)[0])
1852
- write_value = _choose_probe_word_value(current=before, preferred=preferred_write_value)
1853
- client.write_devices(device, [write_value], bit_unit=False, series=series)
1854
- readback = int(client.read_devices(device, 1, bit_unit=False, series=series)[0])
1855
- client.write_devices(device, [before], bit_unit=False, series=series)
1856
- restored = int(client.read_devices(device, 1, bit_unit=False, series=series)[0])
1857
- return (
1858
- f"device={device}, before=0x{before:04X}, write=0x{write_value:04X}, "
1859
- f"readback=0x{readback:04X}, restored=0x{restored:04X}"
1860
- )
1861
-
1862
-
1863
- def _compatibility_probe_direct_bit_write_restore(
1864
- client: _StandardSlmpClient,
1865
- *,
1866
- device: str,
1867
- series: PLCSeries,
1868
- ) -> str:
1869
- before = bool(client.read_devices(device, 1, bit_unit=True, series=series)[0])
1870
- write_value = not before
1871
- client.write_devices(device, [write_value], bit_unit=True, series=series)
1872
- readback = bool(client.read_devices(device, 1, bit_unit=True, series=series)[0])
1873
- client.write_devices(device, [before], bit_unit=True, series=series)
1874
- restored = bool(client.read_devices(device, 1, bit_unit=True, series=series)[0])
1875
- return f"device={device}, before={before}, write={write_value}, readback={readback}, restored={restored}"
1876
-
1877
-
1878
- def _compatibility_probe_random_word_write_restore(
1879
- client: _StandardSlmpClient,
1880
- *,
1881
- devices: Sequence[str],
1882
- preferred_write_value: int,
1883
- series: PLCSeries,
1884
- ) -> str:
1885
- before = client.read_random(word_devices=devices, series=series).word
1886
- write_pairs: list[tuple[str, int]] = []
1887
- for device in devices:
1888
- current = int(before[str(device)])
1889
- write_pairs.append((str(device), _choose_probe_word_value(current=current, preferred=preferred_write_value)))
1890
- client.write_random_words(word_values=write_pairs, series=series)
1891
- readback = client.read_random(word_devices=devices, series=series).word
1892
- restore_pairs = [(str(device), int(before[str(device)])) for device in devices]
1893
- client.write_random_words(word_values=restore_pairs, series=series)
1894
- restored = client.read_random(word_devices=devices, series=series).word
1895
- return (
1896
- f"devices={list(devices)}, before={before}, write={dict(write_pairs)}, readback={readback}, restored={restored}"
1897
- )
1898
-
1899
-
1900
- def _compatibility_probe_random_bit_write_restore(
1901
- client: _StandardSlmpClient,
1902
- *,
1903
- devices: Sequence[str],
1904
- series: PLCSeries,
1905
- ) -> str:
1906
- before = {str(device): bool(client.read_devices(device, 1, bit_unit=True, series=series)[0]) for device in devices}
1907
- write_pairs = [(device, not before[str(device)]) for device in devices]
1908
- client.write_random_bits(bit_values=write_pairs, series=series)
1909
- readback = {
1910
- str(device): bool(client.read_devices(device, 1, bit_unit=True, series=series)[0]) for device in devices
1911
- }
1912
- restore_pairs = [(device, before[str(device)]) for device in devices]
1913
- client.write_random_bits(bit_values=restore_pairs, series=series)
1914
- restored = {
1915
- str(device): bool(client.read_devices(device, 1, bit_unit=True, series=series)[0]) for device in devices
1916
- }
1917
- return (
1918
- f"devices={list(devices)}, before={before}, write={dict(write_pairs)}, readback={readback}, restored={restored}"
1919
- )
1920
-
1921
-
1922
- def _compatibility_probe_block_write_restore(
1923
- client: _StandardSlmpClient,
1924
- *,
1925
- word_device: str | None,
1926
- bit_device: str | None,
1927
- preferred_word_value: int,
1928
- preferred_bit_value: int,
1929
- series: PLCSeries,
1930
- ) -> str:
1931
- word_blocks = [(word_device, 1)] if word_device is not None else []
1932
- bit_blocks = [(bit_device, 1)] if bit_device is not None else []
1933
- before = client.read_block(word_blocks=word_blocks, bit_blocks=bit_blocks, series=series)
1934
-
1935
- write_word_blocks: list[tuple[str, list[int]]] = []
1936
- write_bit_blocks: list[tuple[str, list[int]]] = []
1937
- if word_device is not None:
1938
- current = int(before.word_blocks[0].values[0])
1939
- write_word_blocks.append(
1940
- (word_device, [_choose_probe_word_value(current=current, preferred=preferred_word_value)])
1941
- )
1942
- if bit_device is not None:
1943
- current = int(before.bit_blocks[0].values[0])
1944
- write_bit_blocks.append(
1945
- (bit_device, [_choose_probe_word_value(current=current, preferred=preferred_bit_value)])
1946
- )
1947
-
1948
- client.write_block(
1949
- word_blocks=write_word_blocks,
1950
- bit_blocks=write_bit_blocks,
1951
- series=series,
1952
- split_mixed_blocks=False,
1953
- retry_mixed_on_error=False,
1954
- )
1955
- readback = client.read_block(word_blocks=word_blocks, bit_blocks=bit_blocks, series=series)
1956
- restore_word_blocks = [(row.device, row.values) for row in before.word_blocks]
1957
- restore_bit_blocks = [(row.device, row.values) for row in before.bit_blocks]
1958
- client.write_block(
1959
- word_blocks=restore_word_blocks,
1960
- bit_blocks=restore_bit_blocks,
1961
- series=series,
1962
- split_mixed_blocks=False,
1963
- retry_mixed_on_error=False,
1964
- )
1965
- restored = client.read_block(word_blocks=word_blocks, bit_blocks=bit_blocks, series=series)
1966
- return (
1967
- f"word_device={word_device}, bit_device={bit_device}, "
1968
- f"before_words={before.word_blocks}, before_bits={before.bit_blocks}, "
1969
- f"write_words={write_word_blocks}, write_bits={write_bit_blocks}, "
1970
- f"readback_words={readback.word_blocks}, readback_bits={readback.bit_blocks}, "
1971
- f"restored_words={restored.word_blocks}, restored_bits={restored.bit_blocks}"
1972
- )
1973
-
1974
-
1975
- def _compatibility_probe_memory_write_restore(
1976
- client: _StandardSlmpClient,
1977
- *,
1978
- head_address: int,
1979
- preferred_write_value: int,
1980
- ) -> str:
1981
- before = int(client.memory_read_words(head_address, 1)[0])
1982
- write_value = _choose_probe_word_value(current=before, preferred=preferred_write_value)
1983
- client.memory_write_words(head_address, [write_value])
1984
- readback = int(client.memory_read_words(head_address, 1)[0])
1985
- client.memory_write_words(head_address, [before])
1986
- restored = int(client.memory_read_words(head_address, 1)[0])
1987
- return (
1988
- f"head=0x{head_address:08X}, before=0x{before:04X}, write=0x{write_value:04X}, "
1989
- f"readback=0x{readback:04X}, restored=0x{restored:04X}"
1990
- )
1991
-
1992
-
1993
- def _compatibility_run_command(
1994
- spec: CompatibilityCommandSpec,
1995
- client: _StandardSlmpClient,
1996
- *,
1997
- series: PLCSeries,
1998
- args: argparse.Namespace,
1999
- ) -> tuple[list[tuple[str, str, str]], dict[str, object]]:
2000
- metadata: dict[str, object] = {}
2001
- if spec.code == "0101":
2002
- try:
2003
- info = client.read_type_name()
2004
- except Exception as exc: # noqa: BLE001
2005
- return ([("type_name", "NG", str(exc))], metadata)
2006
- model_label = _resolve_type_name_label(info)
2007
- model_code = _format_model_code(info.model_code)
2008
- inferred_series = _resolve_series_from_type_name(info)
2009
- metadata = {
2010
- "detected_model": model_label,
2011
- "model_code": model_code,
2012
- "detected_series": inferred_series.value if inferred_series is not None else None,
2013
- }
2014
- return ([("type_name", "OK", f"model={model_label}, model_code={model_code}")], metadata)
2015
- if spec.code == "0401":
2016
- return (
2017
- [
2018
- _compatibility_subprobe(
2019
- "word_read",
2020
- lambda: (
2021
- f"device={args.word_device}, values={client.read_devices(args.word_device, 1, series=series)}"
2022
- ),
2023
- ),
2024
- _compatibility_subprobe(
2025
- "bit_read",
2026
- lambda: (
2027
- f"device={args.bit_device}, "
2028
- f"values={client.read_devices(args.bit_device, 1, bit_unit=True, series=series)}"
2029
- ),
2030
- ),
2031
- ],
2032
- metadata,
2033
- )
2034
- if spec.code == "1401":
2035
- return (
2036
- [
2037
- _compatibility_subprobe(
2038
- "word_write_restore",
2039
- lambda: _compatibility_probe_direct_word_write_restore(
2040
- client,
2041
- device=args.word_device,
2042
- preferred_write_value=args.word_write_value,
2043
- series=series,
2044
- ),
2045
- ),
2046
- _compatibility_subprobe(
2047
- "bit_write_restore",
2048
- lambda: _compatibility_probe_direct_bit_write_restore(
2049
- client,
2050
- device=args.bit_device,
2051
- series=series,
2052
- ),
2053
- ),
2054
- ],
2055
- metadata,
2056
- )
2057
- if spec.code == "0403":
2058
- return (
2059
- [
2060
- _compatibility_subprobe(
2061
- "random_read",
2062
- lambda: (
2063
- f"devices={args.random_read_word_device}, "
2064
- f"values={client.read_random(word_devices=args.random_read_word_device, series=series).word}"
2065
- ),
2066
- )
2067
- ],
2068
- metadata,
2069
- )
2070
- if spec.code == "1402":
2071
- return (
2072
- [
2073
- _compatibility_subprobe(
2074
- "random_word_write_restore",
2075
- lambda: _compatibility_probe_random_word_write_restore(
2076
- client,
2077
- devices=args.random_write_word_device,
2078
- preferred_write_value=args.random_write_word_value,
2079
- series=series,
2080
- ),
2081
- ),
2082
- _compatibility_subprobe(
2083
- "random_bit_write_restore",
2084
- lambda: _compatibility_probe_random_bit_write_restore(
2085
- client,
2086
- devices=args.random_write_bit_device,
2087
- series=series,
2088
- ),
2089
- ),
2090
- ],
2091
- metadata,
2092
- )
2093
- if spec.code == "0801":
2094
-
2095
- def _monitor_entry_detail() -> str:
2096
- client.register_monitor_devices(word_devices=[args.monitor_word_device], series=series)
2097
- return f"word_device={args.monitor_word_device}"
2098
-
2099
- return (
2100
- [
2101
- _compatibility_subprobe(
2102
- "monitor_entry",
2103
- _monitor_entry_detail,
2104
- )
2105
- ],
2106
- metadata,
2107
- )
2108
- if spec.code == "0802":
2109
-
2110
- def _monitor_execute_detail() -> str:
2111
- client.register_monitor_devices(word_devices=[args.monitor_word_device], series=series)
2112
- result = client.run_monitor_cycle(word_points=1, dword_points=0)
2113
- return f"word_device={args.monitor_word_device}, values={result.word}"
2114
-
2115
- return ([_compatibility_subprobe("monitor_execute", _monitor_execute_detail)], metadata)
2116
- if spec.code == "0406":
2117
-
2118
- def _block_read_mixed_detail() -> str:
2119
- result = client.read_block(
2120
- word_blocks=[(args.block_word_device, 1)],
2121
- bit_blocks=[(args.block_bit_device, 1)],
2122
- series=series,
2123
- )
2124
- return f"word_device={args.block_word_device}, bit_device={args.block_bit_device}, result={result}"
2125
-
2126
- return (
2127
- [
2128
- _compatibility_subprobe(
2129
- "block_read_word_only",
2130
- lambda: (
2131
- f"device={args.block_word_device}, "
2132
- "result="
2133
- f"{client.read_block(word_blocks=[(args.block_word_device, 1)], series=series).word_blocks}"
2134
- ),
2135
- ),
2136
- _compatibility_subprobe(
2137
- "block_read_bit_only",
2138
- lambda: (
2139
- f"device={args.block_bit_device}, "
2140
- f"result={client.read_block(bit_blocks=[(args.block_bit_device, 1)], series=series).bit_blocks}"
2141
- ),
2142
- ),
2143
- _compatibility_subprobe(
2144
- "block_read_mixed",
2145
- _block_read_mixed_detail,
2146
- ),
2147
- ],
2148
- metadata,
2149
- )
2150
- if spec.code == "1406":
2151
- return (
2152
- [
2153
- _compatibility_subprobe(
2154
- "block_write_word_only",
2155
- lambda: _compatibility_probe_block_write_restore(
2156
- client,
2157
- word_device=args.block_word_device,
2158
- bit_device=None,
2159
- preferred_word_value=args.block_word_write_value,
2160
- preferred_bit_value=args.block_bit_write_value,
2161
- series=series,
2162
- ),
2163
- ),
2164
- _compatibility_subprobe(
2165
- "block_write_bit_only",
2166
- lambda: _compatibility_probe_block_write_restore(
2167
- client,
2168
- word_device=None,
2169
- bit_device=args.block_bit_device,
2170
- preferred_word_value=args.block_word_write_value,
2171
- preferred_bit_value=args.block_bit_write_value,
2172
- series=series,
2173
- ),
2174
- ),
2175
- _compatibility_subprobe(
2176
- "block_write_mixed",
2177
- lambda: _compatibility_probe_block_write_restore(
2178
- client,
2179
- word_device=args.block_word_device,
2180
- bit_device=args.block_bit_device,
2181
- preferred_word_value=args.block_word_write_value,
2182
- preferred_bit_value=args.block_bit_write_value,
2183
- series=series,
2184
- ),
2185
- ),
2186
- ],
2187
- metadata,
2188
- )
2189
- if spec.code == "1001":
2190
- return (
2191
- [
2192
- _compatibility_request_subprobe(
2193
- client,
2194
- name="remote_run",
2195
- command=Command.REMOTE_RUN,
2196
- payload=b"\x01\x00\x00\x00",
2197
- )
2198
- ],
2199
- metadata,
2200
- )
2201
- if spec.code == "1002":
2202
- return (
2203
- [
2204
- _compatibility_request_subprobe(
2205
- client,
2206
- name="remote_stop",
2207
- command=Command.REMOTE_STOP,
2208
- payload=b"\x01\x00",
2209
- )
2210
- ],
2211
- metadata,
2212
- )
2213
- if spec.code == "1005":
2214
- return (
2215
- [
2216
- _compatibility_request_subprobe(
2217
- client,
2218
- name="remote_latch_clear",
2219
- command=Command.REMOTE_LATCH_CLEAR,
2220
- payload=b"\x01\x00",
2221
- )
2222
- ],
2223
- metadata,
2224
- )
2225
- if spec.code == "0619":
2226
- return (
2227
- [
2228
- _compatibility_request_subprobe(
2229
- client,
2230
- name="self_test",
2231
- command=Command.SELF_TEST,
2232
- payload=b"\x04\x00\x11\x22\x33\x44",
2233
- )
2234
- ],
2235
- metadata,
2236
- )
2237
- if spec.code == "0613":
2238
- return (
2239
- [
2240
- _compatibility_subprobe(
2241
- "memory_read",
2242
- lambda: (
2243
- f"head=0x{args.memory_head:08X}, "
2244
- f"values={client.memory_read_words(args.memory_head, args.memory_length)}"
2245
- ),
2246
- )
2247
- ],
2248
- metadata,
2249
- )
2250
- if spec.code == "1613":
2251
- return (
2252
- [
2253
- _compatibility_subprobe(
2254
- "memory_write_restore",
2255
- lambda: _compatibility_probe_memory_write_restore(
2256
- client,
2257
- head_address=args.memory_head,
2258
- preferred_write_value=args.memory_write_value,
2259
- ),
2260
- )
2261
- ],
2262
- metadata,
2263
- )
2264
- if spec.code == "1617":
2265
- return (
2266
- [
2267
- _compatibility_request_subprobe(
2268
- client,
2269
- name="clear_error",
2270
- command=Command.CLEAR_ERROR,
2271
- payload=b"",
2272
- )
2273
- ],
2274
- metadata,
2275
- )
2276
- raise ValueError(f"unsupported compatibility command code: {spec.code}")
2277
-
2278
-
2279
1759
  def _probe_sm400_series(client: _StandardSlmpClient) -> tuple[PLCSeries, list[bool]]:
2280
1760
  series, values = _probe_device_read_with_series(
2281
1761
  client,
@@ -4984,258 +4464,6 @@ def pending_live_verification_main(argv: Sequence[str] | None = None) -> int:
4984
4464
  return 0
4985
4465
 
4986
4466
 
4987
- def compatibility_probe_main(argv: Sequence[str] | None = None) -> int:
4988
- """Probe SLMP compatibility across frame/profile combinations."""
4989
- parser = argparse.ArgumentParser(description="Probe SLMP compatibility across frame/profile combinations")
4990
- parser.add_argument("--host", required=True)
4991
- parser.add_argument("--port", type=int, default=1025)
4992
- parser.add_argument("--transport", choices=("tcp", "udp"), default="tcp")
4993
- parser.add_argument("--timeout", type=float, default=3.0)
4994
- parser.add_argument("--series", choices=("ql", "iqr"), default="ql")
4995
- parser.add_argument("--frame-type", choices=("3e", "4e"), default="3e")
4996
- parser.add_argument("--monitoring-timer", type=_int_auto, default=0x0010, help="e.g. 0x0010")
4997
- parser.add_argument("--network", type=_int_auto, default=0x00)
4998
- parser.add_argument("--station", type=_int_auto, default=0xFF)
4999
- parser.add_argument("--module-io", type=_int_auto, default=0x03FF)
5000
- parser.add_argument("--multidrop", type=_int_auto, default=0x00)
5001
- parser.add_argument(
5002
- "--target",
5003
- help="optional SELF, SELF-CPU1..4, NWx-STy, or NAME,NETWORK,STATION,MODULE_IO,MULTIDROP",
5004
- )
5005
- parser.add_argument("--plc-label", help="stable label used for JSON/matrix output")
5006
- parser.add_argument("--command", action="append", choices=COMPATIBILITY_COMMAND_CODES)
5007
- parser.add_argument("--include-write-restore", action="store_true", help="include write/readback/restore probes")
5008
- parser.add_argument("--include-remote-control", action="store_true", help="include remote RUN/STOP/Latch Clear")
5009
- parser.add_argument("--include-maintenance", action="store_true", help="include Clear Error")
5010
- parser.add_argument("--word-device", default="D130")
5011
- parser.add_argument("--bit-device", default="M120")
5012
- parser.add_argument("--random-read-word-device", action="append")
5013
- parser.add_argument("--random-write-word-device", action="append")
5014
- parser.add_argument("--random-write-bit-device", action="append")
5015
- parser.add_argument("--monitor-word-device", default="D130")
5016
- parser.add_argument("--block-word-device", default="D140")
5017
- parser.add_argument("--block-bit-device", default="M140")
5018
- parser.add_argument("--memory-head", type=_int_auto, default=0x0000)
5019
- parser.add_argument("--memory-length", type=int, default=1)
5020
- parser.add_argument("--word-write-value", type=_int_auto, default=0x0000)
5021
- parser.add_argument("--random-write-word-value", type=_int_auto, default=0x0000)
5022
- parser.add_argument("--block-word-write-value", type=_int_auto, default=0x0000)
5023
- parser.add_argument("--block-bit-write-value", type=_int_auto, default=0x0000)
5024
- parser.add_argument("--memory-write-value", type=_int_auto, default=0x0000)
5025
- parser.add_argument("--output-markdown")
5026
- parser.add_argument("--output-json")
5027
- args = parser.parse_args(list(argv) if argv is not None else None)
5028
-
5029
- selected_specs = _compatibility_selected_specs(
5030
- command_codes=args.command,
5031
- include_write_restore=args.include_write_restore,
5032
- include_remote_control=args.include_remote_control,
5033
- include_maintenance=args.include_maintenance,
5034
- )
5035
- if not selected_specs:
5036
- parser.error("no commands selected; enable at least one probe group or command")
5037
- if args.command:
5038
- selected_codes = {spec.code for spec in selected_specs}
5039
- missing = [code for code in args.command if code not in selected_codes]
5040
- if missing:
5041
- parser.error(
5042
- "requested commands are not enabled by the selected risk flags: " + ", ".join(sorted(set(missing)))
5043
- )
5044
-
5045
- if args.target:
5046
- try:
5047
- target = _parse_named_target(args.target).target
5048
- except ValueError as exc:
5049
- parser.error(str(exc))
5050
- else:
5051
- target = SlmpTarget(
5052
- network=args.network,
5053
- station=args.station,
5054
- module_io=args.module_io,
5055
- multidrop=args.multidrop,
5056
- )
5057
-
5058
- args.random_read_word_device = args.random_read_word_device or [args.word_device, "D131"]
5059
- args.random_write_word_device = args.random_write_word_device or ["D135"]
5060
- args.random_write_bit_device = args.random_write_bit_device or ["M125"]
5061
- plc_label = args.plc_label or f"{args.host}_n{target.network:02X}_s{target.station:02X}"
5062
- markdown_output = args.output_markdown or _compatibility_default_output(
5063
- plc_label=plc_label,
5064
- filename="compatibility_probe_latest.md",
5065
- )
5066
- json_output = args.output_json or _compatibility_default_output(
5067
- plc_label=plc_label,
5068
- filename="compatibility_probe_latest.json",
5069
- )
5070
-
5071
- frame_candidates = [FrameType(args.frame_type)]
5072
- series_candidates = [PLCSeries(args.series)]
5073
-
5074
- rows: list[tuple[str, str, str]] = []
5075
- result_rows: list[dict[str, object]] = []
5076
- selected_command_codes = [spec.code for spec in selected_specs]
5077
- risk_groups = sorted({spec.risk_group for spec in selected_specs})
5078
-
5079
- print("=== Compatibility Probe ===")
5080
- print(f"PLC Label={plc_label}")
5081
- print(f"Host={args.host}, Port={args.port}, Transport={args.transport}")
5082
- print(
5083
- "Target="
5084
- f"network=0x{target.network:02X}, station=0x{target.station:02X}, "
5085
- f"module_io=0x{target.module_io:04X}, multidrop=0x{target.multidrop:02X}"
5086
- )
5087
- print(
5088
- "Combinations="
5089
- + ",".join(f"{frame.value}/{series.value}" for frame in frame_candidates for series in series_candidates)
5090
- )
5091
- print(f"Commands={','.join(selected_command_codes)}")
5092
-
5093
- for frame_type in frame_candidates:
5094
- for series in series_candidates:
5095
- combo_label = f"{frame_type.value}/{series.value}"
5096
- command_rows: list[dict[str, object]] = []
5097
- combo_result: dict[str, object] = {
5098
- "frame_type": frame_type.value,
5099
- "access_profile": series.value,
5100
- "commands": command_rows,
5101
- }
5102
- try:
5103
- with SlmpClient(
5104
- args.host,
5105
- port=args.port,
5106
- transport=args.transport,
5107
- timeout=args.timeout,
5108
- plc_series=series,
5109
- frame_type=frame_type,
5110
- default_target=target,
5111
- monitoring_timer=args.monitoring_timer,
5112
- ) as client:
5113
- for spec in selected_specs:
5114
- subresults, metadata = _compatibility_run_command(spec, client, series=series, args=args)
5115
- status = _compatibility_summarize_subresults(subresults)
5116
- detail = _compatibility_format_subresults(subresults)
5117
- item = f"{combo_label} {spec.code} {spec.name}"
5118
- rows.append((item, status, detail))
5119
- print(f"[{status}] {item}: {detail}")
5120
- command_row: dict[str, object] = {
5121
- "code": spec.code,
5122
- "category": spec.category,
5123
- "name": spec.name,
5124
- "risk_group": spec.risk_group,
5125
- "status": status,
5126
- "detail": detail,
5127
- "subresults": [
5128
- {"name": name, "status": sub_status, "detail": sub_detail}
5129
- for name, sub_status, sub_detail in subresults
5130
- ],
5131
- }
5132
- command_rows.append(command_row)
5133
- for key, value in metadata.items():
5134
- if value is not None and key not in combo_result:
5135
- combo_result[key] = value
5136
- except Exception as exc: # noqa: BLE001
5137
- detail = str(exc)
5138
- print(f"[NG] {combo_label} connection: {detail}")
5139
- combo_result["connection_error"] = detail
5140
- for spec in selected_specs:
5141
- item = f"{combo_label} {spec.code} {spec.name}"
5142
- rows.append((item, "NG", detail))
5143
- command_rows.append(
5144
- {
5145
- "code": spec.code,
5146
- "category": spec.category,
5147
- "name": spec.name,
5148
- "risk_group": spec.risk_group,
5149
- "status": "NG",
5150
- "detail": detail,
5151
- "subresults": [{"name": "connection", "status": "NG", "detail": detail}],
5152
- }
5153
- )
5154
- result_rows.append(combo_result)
5155
-
5156
- json_payload: dict[str, object] = {
5157
- "schema_version": 1,
5158
- "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
5159
- "plc_label": plc_label,
5160
- "host": args.host,
5161
- "port": args.port,
5162
- "transport": args.transport,
5163
- "requested_series": args.series,
5164
- "requested_frame_type": args.frame_type,
5165
- "risk_groups": risk_groups,
5166
- "selected_commands": selected_command_codes,
5167
- "target": {
5168
- "network": target.network,
5169
- "station": target.station,
5170
- "module_io": int(target.module_io),
5171
- "multidrop": target.multidrop,
5172
- },
5173
- "results": result_rows,
5174
- }
5175
- _write_json_report(json_output, json_payload)
5176
-
5177
- summary = Counter(status for _, status, _ in rows)
5178
- _write_markdown_report(
5179
- markdown_output,
5180
- title="# Compatibility Probe Report",
5181
- header_lines=[
5182
- f"- Date: {json_payload['generated_at']}",
5183
- f"- PLC Label: {plc_label}",
5184
- f"- Host: {args.host}",
5185
- f"- Port: {args.port}",
5186
- f"- Transport: {args.transport}",
5187
- f"- Requested series: {args.series}",
5188
- f"- Requested frame: {args.frame_type}",
5189
- (
5190
- f"- Target: network=0x{target.network:02X}, station=0x{target.station:02X}, "
5191
- f"module_io=0x{int(target.module_io):04X}, multidrop=0x{target.multidrop:02X}"
5192
- ),
5193
- f"- Risk groups: {', '.join(risk_groups)}",
5194
- f"- Commands: {', '.join(selected_command_codes)}",
5195
- f"- Summary: OK={summary['OK']}, PARTIAL={summary['PARTIAL']}, NG={summary['NG']}, SKIP={summary['SKIP']}",
5196
- f"- JSON: {json_output}",
5197
- ],
5198
- rows=rows,
5199
- )
5200
- print(f"[DONE] markdown={markdown_output}")
5201
- print(f"[DONE] json={json_output}")
5202
- return 0
5203
-
5204
-
5205
- def compatibility_matrix_render_main(argv: Sequence[str] | None = None) -> int:
5206
- """Render PLC_COMPATIBILITY.md from compatibility probe JSON files."""
5207
- parser = argparse.ArgumentParser(description="Render PLC_COMPATIBILITY.md from compatibility probe JSON files")
5208
- parser.add_argument("--input", action="append", required=True, help="compatibility_probe_latest.json; repeatable")
5209
- parser.add_argument("--output", default="internal_docs/validation/reports/PLC_COMPATIBILITY.md")
5210
- parser.add_argument(
5211
- "--policy-output",
5212
- default=_compatibility_default_policy_output(),
5213
- help="structured policy JSON generated from the same probe inputs",
5214
- )
5215
- parser.add_argument(
5216
- "--omit-pending-columns",
5217
- action="store_true",
5218
- help="hide command columns that are PENDING for every PLC row",
5219
- )
5220
- args = parser.parse_args(list(argv) if argv is not None else None)
5221
-
5222
- source_paths = [Path(path) for path in args.input]
5223
- loaded: list[Mapping[str, object]] = []
5224
- for path in source_paths:
5225
- loaded.append(json.loads(path.read_text(encoding="utf-8")))
5226
-
5227
- content = _render_compatibility_matrix_markdown(
5228
- loaded,
5229
- source_paths=source_paths,
5230
- omit_pending_columns=args.omit_pending_columns,
5231
- )
5232
- _write_text_report(args.output, content)
5233
- _write_json_report(args.policy_output, _build_compatibility_policy(loaded))
5234
- print(f"[DONE] output={args.output}")
5235
- print(f"[DONE] policy={args.policy_output}")
5236
- return 0
5237
-
5238
-
5239
4467
  def device_access_matrix_sync_main(argv: Sequence[str] | None = None) -> int:
5240
4468
  """Render device_access_matrix.md from device_access_matrix.csv."""
5241
4469
  parser = argparse.ArgumentParser(description="Render device_access_matrix.md from device_access_matrix.csv")
@@ -1271,7 +1271,7 @@ _RANDOM_DWORD_ONLY_DIRECT_CODES = frozenset({"LCN", "LZ"})
1271
1271
  _G_HG_CODES = frozenset({"G", "HG"})
1272
1272
  _TEMPORARILY_UNSUPPORTED_TYPED_CODES = frozenset({"G", "HG"})
1273
1273
  _BOUNDARY_START_ACCEPTANCE_CODES = frozenset({"R", "ZR"})
1274
- _MIXED_BLOCK_RETRY_END_CODES = frozenset({0xC056, 0xC05B, 0xC061})
1274
+ _MIXED_BLOCK_RETRY_END_CODES = frozenset({0xC056, 0xC061})
1275
1275
 
1276
1276
 
1277
1277
  def _encode_label_name(label: str) -> bytes:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slmp-connect-python
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: SLMP Connect Python: client library for Mitsubishi SLMP binary communication
5
5
  Author: fa-yoshinobu
6
6
  License-Expression: MIT
@@ -43,6 +43,13 @@ Dynamic: license-file
43
43
 
44
44
  ![Illustration](https://raw.githubusercontent.com/fa-yoshinobu/plc-comm-slmp-python/main/docsrc/assets/melsec.png)
45
45
 
46
+ [![Automated Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/automated-release.yml)
47
+ [![Release](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml/badge.svg)](https://github.com/fa-yoshinobu/plc-comm-slmp-python/actions/workflows/release.yml)
48
+
49
+ [![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)](https://www.python.org/)
50
+ [![MkDocs](https://img.shields.io/badge/MkDocs-526CFE?logo=materialformkdocs&logoColor=white)](https://www.mkdocs.org/)
51
+ [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222222?logo=githubpages&logoColor=white)](https://pages.github.com/)
52
+
46
53
  High-level SLMP helpers for Mitsubishi PLC communication over Binary 3E and 4E frames.
47
54
 
48
55
  This repository treats the high-level helper layer as the recommended user surface:
@@ -1,6 +1,4 @@
1
1
  [console_scripts]
2
- slmp-compatibility-matrix-render = slmp.cli:compatibility_matrix_render_main
3
- slmp-compatibility-probe = slmp.cli:compatibility_probe_main
4
2
  slmp-connection-check = slmp.cli:connection_check_main
5
3
  slmp-device-access-matrix-sync = slmp.cli:device_access_matrix_sync_main
6
4
  slmp-device-range-probe = slmp.cli:device_range_probe_main
@@ -1221,169 +1221,6 @@ class TestCli(unittest.TestCase):
1221
1221
  self.assertEqual(PolicyOtherStationClient.type_name_calls, [("4e", "ql")])
1222
1222
  self.assertIn("Resolved frame: 4e", report)
1223
1223
 
1224
- def test_compatibility_probe_main_writes_json_and_markdown(self) -> None:
1225
- """Test test_compatibility_probe_main_writes_json_and_markdown."""
1226
-
1227
- class CompatibilityProbeClient(SlmpClient):
1228
- def __init__(
1229
- self,
1230
- host: str,
1231
- port: int = 5000,
1232
- *,
1233
- transport: str = "tcp",
1234
- timeout: float = 3.0,
1235
- plc_series: PLCSeries | str = PLCSeries.QL,
1236
- frame_type: cli.FrameType | str = cli.FrameType.FRAME_4E,
1237
- default_target: SlmpTarget | None = None,
1238
- monitoring_timer: int = 0x0010,
1239
- raise_on_error: bool = True,
1240
- trace_hook=None,
1241
- ) -> None:
1242
- super().__init__(
1243
- host,
1244
- port,
1245
- transport=transport,
1246
- timeout=timeout,
1247
- plc_series=plc_series,
1248
- frame_type=frame_type,
1249
- default_target=default_target,
1250
- monitoring_timer=monitoring_timer,
1251
- raise_on_error=raise_on_error,
1252
- trace_hook=trace_hook,
1253
- )
1254
-
1255
- def connect(self) -> None:
1256
- return None
1257
-
1258
- def close(self) -> None:
1259
- return None
1260
-
1261
- def read_type_name(self) -> TypeNameInfo:
1262
- return TypeNameInfo(raw=b"\x00" * 18, model="Q26UDEHCPU", model_code=0x026C)
1263
-
1264
- def read_devices(self, device, points, *, bit_unit=False, series=None): # type: ignore[override]
1265
- if bit_unit:
1266
- return [True]
1267
- return [0]
1268
-
1269
- def read_block(self, *, word_blocks=(), bit_blocks=(), series=None, split_mixed_blocks=False): # type: ignore[override]
1270
- return BlockReadResult(word_blocks=[], bit_blocks=[])
1271
-
1272
- with TemporaryDirectory() as tmp:
1273
- output_md = Path(tmp) / "compatibility_probe_latest.md"
1274
- output_json = Path(tmp) / "compatibility_probe_latest.json"
1275
- with patch.object(cli, "SlmpClient", CompatibilityProbeClient):
1276
- rc = cli.compatibility_probe_main(
1277
- [
1278
- "--host",
1279
- "192.168.250.100",
1280
- "--plc-label",
1281
- "Q26UDEHCPU_BuiltIn",
1282
- "--series",
1283
- "ql",
1284
- "--frame-type",
1285
- "3e",
1286
- "--command",
1287
- "0101",
1288
- "--command",
1289
- "0401",
1290
- "--command",
1291
- "0406",
1292
- "--output-markdown",
1293
- str(output_md),
1294
- "--output-json",
1295
- str(output_json),
1296
- ]
1297
- )
1298
- report = output_md.read_text(encoding="utf-8")
1299
- payload = json.loads(output_json.read_text(encoding="utf-8"))
1300
-
1301
- self.assertEqual(rc, 0)
1302
- self.assertEqual(payload["plc_label"], "Q26UDEHCPU_BuiltIn")
1303
- self.assertEqual(payload["selected_commands"], ["0101", "0401", "0406"])
1304
- self.assertEqual(payload["results"][0]["frame_type"], "3e")
1305
- self.assertEqual(payload["results"][0]["access_profile"], "ql")
1306
- self.assertEqual(payload["results"][0]["detected_model"], "Q26UDHCPU, Q26UDEHCPU")
1307
- self.assertIn("3e/ql 0101 Read Type Name", report)
1308
- self.assertIn("3e/ql 0406 Block Read", report)
1309
-
1310
- def test_compatibility_probe_main_rejects_auto_series_and_frame(self) -> None:
1311
- """Test test_compatibility_probe_main_rejects_auto_series_and_frame."""
1312
-
1313
- with self.assertRaises(SystemExit) as cm:
1314
- cli.compatibility_probe_main(
1315
- [
1316
- "--host",
1317
- "192.168.250.100",
1318
- "--series",
1319
- "auto",
1320
- "--frame-type",
1321
- "auto",
1322
- ]
1323
- )
1324
-
1325
- self.assertEqual(cm.exception.code, 2)
1326
-
1327
- def test_compatibility_matrix_render_main_renders_output(self) -> None:
1328
- """Test test_compatibility_matrix_render_main_renders_output."""
1329
- with TemporaryDirectory() as tmp:
1330
- input_a = Path(tmp) / "probe_a.json"
1331
- input_b = Path(tmp) / "probe_b.json"
1332
- output = Path(tmp) / "PLC_COMPATIBILITY.md"
1333
- policy_output = Path(tmp) / "compatibility_policy.json"
1334
- input_a.write_text(
1335
- json.dumps(
1336
- {
1337
- "plc_label": "PLC-A",
1338
- "results": [
1339
- {
1340
- "frame_type": "3e",
1341
- "access_profile": "ql",
1342
- "commands": [{"code": "0101", "status": "OK"}],
1343
- }
1344
- ],
1345
- }
1346
- ),
1347
- encoding="utf-8",
1348
- )
1349
- input_b.write_text(
1350
- json.dumps(
1351
- {
1352
- "plc_label": "PLC-B",
1353
- "results": [
1354
- {
1355
- "frame_type": "4e",
1356
- "access_profile": "iqr",
1357
- "commands": [{"code": "0101", "status": "NG"}],
1358
- }
1359
- ],
1360
- }
1361
- ),
1362
- encoding="utf-8",
1363
- )
1364
- rc = cli.compatibility_matrix_render_main(
1365
- [
1366
- "--input",
1367
- str(input_a),
1368
- "--input",
1369
- str(input_b),
1370
- "--omit-pending-columns",
1371
- "--policy-output",
1372
- str(policy_output),
1373
- "--output",
1374
- str(output),
1375
- ]
1376
- )
1377
- content = output.read_text(encoding="utf-8")
1378
- policy = json.loads(policy_output.read_text(encoding="utf-8"))
1379
-
1380
- self.assertEqual(rc, 0)
1381
- self.assertIn("| PLC-A | unknown_target | YES |", content)
1382
- self.assertIn("| PLC-B | unknown_target | NO |", content)
1383
- self.assertNotIn("**1401**", content)
1384
- self.assertIn("global", policy)
1385
- self.assertIn("preferred_profiles", policy["global"])
1386
-
1387
1224
  def test_manual_label_verification_main_requires_label_args(self) -> None:
1388
1225
  """Test test_manual_label_verification_main_requires_label_args."""
1389
1226
  with self.assertRaises(SystemExit) as ctx:
@@ -2288,25 +2125,19 @@ class TestDeviceApi(unittest.TestCase):
2288
2125
  self.assertEqual(len(client.requests), 1)
2289
2126
  self.assertEqual(client.requests[0][0], Command.DEVICE_WRITE_BLOCK)
2290
2127
 
2291
- def test_write_block_retry_mixed_on_c05b_splits_after_failed_combined_request(self) -> None:
2292
- """Test test_write_block_retry_mixed_on_c05b_splits_after_failed_combined_request."""
2293
- client = FakeClient()
2294
- client.response_queue = [(0xC05B, b""), (0x0000, b""), (0x0000, b"")]
2295
- with warnings.catch_warnings(record=True) as caught:
2296
- warnings.simplefilter("always")
2297
- client.write_block(
2298
- word_blocks=[("D100", [0x1111])],
2299
- bit_blocks=[("M200", [0x0001])],
2300
- series=PLCSeries.IQR,
2301
- retry_mixed_on_error=True,
2302
- )
2303
- self.assertEqual(len(client.requests), 3)
2304
- self.assertEqual([request[0] for request in client.requests], [Command.DEVICE_WRITE_BLOCK] * 3)
2305
- self.assertEqual([request[2][:2] for request in client.requests], [b"\x01\x01", b"\x01\x00", b"\x00\x01"])
2306
- self.assertTrue(all(request[3]["raise_on_error"] is False for request in client.requests))
2307
- self.assertTrue(
2308
- any(isinstance(item.message, SlmpPracticalPathWarning) and "0xC05B" in str(item.message) for item in caught)
2309
- )
2128
+ def test_write_block_retry_mixed_on_c05b_does_not_split(self) -> None:
2129
+ """Test test_write_block_retry_mixed_on_c05b_does_not_split."""
2130
+ client = FakeClient()
2131
+ client.response_queue = [(0xC05B, b"")]
2132
+ with self.assertRaises(SlmpError) as ctx:
2133
+ client.write_block(
2134
+ word_blocks=[("D100", [0x1111])],
2135
+ bit_blocks=[("M200", [0x0001])],
2136
+ series=PLCSeries.IQR,
2137
+ retry_mixed_on_error=True,
2138
+ )
2139
+ self.assertEqual(ctx.exception.end_code, 0xC05B)
2140
+ self.assertEqual(len(client.requests), 1)
2310
2141
 
2311
2142
  def test_write_block_retry_mixed_on_c056_splits_after_failed_combined_request(self) -> None:
2312
2143
  """Test test_write_block_retry_mixed_on_c056_splits_after_failed_combined_request."""