From e0c7ae710a10001640c86200e66e1d401e18bf80 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:15:44 -0500 Subject: [PATCH 01/55] spec: Initial commit --- .claude/settings.local.json | 9 + scratchpad/TestingDisplayVersusStorage.cdv | Bin 0 -> 686 bytes scratchpad/TestingDisplayVersusStorage.csv | 24 + spec/ARCHITECTURE.md | 447 +++++++++++++++ spec/CLICKDEVICE_SPEC.md | 603 +++++++++++++++++++++ spec/CLICKSERVER_SPEC.md | 594 ++++++++++++++++++++ spec/HANDOFF.md | 60 ++ 7 files changed, 1737 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 scratchpad/TestingDisplayVersusStorage.cdv create mode 100644 scratchpad/TestingDisplayVersusStorage.csv create mode 100644 spec/ARCHITECTURE.md create mode 100644 spec/CLICKDEVICE_SPEC.md create mode 100644 spec/CLICKSERVER_SPEC.md create mode 100644 spec/HANDOFF.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f93063b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(make:*)", + ] + } +} diff --git a/scratchpad/TestingDisplayVersusStorage.cdv b/scratchpad/TestingDisplayVersusStorage.cdv new file mode 100644 index 0000000000000000000000000000000000000000..5feb12c5519082d2282e366618d6a34efafacc88 GIT binary patch literal 686 zcmZ`%%MQXY49gjbKLM%hV;g6xX?J#j|NjrbPH2O8Xxi2$w&SFI+>bKIL4v+}*~t~_ zps`6tnGqkv;}QE74;nxJz*f0*)htm?lJO>d3wE>eh@l!QC#@pA;6%!)-n^r}R`G2_Pqk`u+_gB?R<9mmnewOZMf(~&rd=z-% zf9}4Q?uR(~PUtFVX94TxXX$>38-Sn^XPWe}ld7_6U7MkGylMqTSib<>X11y(GseDQ t*1QqUW`y(lW^cGHo1*C@>Qt!DnwBHabL`1;R tuple[str, int]: + """Parse 'DF1', 'X001', 'ds100' → ('DF', 1), ('X', 1), ('DS', 100)""" +``` + +The Modbus layer calls `parse_address()` then looks up `ModbusMapping` by bank name. ClickNick calls `parse_address()` then looks up `BankConfig`. Same function, different downstream lookups. + +--- + +## Key Design Decision: Optional Modbus + +**Problem:** ClickNick only needs nickname/blocktag functionality. It should not require pymodbus. + +**Decision:** pymodbus is an optional dependency. + +```toml +# pyproject.toml +[project.optional-dependencies] +modbus = ["pymodbus>=3.7"] +``` + +- `banks.py`, `addresses.py`, `blocks.py`, `nicknames.py`, `validation.py`, `dataview.py` — zero external dependencies (stdlib only) +- `client.py`, `server.py` — require pymodbus, import guarded +- Users: `pip install pyclickplc` for core, `pip install pyclickplc[modbus]` for everything + +--- + +## Key Design Decision: XD/YD Banks + +XD and YD are byte-grouped views of X/Y inputs/outputs exposed by the CLICK programming software. They have Modbus register addresses (not coils — they read/write 16-bit words that pack groups of I/O bits). + +**Decision:** Include XD/YD in both `BANKS` and `MODBUS_MAPPINGS`. XD is read-only; YD is read/write. The `addresses.py` XD/YD helpers move over as-is. + +> **TODO:** XD/YD Modbus details need to be confirmed by testing against real hardware. The `ModbusMapping` entries for XD/YD will be added once base addresses and behavior are verified. + +--- + +## Implementation Phases + +### Phase 1: Foundation + +**Modules:** `banks.py`, `addresses.py`, `validation.py` + +- Extract from ClickNick `constants.py` and `address_row.py` +- Create `BankConfig` dataclass +- Unify `DataType` enum as the canonical type system +- Port all address parsing/formatting functions +- Port all validation rules and functions +- Comprehensive tests + +**Unblocks:** Everything else. ClickNick can start importing immediately. + +### Phase 2: BlockTags + +**Module:** `blocks.py` + +- Requires ClickNick Phase 0 (unique block names) to be done first ✓ +- Extract from ClickNick `blocktag.py` (post-simplification) +- `BlockTag`, `BlockRange`, `MemoryBankMeta` +- All parsing and computation functions +- Tests + +### Phase 3: File I/O + +**Modules:** `nicknames.py`, `dataview.py` + +- Extract CSV read/write from ClickNick `data_source.py` +- Extract CDV read/write from ClickNick `cdv_file.py` +- Create `NicknameProject` as high-level loader +- Tests using existing test fixtures from ClickNick + +### Phase 4: Modbus Core + +**Module:** `modbus.py` + +- New code — `ModbusMapping` definitions for all 14 Modbus banks +- Forward mapping (PLC → Modbus address) +- Reverse mapping (Modbus → PLC address) +- Register packing/unpacking (struct-based) +- Sparse coil logic (X/Y forward and reverse) +- Text register handling +- Tests — extensive, since this is new code + +**Can run in parallel with Phases 2 & 3** (independent dependency chains). + +### Phase 5: Modbus Client + +**Module:** `client.py` + +- New code — `ClickDriver` per CLICKDEVICE_SPEC +- `AddressAccessor`, `AddressInterface`, `TagInterface` +- Uses `modbus.py` for mapping/packing, `nicknames.py` for tag loading +- Tests with mocked Modbus client + +### Phase 6: Modbus Server + +**Module:** `server.py` + +- New code — `ClickServer` per CLICKSERVER_SPEC +- `DataProvider` protocol, `MemoryDataProvider` +- Request handling for all supported function codes +- Tests with mocked/real pymodbus server + +### Phase 7: Integration + +- Update ClickNick imports (mechanical find-and-replace) +- Integration tests: ClickDriver ↔ ClickServer round-trips +- Wire into pyrung +- Delete moved code from ClickNick + +--- + +## Changes from Original Extraction Plan + +| Original Plan | Revised | +|---------------|---------| +| 5 modules (`banks`, `addresses`, `blocks`, `nicknames`, `validation`) | 9 modules (+ `dataview`, `modbus`, `client`, `server`) | +| `DataType` extracted as-is | `DataType` becomes canonical; Modbus types derived from it | +| `ADDRESS_RANGES` as flat tuples | `BankConfig` dataclass with `valid_ranges` for sparse banks | +| X/Y shown as full 1-816 range | Sparse `valid_ranges` defines exactly which addresses exist; ClickNick filters display accordingly | +| XD/YD excluded from Modbus | XD/YD included in `MODBUS_MAPPINGS` (XD read-only, YD read/write; details TBD pending hardware testing) | +| No Modbus knowledge | `modbus.py` adds protocol mapping layer | +| No mention of CDV files | `dataview.py` added | +| `AddressRecord` in `addresses.py` | Unchanged — still the shared data transfer object | +| pymodbus not mentioned | Optional dependency for client/server | + +The original extraction plan's module boundaries and phase ordering are preserved. The Modbus layer slots in alongside without disturbing the ClickNick extraction path. + +--- + +## Public API (`__init__.py`) + +```python +# Core +from pyclickplc.banks import BankConfig, BANKS, DataType +from pyclickplc.addresses import AddressRecord, parse_address, format_address_display +from pyclickplc.validation import validate_nickname, validate_initial_value + +# BlockTags +from pyclickplc.blocks import BlockTag, BlockRange, MemoryBankMeta + +# File I/O +from pyclickplc.nicknames import read_csv, write_csv, load_nickname_file, NicknameProject +from pyclickplc.dataview import read_cdv, write_cdv + +# Modbus (import-guarded, requires pyclickplc[modbus]) +from pyclickplc.client import ClickDriver +from pyclickplc.server import ClickServer, MemoryDataProvider, DataProvider +``` diff --git a/spec/CLICKDEVICE_SPEC.md b/spec/CLICKDEVICE_SPEC.md new file mode 100644 index 0000000..74dd3b6 --- /dev/null +++ b/spec/CLICKDEVICE_SPEC.md @@ -0,0 +1,603 @@ +# clickclient Driver Specification + +A Python driver for AutomationDirect CLICK Plcs using Modbus TCP/IP. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Dependencies](#dependencies) +3. [Data Structures](#data-structures) +4. [Address Types & Configuration](#address-types--configuration) +5. [ClickDriver Class](#clickclient-class) +6. [AddressAccessor Class](#addressaccessor-class) +7. [AddressInterface Class](#addressinterface-class) +8. [TagInterface Class](#taginterface-class) +9. [Tag System](#tag-system) +10. [Validation Rules](#validation-rules) +11. [Test Scenarios](#test-scenarios) + +--- + +## Overview + +The driver provides asynchronous communication with AutomationDirect CLICK Plcs over Ethernet. It abstracts Modbus protocol details and PLC-specific quirks, offering a clean Pythonic interface with clear separation between raw address access and named tag access. + +### Key Features + +- Async/await pattern using asyncio +- Context manager support (`async with`) +- Clear separation: `plc.addr` for raw addresses, `plc.tag` for named tags +- Pythonic memory bank accessors: `plc.df.read(1, 10)` for ranges +- Optional tag file loading for named access + +### Interface Summary + +```python +async with ClickDriver('192.168.1.100') as plc: + # Category accessors (recommended for raw addresses) + value = await plc.df.read(1) # Single value + values = await plc.df.read(1, 10) # Range (inclusive) + await plc.df.write(1, 3.14) # Write single + await plc.df.write(1, [1.0, 2.0]) # Write consecutive + + # Address interface (string-based) + value = await plc.addr.read('df1') + values = await plc.addr.read('df1-df10') + await plc.addr.write('df1', 3.14) + + # Tag interface (requires tag file) + value = await plc.tag.read('MyTagName') + values = await plc.tag.read() # All tags + await plc.tag.write('MyTagName', 3.14) +``` + +--- + +## Dependencies + +- Requires an `AsyncioModbusClient` base class from `clickclient.util` that provides: + - `read_coils(address: int, count: int) -> CoilResult` (CoilResult has `.bits: list[bool]`) + - `write_coils(address: int, data: list[bool]) -> None` + - `read_registers(address: int, count: int) -> list[int]` + - `write_registers(address: int, data: list[int]) -> None` + - Context manager support (`__aenter__`, `__aexit__`) + - Constructor: `__init__(self, address: str, timeout: int)` + +--- + +## Data Structures + +### AddressType (dataclass, frozen=True) + +Defines configuration for a PLC address type. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `base` | `int` | required | Modbus base address | +| `max_addr` | `int` | required | Maximum valid PLC address number | +| `data_type` | `str` | required | One of: `'bool'`, `'int16'`, `'int32'`, `'float'`, `'str'` | +| `width` | `int` | `1` | Registers per value (2 for 32-bit types) | +| `signed` | `bool` | `True` | Whether numeric type is signed | +| `sparse` | `bool` | `False` | True for X/Y addresses with gaps in addressing | +| `writable` | `frozenset[int] \| None` | `None` | If set, only these specific addresses are writable | + +--- + +## Address Types & Configuration + +The following address types must be supported: + +### Boolean Types (Coils) + +| Category | Base | Max | Notes | +|----------|------|-----|-------| +| `x` | 0 | 836 | Inputs, sparse (CPU: `X001-X016`, `X021-X036`; Expansion: `*01-*16`) | +| `y` | 8192 | 836 | Outputs, sparse (CPU: `Y001-Y016`, `Y021-Y036`; Expansion: `*01-*16`) | +| `c` | 16384 | 2000 | Control relays | +| `t` | 45057 | 500 | Timer status bits | +| `ct` | 49152 | 250 | Counter status bits | +| `sc` | 61440 | 1000 | System control relays, **limited writable**: {53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121} | + +### Numeric Types (Registers) + +| Category | Base | Max | Type | Width | Signed | +|----------|------|-----|------|-------|--------| +| `ds` | 0 | 4500 | int16 | 1 | Yes | +| `dd` | 16384 | 1000 | int32 | 2 | Yes | +| `dh` | 24576 | 500 | int16 | 1 | No (unsigned) | +| `df` | 28672 | 500 | float | 2 | N/A | +| `td` | 45056 | 500 | int16 | 1 | Yes | +| `ctd` | 49152 | 250 | int32 | 2 | Yes | +| `sd` | 61440 | 1000 | int16 | 1 | No, **limited writable**: {29, 31, 32, 34, 35, 36, 40, 41, 42, 50, 51, 60, 61, 106, 107, 108, 112, 113, 114, 140, 141, 142, 143, 144, 145, 146, 147, 214, 215} | + +### Text Type + +| Category | Base | Max | Notes | +|----------|------|-----|-------| +| `txt` | 36864 | 1000 | Packed ASCII, 2 chars per register | + +--- + +## ClickDriver Class + +Inherits from `AsyncioModbusClient`. + +### Class Attribute + +```python +data_types: ClassVar[dict[str, str]] # Maps bank -> data_type string +``` + +### Constructor + +```python +def __init__(self, address: str, tag_filepath: str = '', timeout: int = 1) +``` + +- `address`: PLC IP address or DNS name +- `tag_filepath`: Optional path to tags CSV file +- `timeout`: Communication timeout in seconds + +### Instance Attributes + +- `tags: dict` - Loaded tag definitions (empty dict if no file provided) +- `addr: AddressInterface` - Interface for raw address operations +- `tag: TagInterface` - Interface for tag-based operations + +### Pythonic Memory Bank Accessors + +#### `__getattr__(name: str) -> AddressAccessor` + +Returns an `AddressAccessor` for the given bank. + +```python +plc.df # Returns AddressAccessor for DF registers +plc.x # Returns AddressAccessor for X inputs +``` + +- Raises `AttributeError` for names starting with `_` +- Raises `AttributeError` for unknown banks +- Accessors are cached and reused + +--- + +## AddressAccessor Class + +Provides method-based access to a specific address banks. + +### Constructor + +```python +def __init__(self, plc: ClickDriver, bank: str) +``` + +### Methods + +#### `async read(start: int, end: int | None = None) -> dict | bool | int | float | str` + +Read single value or range. + +```python +value = await plc.df.read(1) # Single value at DF1 +values = await plc.df.read(1, 10) # Range DF1 through DF10 (inclusive) +``` + +**Returns:** +- Single address (`end` is `None`): Returns the value directly +- Range: Returns `{address: value}` dict (e.g., `{'df1': 0.0, 'df2': 1.0, ...}`) + +#### `async write(start: int, data) -> None` + +Write single value or list of values. + +```python +await plc.df.write(1, 3.14) # Single value to DF1 +await plc.df.write(1, [1.0, 2.0]) # Writes DF1=1.0, DF2=2.0 +``` + +- Validates data types +- Validates writability for restricted addresses + +#### `__repr__() -> str` + +Returns `` + +--- + +## AddressInterface Class + +Provides string-based access to raw PLC addresses. + +### Constructor + +```python +def __init__(self, plc: ClickDriver) +``` + +### Methods + +#### `async read(address: str) -> dict | bool | int | float | str` + +Read values by address string. + +```python +value = await plc.addr.read('df1') # Single value +values = await plc.addr.read('df1-df10') # Range as dict +``` + +**Returns:** +- Single address: Returns the value directly +- Range: Returns `{address: value}` dict (e.g., `{'df1': 0.0, 'df2': 1.0, ...}`) + +#### `async write(address: str, data) -> None` + +Write values by address string. + +```python +await plc.addr.write('df1', 3.14) # Single value +await plc.addr.write('df1', [1.0, 2.0]) # Writes DF1, DF2 +``` + +- `data` can be a single value or a list +- List writes consecutively starting at address +- Validates data type matches address type +- Validates writability for restricted addresses + +--- + +## TagInterface Class + +Provides access via tag nicknames. Requires tags to be loaded. + +### Constructor + +```python +def __init__(self, plc: ClickDriver) +``` + +### Methods + +#### `async read(tag_name: str | None = None) -> dict | bool | int | float | str` + +Read values by tag name. + +```python +value = await plc.tag.read('MyTag') # Single tag value +values = await plc.tag.read() # All tags as dict +``` + +**Behavior:** +- If `tag_name` provided: returns the value for that tag +- If `tag_name` is `None`: returns all tagged values as `{tag_name: value}` +- Raises `KeyError` if tag not found +- Raises `ValueError` if called with `None` when no tags loaded + +#### `async write(tag_name: str, data) -> None` + +Write value by tag name. + +```python +await plc.tag.write('MyTag', 3.14) +await plc.tag.write('MyTag', [1.0, 2.0]) # Writes consecutive addresses +``` + +- Resolves tag to underlying address, then writes +- Validates data type and writability + +#### `read_all() -> dict` + +Returns a copy of all tag definitions (synchronous). + +```python +tags = plc.tag.read_all() +# {'MyTag': {'address': 'DF1', 'type': 'float'}, ...} +``` + +--- + +## Tag System + +### CSV File Format + +Tags are loaded from a CSV file exported from Click programming software. + +**Expected columns:** +- `Nickname` - Tag name (skip if empty or starts with `_`) +- `Address` - PLC address (e.g., `DF1`, `X101`) +- `Modbus Address` - Numeric Modbus address +- `Address Comment` - Optional comment + +**Note:** First line may have `## ` prefix that must be stripped. + +### Tag Data Structure + +```python +{ + 'TagName': { + 'address': str, # PLC address (e.g., 'DF1', 'X101') + 'type': str, # Data type string + 'comment': str, # Optional, only if present in CSV + } +} +``` + +Tags are sorted PLC address. + +--- + +## Validation Rules + +### Address Parsing + +Format: `BANK + NUMBER` or `BANK + NUMBER - BANK + NUMBER` + +- Bank is case-insensitive +- Inter-bank ranges not supported (e.g., `DF1-DD5` is invalid) +- End must be greater than start + +### Sparse Addressing (X, Y) + +X and Y addresses map to physical hardware slots with gaps in the Modbus address space. + +**X (inputs) addressing:** +- CPU slot 1: `X001-X016` → coils 0-15 +- CPU slot 2: `X021-X036` → coils 16-31 +- Expansion slots: `X101-X116`, `X201-X216`, ..., `X801-X816` → standard 16 addresses per hundred + +**Y (outputs) addressing:** +- CPU slot 1: `Y001-Y016` → coils 0-15 +- CPU slot 2: `Y021-Y036` → coils 16-31 +- Expansion slots: `Y101-Y116`, `Y201-Y216`, ..., `Y801-Y816` → standard 16 addresses per hundred + +**Valid addresses:** +- X: 001-016, 021-036, 101-116, 201-216, ..., 801-816 +- Y: 001-016, 021-036, 101-116, 201-216, ..., 801-816 + +**Invalid addresses:** Anything not listed above (e.g., X017-X020, X037-X100, Y017-Y020, Y037-Y100) + +> **Note:** While current CLICK CPU slots only have 8 inputs (X) and 6 outputs (Y), the full 16 addresses per slot are reserved in the Modbus space. +> +> **TODO:** Should we have a default toggle to only read X001-X008/X021-X028 and Y001-Y006/Y021-Y026? + +### Range Validation + +For non-sparse types: +- Start must be in `[1, max_addr]` +- End (if provided) must be `> start` and `<= max_addr` + +For sparse types (X, Y): +- Address must be in a valid range (see Sparse Addressing above) +- Validation must check CPU slots (000) differently from expansion slots (100+) + +### Data Type Validation + +| Type | Accepted Python Types | +|------|----------------------| +| `bool` | `bool` | +| `int16` | `int` | +| `int32` | `int` | +| `float` | `int` or `float` | +| `str` | `str` | + +### Writability Validation + +For `sc` and `sd` categories, only specific addresses are writable. Writing to non-writable addresses raises `ValueError`. + +--- + +## Internal Behavior Specifications + +### Modbus Address Calculation + +#### Coils (Boolean Types) + +**Standard coils:** +``` +coil_address = base + index - 1 +``` + +**Sparse coils (X):** +``` +if index <= 16: # CPU slot 1: X001-X016 + coil_address = base + index - 1 +elif index <= 36: # CPU slot 2: X021-X036 + coil_address = base + 16 + (index - 21) +else: # Expansion: X101+ + hundred = index // 100 + unit = index % 100 + coil_address = base + 32 * hundred + (unit - 1) +``` + +**Sparse coils (Y):** +``` +if index <= 16: # CPU slot 1: Y001-Y016 + coil_address = base + index - 1 +elif index <= 36: # CPU slot 2: Y021-Y036 + coil_address = base + 16 + (index - 21) +else: # Expansion: Y101+ + hundred = index // 100 + unit = index % 100 + coil_address = base + 32 * hundred + (unit - 1) +``` + +#### Registers (Numeric Types) + +``` +register_address = base + width * (index - 1) +count = width * number_of_values +``` + +### Register Packing/Unpacking + +**int16:** Direct 16-bit value (signed or unsigned based on config) + +**int32 and float:** Little-endian, split across 2 registers +- Pack: Use struct to convert to 4 bytes, then unpack as two 16-bit values +- Unpack: Pack as 16-bit values, then unpack as 32-bit type + +### Text Handling + +TXT registers pack 2 ASCII characters per register with byte-swapping quirk: +- Each register stores low byte first, high byte second +- Reading requires byte swapping within each register + +**Odd/even alignment:** Must handle cases where start or end address falls in the middle of a register. + +**Writing:** Must write complete registers; fetch adjacent byte if writing single character. + +### Sparse Coil Handling + +When reading X/Y ranges that span gaps or slot boundaries: +- Read the required coil range from Modbus +- Map coils back to PLC addresses, skipping invalid address ranges +- Gaps: 017-020, 037-100 (same for both X and Y) + +When writing X/Y ranges that span gaps: +- Insert `False` padding values for the gaps between valid ranges + +--- + +## Test Scenarios + +### Construction + +1. Create with IP address only +2. Create with IP and tag file +3. Create with custom timeout +4. Invalid tag file path raises appropriate error +5. `plc.addr` is an `AddressInterface` instance +6. `plc.tag` is a `TagInterface` instance + +### AddressAccessor - Reading + +#### Single Values +7. `plc.df.read(1)` reads single float +8. `plc.ds.read(1)` reads single int16 +9. `plc.dd.read(1)` reads single int32 +10. `plc.dh.read(1)` reads single unsigned int16 +11. `plc.x.read(101)` reads single bool (sparse) +12. `plc.c.read(1)` reads single bool +13. `plc.txt.read(1)` reads single char + +#### Ranges (Inclusive) +14. `plc.df.read(1, 10)` reads DF1 through DF10 (10 values) +15. `plc.c.read(1, 100)` reads C1 through C100 +16. `plc.x.read(101, 116)` reads within single sparse hundred +17. `plc.x.read(101, 216)` reads across sparse boundary + +#### Edge Cases +18. Read at max address for each type +19. `plc.df.read(500)` (max DF address) +20. `plc.df.read(500, 500)` single value via range syntax + +### AddressAccessor - Writing + +21. `plc.df.write(1, 3.14)` writes single float +22. `plc.ds.write(1, 42)` writes single int16 +23. `plc.c.write(1, True)` writes single bool +24. `plc.df.write(1, [1.0, 2.0, 3.0])` writes consecutive +25. `plc.x.write(101, [True, False, True])` writes sparse + +#### Restricted Addresses +26. `plc.sc.write(53, True)` succeeds (writable) +27. `plc.sc.write(1, True)` raises ValueError (not writable) +28. `plc.sd.write(29, 100)` succeeds (writable) +29. `plc.sd.write(1, 100)` raises ValueError (not writable) + +### AddressInterface + +30. `plc.addr.read('df1')` returns single value +31. `plc.addr.read('df1-df10')` returns dict with 10 entries +32. `plc.addr.read('DF1')` works (case-insensitive) +33. `plc.addr.write('df1', 3.14)` writes single +34. `plc.addr.write('df1', [1.0, 2.0])` writes consecutive +35. `plc.addr.read('invalid1')` raises ValueError + +### TagInterface + +36. `plc.tag.read('ExistingTag')` returns value +37. `plc.tag.read('NonexistentTag')` raises KeyError +38. `plc.tag.read()` returns all tagged values +39. `plc.tag.read()` with no tags loaded raises ValueError +40. `plc.tag.write('ExistingTag', value)` writes +41. `plc.tag.read_all()` returns copy of tag definitions + +### Memory Bank Accessor Attributes + +42. `plc.df` returns AddressAccessor +43. `plc.DF` returns same AddressAccessor (case-insensitive) +44. `plc._private` raises AttributeError +45. `plc.invalid_bank` raises AttributeError +46. `repr(plc.df)` returns `` + +### Validation Errors + +47. `plc.addr.read('df0')` raises ValueError (below min) +48. `plc.addr.read('df501')` raises ValueError (above max) +49. `plc.addr.read('df10-df5')` raises ValueError (end <= start) +50. `plc.addr.read('df1-dd10')` raises ValueError (inter-bank) +51. `plc.x.read(17)` raises ValueError (invalid sparse: in gap 017-020) +52. `plc.x.read(37)` raises ValueError (invalid sparse: in gap 037-100) +53. `plc.df.write(1, 'string')` raises ValueError (wrong type) +54. `plc.ds.write(1, 3.14)` raises ValueError (float for int16) + +### Text Special Cases + +55. Read single char at odd position (`txt1`) +56. Read single char at even position (`txt2`) +57. Read range with odd start, odd end +58. Read range with even start, even end +59. Write string of odd length +60. Write string of even length + +### Sparse Coil Special Cases + +61. Read X in CPU slot 1 (`x001`, `x016`) +62. Read X in CPU slot 2 (`x021`, `x036`) +63. Read X in expansion slot (`x101`, `x116`) +64. Read X range within CPU slot 1 (`x001-x010`) +65. Read X range spanning CPU slots (`x010-x025`) +66. Read X range spanning to expansion (`x030-x105`) +67. Read Y in CPU slot 1 (`y001`, `y016`) +68. Read Y in CPU slot 2 (`y021`, `y036`) +69. Read Y in expansion slot (`y101`, `y116`) +70. Read Y range spanning CPU slots (`y010-y025`) +71. Write X values spanning CPU slot gap (`x014-x023`) +72. Write Y values spanning CPU slot gap (`y014-y023`) +73. Validate X rejects `x017`, `x020`, `x037`, `x100` +74. Validate Y rejects `y017`, `y020`, `y037`, `y100` + +--- + +## Error Messages + +Provide clear, actionable error messages: + +- `"'{bank}' is not a supported address type."` +- `"{BANK} address must be *01-*16."` (for sparse) +- `"{BANK} must be in [1, {max}]"` +- `"{BANK} end must be > start and <= {max}"` +- `"Inter-bank ranges are unsupported."` +- `"End address must be greater than start address."` +- `"Expected {address} as {expected_type}, got {actual_type}."` +- `"{BANK}{index} is not writable."` +- `"Tag '{name}' not found. Available: [...]"` +- `"No tags loaded. Provide a tag file or specify a tag name."` + +--- + +## Constants + +### STRUCT_FORMATS + +```python +{'int16': 'h', 'int32': 'i', 'float': 'f'} +``` + +### TYPE_MAP + +```python +{'bool': bool, 'int16': int, 'int32': int, 'float': (int, float), 'str': str} +``` diff --git a/spec/CLICKSERVER_SPEC.md b/spec/CLICKSERVER_SPEC.md new file mode 100644 index 0000000..b8ad54f --- /dev/null +++ b/spec/CLICKSERVER_SPEC.md @@ -0,0 +1,594 @@ +# ClickServer Specification + +A Modbus TCP server that simulates an AutomationDirect CLICK PLC. Incoming Modbus requests are reverse-mapped to PLC addresses and routed to a user-supplied DataProvider. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Dependencies](#dependencies) +3. [Shared Core Module](#shared-core-module) +4. [DataProvider Protocol](#dataprovider-protocol) +5. [MemoryDataProvider](#memorydataprovider) +6. [ClickServer Class](#clickserver-class) +7. [Internal Behavior](#internal-behavior) +8. [Validation Rules](#validation-rules) +9. [Error Handling](#error-handling) +10. [Test Scenarios](#test-scenarios) + +--- + +## Overview + +### Key Features + +- Async server using pymodbus +- User-supplied DataProvider decouples storage from Modbus transport +- Full CLICK PLC Modbus address space (coils and registers) +- Context manager support (`async with`) +- Reference `MemoryDataProvider` included for testing and simple use cases + +### Interface Summary + +```python +from pyclickplc.server import ClickServer, MemoryDataProvider + +# Simple in-memory simulator +provider = MemoryDataProvider() +provider.set('DF1', 3.14) +provider.set('X001', True) + +async with ClickServer(provider, port=5020) as server: + await server.serve_forever() +``` + +```python +# Integration test with ClickDriver +provider = MemoryDataProvider() +provider.set('DF1', 3.14) + +async with ClickServer(provider, port=5020) as server: + async with ClickDriver('localhost:5020') as plc: + value = await plc.df.read(1) # Returns 3.14 + await plc.ds.write(1, 42) + + stored = provider.get('DS1') # Returns 42 +``` + +### Request Flow + +``` +Modbus Client ClickServer DataProvider + | | | + |--- Read registers 28672-28675 --> | + | |-- reverse map 28672 -> DF1 --> | + | |-- reverse map 28674 -> DF2 --> | + | | read('DF1') --> + | | <-- 3.14 ------| + | | read('DF2') --> + | | <-- 0.0 -------| + | |-- pack [3.14, 0.0] as 4 regs | + |<-- [reg, reg, reg, reg] ------| | +``` + +--- + +## Dependencies + +### Runtime + +- `pymodbus` — Modbus TCP server implementation +- `pyclickplc.core` — Shared address definitions, mapping, and packing logic + +### Shared Core (`pyclickplc.core`) + +The following components are shared between ClickDriver and ClickServer. They are currently defined in the [ClickDevice Spec](CLICKDEVICE_SPEC.md) and must be extracted into a shared core module: + +- `AddressType` dataclass (frozen) +- All address type configurations (x, y, c, t, ct, sc, ds, dd, dh, df, td, ctd, sd, txt) +- Address string parsing: `parse_address(address: str) -> tuple[str, int]` +- Forward mapping: PLC address → Modbus address +- Register packing/unpacking (struct-based conversion for int16, int32, float) +- Text handling (packed ASCII with byte-swapping) +- Sparse addressing logic (X/Y coil slot mapping) +- Validation rules (range checks, sparse gap checks) +- Constants: `STRUCT_FORMATS`, `TYPE_MAP` + +The server adds one new core concern: **reverse mapping** (Modbus address → PLC address), which is the inverse of the forward mapping. + +--- + +## DataProvider Protocol + +The DataProvider is the user-supplied backend that stores and retrieves PLC values. The server translates Modbus requests into DataProvider calls. + +```python +class DataProvider(Protocol): + async def read(self, address: str) -> bool | int | float | str: + """Read a single PLC address. + + Args: + address: Uppercase PLC address string (e.g., 'DF1', 'X001', 'DS100') + + Returns: + Current value. Type must match the address bank: + - bool for X, Y, C, T, CT, SC + - int for DS, DD, DH, TD, CTD, SD + - float for DF + - str for TXT (single character) + """ + ... + + async def write(self, address: str, value: bool | int | float | str) -> None: + """Write a value to a single PLC address. + + Args: + address: Uppercase PLC address string + value: Value to write. Type matches the address bank. + """ + ... +``` + +### Contract + +- The server calls `read()`/`write()` once per PLC address per Modbus request. A Modbus read of 10 consecutive DF registers results in 5 `read()` calls (DF is width-2). +- Address strings are always uppercase with no spaces: `'DF1'`, `'X001'`, `'DS100'`. +- The server validates writability (SC/SD restrictions) **before** calling `write()`. The DataProvider does not need to enforce writability. +- The server handles all Modbus packing/unpacking. The DataProvider only deals in native Python types. +- If `read()` returns a value of the wrong type, behavior is undefined. + +--- + +## MemoryDataProvider + +Reference implementation that stores values in an in-memory dictionary. + +```python +class MemoryDataProvider: + def __init__(self) -> None: ... + + async def read(self, address: str) -> bool | int | float | str: ... + async def write(self, address: str, value: bool | int | float | str) -> None: ... + + # Synchronous convenience methods for setup and inspection + def set(self, address: str, value: bool | int | float | str) -> None: ... + def get(self, address: str) -> bool | int | float | str: ... + def bulk_set(self, values: dict[str, bool | int | float | str]) -> None: ... +``` + +### Behavior + +- Values stored in `dict[str, bool | int | float | str]` +- `read()` returns stored value, or a **type-appropriate default** if never written: + +| Bank Data Type | Default | +|----------------|---------| +| `bool` (X, Y, C, T, CT, SC) | `False` | +| `int16` (DS, TD) | `0` | +| `int32` (DD, CTD) | `0` | +| `int16` unsigned (DH, SD) | `0` | +| `float` (DF) | `0.0` | +| `str` (TXT) | `'\x00'` | + +- `write()` stores the value +- `set()` / `get()` are synchronous wrappers for test setup and inspection +- `bulk_set()` calls `set()` for each entry +- Address strings are normalized to uppercase internally +- Determines the bank's data type from the shared core address type configuration + +--- + +## ClickServer Class + +### Constructor + +```python +def __init__(self, provider: DataProvider, host: str = 'localhost', port: int = 502) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `provider` | `DataProvider` | required | User-supplied value storage backend | +| `host` | `str` | `'localhost'` | Interface to bind (`'localhost'` for local-only, `'0.0.0.0'` for all) | +| `port` | `int` | `502` | TCP port (502 is Modbus default; use 5020+ for non-root testing) | + +### Instance Attributes + +- `provider: DataProvider` — The value storage backend +- `host: str` — Bound interface +- `port: int` — Bound port + +### Lifecycle Methods + +```python +async def start(self) -> None: + """Start the Modbus TCP server. Returns immediately; server runs in background.""" + +async def stop(self) -> None: + """Stop the server gracefully.""" + +async def serve_forever(self) -> None: + """Start the server and block until stop() is called or task is cancelled.""" +``` + +### Context Manager + +```python +async with ClickServer(provider, port=5020) as server: + # server.start() has been called + ... +# server.stop() is called on exit +``` + +- `__aenter__` calls `start()`, returns `self` +- `__aexit__` calls `stop()` + +--- + +## Internal Behavior + +### Reverse Mapping: Modbus Address → PLC Address + +The inverse of the driver's forward mapping. Given a raw Modbus coil or register number, determine the PLC bank and index. + +#### Coil Reverse Mapping + +Coil banks (from shared core): + +| Bank | Base | Address Space Size | +|------|------|--------------------| +| x | 0 | 832 (26 slots * 32) | +| y | 8192 | 832 | +| c | 16384 | 2000 | +| t | 45057 | 500 | +| ct | 49152 | 250 | +| sc | 61440 | 1000 | + +``` +For each coil bank in [x, y, c, t, ct, sc]: + end = base + address_space_size + if base <= coil_address < end: + offset = coil_address - base + if bank.sparse: + return reverse_sparse(bank, offset) + else: + index = offset + 1 + return f"{BANK}{index}" +return None # Unmapped +``` + +#### Sparse Coil Reverse Mapping (X, Y) + +``` +offset = coil_address - base + +if offset < 16: # CPU slot 1 + index = offset + 1 # *001-*016 + +elif offset < 32: # CPU slot 2 + index = 21 + (offset - 16) # *021-*036 + +else: # Expansion slots + hundred = offset // 32 # Slot number (1-8) + unit = (offset % 32) + 1 # Position within slot + if unit > 16: + return None # Gap — unmapped + index = hundred * 100 + unit # *101-*116, *201-*216, ... + +return f"{BANK}{index:03d}" +``` + +#### Register Reverse Mapping + +Register banks (from shared core): + +| Bank | Base | Max | Width | End Address | +|------|------|-----|-------|-------------| +| ds | 0 | 4500 | 1 | 4500 | +| dd | 16384 | 1000 | 2 | 18384 | +| dh | 24576 | 500 | 1 | 25076 | +| df | 28672 | 500 | 2 | 29672 | +| txt | 36864 | 1000 | 1 | 37864 | +| td | 45056 | 500 | 1 | 45556 | +| ctd | 49152 | 250 | 2 | 49652 | +| sd | 61440 | 1000 | 1 | 62440 | + +``` +For each register bank in [ds, dd, dh, df, txt, td, ctd, sd]: + end = base + width * max_addr + if base <= register_address < end: + offset = register_address - base + index = offset // width + 1 + reg_position = offset % width # 0 = first register, 1 = second (width-2 only) + return (bank, index, reg_position) +return None # Unmapped +``` + +> **Note:** `reg_position` is needed for FC 06 (write single register) on width-2 types. See [Register Write Handling](#register-write-handling). + +#### Unmapped Addresses + +Modbus addresses that do not map to any PLC bank return default values without calling the DataProvider: +- Unmapped coils → `False` +- Unmapped registers → `0` + +This matches real CLICK PLC behavior for undefined address space. + +### Supported Function Codes + +| FC | Name | Supported | +|----|------|-----------| +| 01 | Read Coils | Yes | +| 02 | Read Discrete Inputs | Yes (same as FC 01) | +| 03 | Read Holding Registers | Yes | +| 04 | Read Input Registers | Yes (same as FC 03) | +| 05 | Write Single Coil | Yes | +| 06 | Write Single Register | Yes | +| 15 | Write Multiple Coils | Yes | +| 16 | Write Multiple Registers | Yes | + +> **Note:** The CLICK PLC does not distinguish between holding/input registers or coils/discrete inputs. FC 01 and FC 02 behave identically, as do FC 03 and FC 04. + +### Coil Read Handling (FC 01/02) + +1. For each coil in `[address, address + count)`: + a. Reverse-map to PLC address + b. If mapped: call `provider.read(plc_address)` → `bool` + c. If unmapped (gap or undefined): return `False` +2. Return list of bool values + +### Coil Write Handling + +**FC 05 — Write Single Coil:** + +1. Reverse-map coil address to PLC address +2. If unmapped: raise Modbus `IllegalAddress` exception +3. Validate writability (SC restrictions) +4. Call `provider.write(plc_address, bool_value)` + +**FC 15 — Write Multiple Coils:** + +1. For each coil in the write range: + a. Reverse-map to PLC address + b. If unmapped (sparse gap): skip silently + c. If mapped but not writable: raise Modbus `IllegalAddress` exception + d. Call `provider.write(plc_address, value)` + +### Register Read Handling (FC 03/04) + +1. For each register in `[address, address + count)`: + a. Reverse-map to `(bank, index, reg_position)` + b. If unmapped: yield `0` + c. If mapped: collect into groups by `(bank, index)` +2. For each unique PLC address, call `provider.read(plc_address)` once +3. Pack each value into register(s) using shared core packing logic +4. Return concatenated register values in order + +**Optimization:** When reading a range of consecutive addresses within one bank, the server can determine the full set of PLC addresses up front and batch the reads. + +### Register Write Handling + +**FC 16 — Write Multiple Registers:** + +1. Determine which PLC addresses are covered by the write range +2. Group registers by PLC address +3. For each **complete** PLC address (all `width` registers present): + a. Validate writability (SD restrictions) + b. Unpack value from register(s) using shared core logic + c. Call `provider.write(plc_address, unpacked_value)` +4. For **partial** PLC addresses at boundaries (width-2 type where only 1 register is in the write range): + a. Read current value via `provider.read(plc_address)` + b. Replace the affected register half + c. Unpack the combined registers + d. Call `provider.write(plc_address, new_value)` + +**FC 06 — Write Single Register:** + +FC 06 writes exactly one 16-bit register. + +- **Width-1 types** (DS, DH, TXT, TD, SD): Unpack and write directly. +- **Width-2 types** (DD, DF, CTD): Read-modify-write: + 1. Call `provider.read(plc_address)` to get current value + 2. Pack current value into 2 registers + 3. Replace the register at `reg_position` with the new value + 4. Unpack the modified pair + 5. Call `provider.write(plc_address, new_value)` + +### pymodbus Integration + +The server uses pymodbus's `StartAsyncTcpServer` with a custom `ModbusSlaveContext`. The recommended implementation approach is a custom datastore (subclass of `ModbusBaseSlaveContext` or equivalent) that overrides value access to route through the reverse mapping and DataProvider. + +--- + +## Validation Rules + +### Writability + +The server enforces writability restrictions **before** calling `provider.write()`: + +- **SC coils:** Only addresses in `{53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}` are writable +- **SD registers:** Only addresses in `{29, 31, 32, 34, 35, 36, 40, 41, 42, 50, 51, 60, 61, 106, 107, 108, 112, 113, 114, 140, 141, 142, 143, 144, 145, 146, 147, 214, 215}` are writable + +Writing to a non-writable SC or SD address raises a Modbus `IllegalAddress` exception. + +All other banks are fully writable within their address range. + +### Address Range + +The server accepts Modbus requests for any valid address in each bank's range. Requests beyond a bank's address space that don't fall in another bank return defaults (reads) or are rejected (writes). + +--- + +## Error Handling + +### Modbus Exceptions + +The server returns standard Modbus exception responses: + +| Condition | Modbus Exception | +|-----------|-----------------| +| Write to unmapped address | `IllegalAddress` (0x02) | +| Write to non-writable SC/SD | `IllegalAddress` (0x02) | +| DataProvider raises exception | `SlaveDeviceFailure` (0x04) | + +### DataProvider Errors + +If the DataProvider raises an exception during `read()` or `write()`, the server: +1. Catches the exception +2. Returns a Modbus `SlaveDeviceFailure` exception to the client +3. Logs the error (if logging is configured) + +The server never crashes due to a DataProvider error. + +--- + +## Test Scenarios + +### Construction + +1. Create with MemoryDataProvider and default host/port +2. Create with custom host and port +3. Provider is stored as instance attribute + +### Reverse Mapping — Coils + +4. Coil `0` → `X001` +5. Coil `15` → `X016` +6. Coil `16` → `X021` +7. Coil `31` → `X036` +8. Coil `32` → `X101` +9. Coil `47` → `X116` +10. Coil `64` → `X201` +11. Coil `8192` → `Y001` +12. Coil `8208` → `Y021` +13. Coil `8224` → `Y101` +14. Coil `16384` → `C1` +15. Coil `16385` → `C2` +16. Coil `18383` → `C2000` +17. Coil `45057` → `T1` +18. Coil `49152` → `CT1` +19. Coil `61440` → `SC1` + +### Reverse Mapping — Sparse Gaps + +20. Coil `48` (X slot 1, unit 17) → unmapped +21. Coil `8240` (Y slot 1, unit 17) → unmapped +22. Verify all gap coils in CPU slot boundary (16-31 maps to *021-*036, not gaps) + +### Reverse Mapping — Registers + +23. Register `0` → `DS1` +24. Register `4499` → `DS4500` +25. Register `16384` → `DD1` +26. Register `16386` → `DD2` (width-2, second value) +27. Register `16385` → `DD1`, reg_position=1 (second register of DD1) +28. Register `24576` → `DH1` +29. Register `25075` → `DH500` +30. Register `28672` → `DF1` +31. Register `28674` → `DF2` +32. Register `36864` → `TXT1` +33. Register `45056` → `TD1` +34. Register `49152` → `CTD1` +35. Register `49154` → `CTD2` +36. Register `61440` → `SD1` + +### Reverse Mapping — Unmapped + +37. Coil `10000` (between Y and C) → unmapped, returns `False` +38. Register `5000` (between DS and DD) → unmapped, returns `0` + +### Read via Modbus — Coils + +39. Read single coil (C1) → `provider.read('C1')` called, bool returned +40. Read coil range (C1-C10) → `provider.read()` called 10 times +41. Read sparse coil (X001) → correct reverse mapping and read +42. Read sparse range spanning CPU slots (X010-X025) → correct gap handling +43. Read unmapped coil → `False` returned, provider not called + +### Read via Modbus — Registers + +44. Read single int16 register (DS1) → correct value +45. Read single unsigned int16 (DH1) → correct unsigned value +46. Read width-2 float (DF1) → 2 registers packed correctly +47. Read width-2 int32 (DD1) → 2 registers packed correctly +48. Read register range (DF1-DF5) → 10 registers, 5 provider calls +49. Read unmapped register → `0` returned, provider not called + +### Write via Modbus — Coils + +50. FC 05: Write single coil (C1=True) → `provider.write('C1', True)` called +51. FC 15: Write multiple coils (C1-C5) → 5 `provider.write()` calls +52. FC 05: Write unmapped coil → Modbus `IllegalAddress` +53. FC 05: Write non-writable SC (SC1) → Modbus `IllegalAddress` +54. FC 05: Write writable SC (SC53) → succeeds +55. FC 15: Write sparse coils spanning gap → gap addresses skipped + +### Write via Modbus — Registers + +56. FC 06: Write single int16 register (DS1=42) → `provider.write('DS1', 42)` called +57. FC 16: Write multiple registers for consecutive DS values +58. FC 16: Write float (DF1=3.14) → 2 registers unpacked to float, `provider.write('DF1', 3.14)` called +59. FC 16: Write int32 (DD1=100000) → 2 registers unpacked correctly +60. FC 06: Write single register of width-2 type (DF1, first half) → read-modify-write +61. FC 16: Partial write at boundary of width-2 type → read-modify-write for partial value +62. FC 06: Write unmapped register → Modbus `IllegalAddress` +63. FC 06: Write non-writable SD (SD1) → Modbus `IllegalAddress` +64. FC 06: Write writable SD (SD29) → succeeds + +### MemoryDataProvider + +65. Read unset bool address → `False` +66. Read unset int address → `0` +67. Read unset float address → `0.0` +68. Read unset text address → `'\x00'` +69. Write then read returns written value +70. `set()` then `get()` returns value (sync) +71. `set()` then `read()` returns value (async reads sync-set data) +72. `bulk_set()` sets multiple values +73. Address normalization: `set('df1', 1.0)` then `get('DF1')` returns `1.0` + +### Server Lifecycle + +74. Context manager: server starts and stops cleanly +75. Explicit `start()` / `stop()` lifecycle +76. `serve_forever()` blocks until `stop()` called from another task +77. Multiple start/stop cycles work correctly +78. Stop while no clients connected +79. Stop while client connected — connection closed gracefully + +### DataProvider Error Handling + +80. Provider `read()` raises → Modbus `SlaveDeviceFailure` returned +81. Provider `write()` raises → Modbus `SlaveDeviceFailure` returned +82. Server continues operating after provider error + +### Integration (ClickDriver ↔ ClickServer) + +83. Driver writes DF, provider sees value via `get()` +84. Provider `set()` value, driver reads it +85. Float round-trip preserves value (within float32 precision) +86. Int32 round-trip preserves value +87. Int16 signed round-trip (positive and negative) +88. Int16 unsigned (DH) round-trip +89. Bool round-trip +90. Text round-trip +91. Sparse coil (X/Y) round-trip +92. Read range via driver matches individual provider values + +--- + +## Summary: Shared vs. Server-Specific + +| Component | Location | +|-----------|----------| +| AddressType, bank configs, constants | `pyclickplc.core` (shared) | +| Address parsing, validation | `pyclickplc.core` (shared) | +| Forward mapping (PLC → Modbus) | `pyclickplc.core` (shared) | +| Register packing/unpacking | `pyclickplc.core` (shared) | +| Sparse addressing, text handling | `pyclickplc.core` (shared) | +| **Reverse mapping (Modbus → PLC)** | `pyclickplc.core` (shared, new) | +| DataProvider protocol | `pyclickplc.server` | +| MemoryDataProvider | `pyclickplc.server` | +| ClickServer class | `pyclickplc.server` | +| pymodbus server integration | `pyclickplc.server` | diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md new file mode 100644 index 0000000..bf8c45a --- /dev/null +++ b/spec/HANDOFF.md @@ -0,0 +1,60 @@ +# Handoff: pyclickplc Implementation Order + +See `ARCHITECTURE.md` for full module layout, dependency graph, and design decisions. + +--- + +## Starting Point + +- pyclickplc: empty `src/pyclickplc/__init__.py`, no code yet +- ClickNick: working app, Phase 0 (unique block names) done +- Specs written: `CLICKDEVICE_SPEC.md` (client), `CLICKSERVER_SPEC.md` (server) + +## Step 1: `banks.py` + `addresses.py` in pyclickplc + +Build the foundation directly in pyclickplc — don't fix ClickNick first. + +- `BankConfig` frozen dataclass with `valid_ranges` for sparse X/Y +- `DataType` enum (canonical, from ClickNick's existing `DataType`) +- All bank definitions (`BANKS` dict) +- Address parsing/formatting functions (one parser, shared by all consumers) +- Sparse address validation using `valid_ranges` +- `AddressRecord` frozen dataclass +- `validation.py` (nickname/comment/initial value rules) + +Test thoroughly — this is the foundation everything else builds on. + +## Step 2: Wire ClickNick to pyclickplc + +- Replace `from ..models.constants import ADDRESS_RANGES, DataType, ...` with pyclickplc imports +- Update ClickNick's X/Y display to filter using `BankConfig.valid_ranges` +- Delete moved code from ClickNick's `constants.py` and `address_row.py` +- Run ClickNick's existing tests to verify nothing broke + +## Step 3: `blocks.py` + `nicknames.py` + `dataview.py` + +Extract remaining ClickNick shared code into pyclickplc: + +- BlockTag system → `blocks.py` +- CSV read/write → `nicknames.py` +- CDV file I/O → `dataview.py` +- Update ClickNick imports, delete moved code + +## Step 4: `modbus.py` + +New code — Modbus protocol mapping layer: + +- `ModbusMapping` definitions for all banks (XD/YD pending hardware testing) +- Forward/reverse address mapping +- Register packing/unpacking +- Sparse coil offset calculation (reads `valid_ranges` from `BankConfig`) + +Can be developed in parallel with Step 3. + +## Step 5: `client.py` + `server.py` + +New code per the existing specs: + +- `ClickDriver` (Modbus TCP client) per `CLICKDEVICE_SPEC.md` +- `ClickServer` (Modbus TCP server) per `CLICKSERVER_SPEC.md` +- Integration tests: driver ↔ server round-trips From 7094c466b5e6234f8841d6675ba03c3d7b2a258c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:20:30 -0500 Subject: [PATCH 02/55] Add banks, addresses, and validation foundation modules Extract PLC memory bank configuration, address parsing, and validation logic from ClickNick into standalone pyclickplc modules. Introduces BankConfig frozen dataclass, parse_address strict parser, and simplified AddressRecord model. 93 tests. Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 82 ++++++++++ src/pyclickplc/addresses.py | 302 +++++++++++++++++++++++++++++++++++ src/pyclickplc/banks.py | 218 +++++++++++++++++++++++++ src/pyclickplc/validation.py | 181 +++++++++++++++++++++ tests/test_addresses.py | 287 +++++++++++++++++++++++++++++++++ tests/test_banks.py | 213 ++++++++++++++++++++++++ tests/test_placeholder.py | 3 - tests/test_validation.py | 171 ++++++++++++++++++++ 8 files changed, 1454 insertions(+), 3 deletions(-) create mode 100644 src/pyclickplc/addresses.py create mode 100644 src/pyclickplc/banks.py create mode 100644 src/pyclickplc/validation.py create mode 100644 tests/test_addresses.py create mode 100644 tests/test_banks.py delete mode 100644 tests/test_placeholder.py create mode 100644 tests/test_validation.py diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index e69de29..e91357f 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -0,0 +1,82 @@ +"""pyclickplc - Utilities for AutomationDirect CLICK PLCs.""" + +from .addresses import ( + AddressRecord, + format_address_display, + get_addr_key, + normalize_address, + parse_addr_key, + parse_address, + parse_address_display, +) +from .banks import ( + BANKS, + BIT_ONLY_TYPES, + DATA_TYPE_DISPLAY, + DATA_TYPE_HINTS, + DEFAULT_RETENTIVE, + INTERLEAVED_PAIRS, + INTERLEAVED_TYPE_PAIRS, + MEMORY_TYPE_BASES, + MEMORY_TYPE_TO_DATA_TYPE, + NON_EDITABLE_TYPES, + PAIRED_RETENTIVE_TYPES, + BankConfig, + DataType, + is_valid_address, +) +from .validation import ( + COMMENT_MAX_LENGTH, + FLOAT_MAX, + FLOAT_MIN, + FORBIDDEN_CHARS, + INT2_MAX, + INT2_MIN, + INT_MAX, + INT_MIN, + NICKNAME_MAX_LENGTH, + RESERVED_NICKNAMES, + validate_comment, + validate_initial_value, + validate_nickname, +) + +__all__ = [ + # banks + "BankConfig", + "BANKS", + "DataType", + "DATA_TYPE_DISPLAY", + "DATA_TYPE_HINTS", + "DEFAULT_RETENTIVE", + "INTERLEAVED_PAIRS", + "INTERLEAVED_TYPE_PAIRS", + "MEMORY_TYPE_BASES", + "MEMORY_TYPE_TO_DATA_TYPE", + "NON_EDITABLE_TYPES", + "PAIRED_RETENTIVE_TYPES", + "BIT_ONLY_TYPES", + "is_valid_address", + # addresses + "AddressRecord", + "get_addr_key", + "parse_addr_key", + "format_address_display", + "parse_address_display", + "parse_address", + "normalize_address", + # validation + "NICKNAME_MAX_LENGTH", + "COMMENT_MAX_LENGTH", + "FORBIDDEN_CHARS", + "RESERVED_NICKNAMES", + "INT_MIN", + "INT_MAX", + "INT2_MIN", + "INT2_MAX", + "FLOAT_MIN", + "FLOAT_MAX", + "validate_nickname", + "validate_comment", + "validate_initial_value", +] diff --git a/src/pyclickplc/addresses.py b/src/pyclickplc/addresses.py new file mode 100644 index 0000000..801da05 --- /dev/null +++ b/src/pyclickplc/addresses.py @@ -0,0 +1,302 @@ +"""Address parsing, formatting, and the AddressRecord data model. + +Provides addr_key calculations, XD/YD helpers, address display formatting, +and the AddressRecord frozen dataclass for representing PLC addresses. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .banks import ( + _INDEX_TO_TYPE, + BANKS, + DATA_TYPE_DISPLAY, + DEFAULT_RETENTIVE, + MEMORY_TYPE_BASES, + NON_EDITABLE_TYPES, + DataType, + is_valid_address, +) + +# ============================================================================== +# AddrKey Functions +# ============================================================================== + + +def get_addr_key(memory_type: str, address: int) -> int: + """Calculate AddrKey from memory type and MDB address. + + Args: + memory_type: The memory type (X, Y, C, etc.) + address: The MDB address number + + Returns: + The AddrKey value used as primary key in MDB + + Raises: + KeyError: If memory_type is not recognized + """ + return MEMORY_TYPE_BASES[memory_type] + address + + +def parse_addr_key(addr_key: int) -> tuple[str, int]: + """Parse an AddrKey back to memory type and MDB address. + + Args: + addr_key: The AddrKey value from MDB + + Returns: + Tuple of (memory_type, mdb_address) + + Raises: + KeyError: If the type index is not recognized + """ + type_index = addr_key >> 24 + address = addr_key & 0xFFFFFF + return _INDEX_TO_TYPE[type_index], address + + +# ============================================================================== +# XD/YD Helpers +# ============================================================================== + + +def is_xd_yd_upper_byte(memory_type: str, mdb_address: int) -> bool: + """Check if an XD/YD MDB address is for an upper byte (only XD0u/YD0u at MDB 1).""" + if memory_type in ("XD", "YD"): + return mdb_address == 1 + return False + + +def is_xd_yd_hidden_slot(memory_type: str, mdb_address: int) -> bool: + """Check if an XD/YD MDB address is a hidden slot (odd addresses > 1).""" + if memory_type in ("XD", "YD"): + return mdb_address >= 3 and mdb_address % 2 == 1 + return False + + +def xd_yd_mdb_to_display(mdb_address: int) -> int: + """Convert XD/YD MDB address to display address number. + + MDB 0 -> 0 (XD0), MDB 1 -> 0 (XD0u), MDB 2 -> 1, ..., MDB 16 -> 8. + """ + if mdb_address <= 1: + return 0 + return mdb_address // 2 + + +def xd_yd_display_to_mdb(display_addr: int, upper_byte: bool = False) -> int: + """Convert XD/YD display address to MDB address. + + Args: + display_addr: The display address (0-8) + upper_byte: True for XD0u/YD0u (only valid for display_addr=0) + + Returns: + The MDB address + """ + if display_addr == 0: + return 1 if upper_byte else 0 + return display_addr * 2 + + +# ============================================================================== +# Address Display Functions +# ============================================================================== + + +def format_address_display(memory_type: str, mdb_address: int) -> str: + """Format a memory type and MDB address as a display string. + + X/Y are 3-digit zero-padded. XD/YD use special encoding. Others are unpadded. + """ + if memory_type in ("XD", "YD"): + if mdb_address == 0: + return f"{memory_type}0" + elif mdb_address == 1: + return f"{memory_type}0u" + else: + display_addr = mdb_address // 2 + return f"{memory_type}{display_addr}" + if memory_type in ("X", "Y"): + return f"{memory_type}{mdb_address:03d}" + return f"{memory_type}{mdb_address}" + + +def parse_address_display(address_str: str) -> tuple[str, int] | None: + """Parse a display address string to memory type and MDB address. + + Lenient: returns None on invalid input. + For XD/YD, returns MDB address: "XD1" -> ("XD", 2). + + Args: + address_str: Address string like "X001", "XD0", "XD0u", "XD8" + + Returns: + Tuple of (memory_type, mdb_address) or None if invalid + """ + if not address_str: + return None + + address_str = address_str.strip().upper() + + match = re.match(r"^([A-Z]+)(\d+)(U?)$", address_str) + if not match: + return None + + memory_type = match.group(1) + display_addr = int(match.group(2)) + is_upper = match.group(3) == "U" + + if memory_type not in MEMORY_TYPE_BASES: + return None + + if memory_type in ("XD", "YD"): + if is_upper and display_addr != 0: + return None # Invalid: XD1u, XD2u, etc. don't exist + return memory_type, xd_yd_display_to_mdb(display_addr, is_upper) + + return memory_type, display_addr + + +def parse_address(address_str: str) -> tuple[str, int]: + """Parse an address string to (bank_name, display_address). + + Strict: raises ValueError on invalid input. + Returns display/logical address, NOT MDB encoding. + For XD/YD: "XD1" -> ("XD", 1), unlike parse_address_display which returns ("XD", 2). + + Args: + address_str: Address string like "X001", "DS100", "XD1" + + Returns: + Tuple of (bank_name, display_address) + + Raises: + ValueError: If the address string is invalid + """ + if not address_str or not address_str.strip(): + raise ValueError(f"Empty address: {address_str!r}") + + address_str = address_str.strip().upper() + + match = re.match(r"^([A-Z]+)(\d+)(U?)$", address_str) + if not match: + raise ValueError(f"Invalid address format: {address_str!r}") + + bank_name = match.group(1) + addr_num = int(match.group(2)) + is_upper = match.group(3) == "U" + + if bank_name not in BANKS: + raise ValueError(f"Unknown bank: {bank_name!r}") + + if bank_name in ("XD", "YD"): + if is_upper and addr_num != 0: + raise ValueError(f"Invalid upper byte address: {address_str!r}") + # Return display address directly (not MDB encoding) + # For XD/YD, valid display addresses are 0-8 (plus 0u) + if is_upper: + return bank_name, 0 # XD0u -> display addr 0 + if addr_num < 0 or addr_num > 8: + raise ValueError(f"Address out of range: {address_str!r}") + return bank_name, addr_num + + if not is_valid_address(bank_name, addr_num): + raise ValueError(f"Address out of range: {address_str!r}") + + return bank_name, addr_num + + +def normalize_address(address: str) -> str | None: + """Normalize an address string to its canonical display form. + + E.g., "x1" -> "X001", "xd0u" -> "XD0u". + + Returns: + The normalized display address, or None if address is invalid. + """ + parsed = parse_address_display(address) + if not parsed: + return None + memory_type, mdb_address = parsed + return format_address_display(memory_type, mdb_address) + + +# ============================================================================== +# AddressRecord Data Model +# ============================================================================== + + +@dataclass(frozen=True) +class AddressRecord: + """Immutable record for a single PLC address. + + Simpler than ClickNick's AddressRow -- omits all UI validation state. + """ + + # --- Identity --- + memory_type: str # 'X', 'Y', 'C', etc. + address: int # MDB address (1, 2, 3... or 0 for XD/YD) + + # --- Content --- + nickname: str = "" + comment: str = "" + initial_value: str = "" + retentive: bool = False + + # --- Metadata --- + data_type: int = DataType.BIT + used: bool | None = None # None = unknown + + @property + def addr_key(self) -> int: + """Get the AddrKey for this record.""" + return get_addr_key(self.memory_type, self.address) + + @property + def display_address(self) -> str: + """Get the display string for this address.""" + return format_address_display(self.memory_type, self.address) + + @property + def data_type_display(self) -> str: + """Get human-readable data type name.""" + return DATA_TYPE_DISPLAY.get(self.data_type, "") + + @property + def is_default_retentive(self) -> bool: + """Return True if retentive matches the default for this memory_type.""" + default = DEFAULT_RETENTIVE.get(self.memory_type, False) + return self.retentive == default + + @property + def is_default_initial_value(self) -> bool: + """Return True if the initial value is the default for its data type.""" + return ( + self.initial_value == "" + or self.data_type != DataType.TXT + and str(self.initial_value) == "0" + ) + + @property + def has_content(self) -> bool: + """True if record has any user-defined content worth saving.""" + return ( + self.nickname != "" + or self.comment != "" + or not self.is_default_initial_value + or not self.is_default_retentive + ) + + @property + def can_edit_initial_value(self) -> bool: + """True if initial value can be edited for this memory type.""" + return self.memory_type not in NON_EDITABLE_TYPES + + @property + def can_edit_retentive(self) -> bool: + """True if retentive setting can be edited for this memory type.""" + return self.memory_type not in NON_EDITABLE_TYPES diff --git a/src/pyclickplc/banks.py b/src/pyclickplc/banks.py new file mode 100644 index 0000000..9922092 --- /dev/null +++ b/src/pyclickplc/banks.py @@ -0,0 +1,218 @@ +"""PLC memory bank configuration and address validation. + +Defines the 16 memory banks of AutomationDirect CLICK PLCs with their +address ranges, data types, and interleaving relationships. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + +# ============================================================================== +# Data Types +# ============================================================================== + + +class DataType(IntEnum): + """DataType mapping from MDB database.""" + + BIT = 0 # C, CT, SC, T, X, Y - values: "0" or "1" + INT = 1 # DS, SD, TD - 16-bit signed: -32768 to 32767 + INT2 = 2 # CTD, DD - 32-bit signed: -2147483648 to 2147483647 + FLOAT = 3 # DF - float: -3.4028235E+38 to 3.4028235E+38 + HEX = 4 # DH, XD, YD - hex string: "0000" to "FFFF" + TXT = 6 # TXT - single ASCII character + + +# Display names for DataType values +DATA_TYPE_DISPLAY: dict[int, str] = { + DataType.BIT: "BIT", + DataType.INT: "INT", + DataType.INT2: "INT2", + DataType.FLOAT: "FLOAT", + DataType.HEX: "HEX", + DataType.TXT: "TXT", +} + +# Hint text for initial value fields by DataType +DATA_TYPE_HINTS: dict[int, str] = { + DataType.BIT: "0 or 1 (checkbox)", + DataType.INT: "Range: `-32768` to `32767`", + DataType.INT2: "Range: `-2147483648` to `2147483647`", + DataType.FLOAT: "Range: `-3.4028235E+38` to `3.4028235E+38`", + DataType.HEX: "Range: '0000' to 'FFFF'", + DataType.TXT: "Single ASCII char: eg 'A'", +} + + +# ============================================================================== +# Bank Configuration +# ============================================================================== + + +# X/Y sparse address ranges: 10 hardware slots of 16 addresses each +_SPARSE_RANGES: tuple[tuple[int, int], ...] = ( + (1, 16), + (21, 36), + (101, 116), + (121, 136), + (201, 216), + (221, 236), + (301, 316), + (321, 336), + (801, 816), + (821, 836), +) + + +@dataclass(frozen=True) +class BankConfig: + """Configuration for a single PLC memory bank.""" + + name: str + min_addr: int + max_addr: int + data_type: DataType + valid_ranges: tuple[tuple[int, int], ...] | None = None # None = contiguous + interleaved_with: str | None = None + + +BANKS: dict[str, BankConfig] = { + "X": BankConfig("X", 1, 816, DataType.BIT, valid_ranges=_SPARSE_RANGES), + "Y": BankConfig("Y", 1, 816, DataType.BIT, valid_ranges=_SPARSE_RANGES), + "C": BankConfig("C", 1, 2000, DataType.BIT), + "T": BankConfig("T", 1, 500, DataType.BIT, interleaved_with="TD"), + "CT": BankConfig("CT", 1, 250, DataType.BIT, interleaved_with="CTD"), + "SC": BankConfig("SC", 1, 1000, DataType.BIT), + "DS": BankConfig("DS", 1, 4500, DataType.INT), + "DD": BankConfig("DD", 1, 1000, DataType.INT2), + "DH": BankConfig("DH", 1, 500, DataType.HEX), + "DF": BankConfig("DF", 1, 500, DataType.FLOAT), + "XD": BankConfig("XD", 0, 16, DataType.HEX), + "YD": BankConfig("YD", 0, 16, DataType.HEX), + "TD": BankConfig("TD", 1, 500, DataType.INT, interleaved_with="T"), + "CTD": BankConfig("CTD", 1, 250, DataType.INT2, interleaved_with="CT"), + "SD": BankConfig("SD", 1, 1000, DataType.INT), + "TXT": BankConfig("TXT", 1, 1000, DataType.TXT), +} + + +# ============================================================================== +# Derived Constants +# ============================================================================== + + +# DataType by memory type (derived from BANKS) +MEMORY_TYPE_TO_DATA_TYPE: dict[str, int] = {name: b.data_type for name, b in BANKS.items()} + +# Types that only hold bit values +BIT_ONLY_TYPES: frozenset[str] = frozenset( + name for name, b in BANKS.items() if b.data_type == DataType.BIT +) + + +# ============================================================================== +# Legacy Dicts (for addr_key system and ClickNick compatibility) +# ============================================================================== + + +# Base values for AddrKey calculation (Primary Key in MDB) +MEMORY_TYPE_BASES: dict[str, int] = { + "X": 0x0000000, + "Y": 0x1000000, + "C": 0x2000000, + "T": 0x3000000, + "CT": 0x4000000, + "SC": 0x5000000, + "DS": 0x6000000, + "DD": 0x7000000, + "DH": 0x8000000, + "DF": 0x9000000, + "XD": 0xA000000, + "YD": 0xB000000, + "TD": 0xC000000, + "CTD": 0xD000000, + "SD": 0xE000000, + "TXT": 0xF000000, +} + +# Reverse mapping: type_index -> memory_type +_INDEX_TO_TYPE: dict[int, str] = {v >> 24: k for k, v in MEMORY_TYPE_BASES.items()} + +# Default retentive values by memory type (from CLICK documentation) +DEFAULT_RETENTIVE: dict[str, bool] = { + "X": False, + "Y": False, + "C": False, + "T": False, + "CT": True, # Counters are retentive by default + "SC": False, # Can't change + "DS": True, # Data registers are retentive by default + "DD": True, + "DH": True, + "DF": True, + "XD": False, # Can't change + "YD": False, # Can't change + "TD": False, # See note + "CTD": True, # See note + "SD": False, # Can't change + "TXT": True, +} + +# Canonical set of interleaved type pairs +INTERLEAVED_TYPE_PAIRS: frozenset[frozenset[str]] = frozenset( + { + frozenset({"T", "TD"}), + frozenset({"CT", "CTD"}), + } +) + +# Bidirectional lookup for interleaved pairs +INTERLEAVED_PAIRS: dict[str, str] = { + "T": "TD", + "TD": "T", + "CT": "CTD", + "CTD": "CT", +} + +# Memory types that share retentive with their paired type +PAIRED_RETENTIVE_TYPES: dict[str, str] = {"TD": "T", "CTD": "CT"} + +# Memory types where InitialValue/Retentive cannot be edited +NON_EDITABLE_TYPES: frozenset[str] = frozenset({"SC", "SD", "XD", "YD"}) + + +# ============================================================================== +# Address Validation +# ============================================================================== + + +def is_valid_address(bank_name: str, address: int) -> bool: + """Check if an address is valid for the given bank. + + Uses valid_ranges for sparse banks (X/Y), simple min/max for contiguous banks. + + Args: + bank_name: The bank name (e.g., "X", "DS") + address: The address number to validate + + Returns: + True if the address is valid for the bank + """ + bank = BANKS.get(bank_name) + if bank is None: + return False + + if bank.valid_ranges is not None: + return any(lo <= address <= hi for lo, hi in bank.valid_ranges) + + return bank.min_addr <= address <= bank.max_addr + + +# ============================================================================== +# Runtime Assertions +# ============================================================================== + +assert set(BANKS) == set(MEMORY_TYPE_BASES), "BANKS and MEMORY_TYPE_BASES keys must match" +assert set(BANKS) == set(DEFAULT_RETENTIVE), "BANKS and DEFAULT_RETENTIVE keys must match" diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py new file mode 100644 index 0000000..a5f6950 --- /dev/null +++ b/src/pyclickplc/validation.py @@ -0,0 +1,181 @@ +"""Validation functions for PLC address data. + +Validates nicknames, comments, and initial values against CLICK PLC rules. +""" + +from __future__ import annotations + +from .banks import ( + DataType, +) + +# ============================================================================== +# Constants +# ============================================================================== + +NICKNAME_MAX_LENGTH = 24 +COMMENT_MAX_LENGTH = 128 + +# Characters forbidden in nicknames +# Note: Space is allowed, hyphen (-) and period (.) are forbidden +FORBIDDEN_CHARS: frozenset[str] = frozenset("%\"<>!#$&'()*+-./:;=?@[\\]^`{|}~") + +RESERVED_NICKNAMES: frozenset[str] = frozenset( + { + "log", + "sum", + "sin", + "asin", + "rad", + "cos", + "acos", + "sqrt", + "deg", + "tan", + "atan", + "ln", + "pi", + "mod", + "and", + "or", + "xor", + "lsh", + "rsh", + "lro", + "rro", + } +) + +# Value ranges for validation +INT_MIN = -32768 +INT_MAX = 32767 +INT2_MIN = -2147483648 +INT2_MAX = 2147483647 +FLOAT_MIN = -3.4028235e38 +FLOAT_MAX = 3.4028235e38 + + +# ============================================================================== +# Validation Functions +# ============================================================================== + + +def validate_nickname(nickname: str) -> tuple[bool, str]: + """Validate nickname format (length, characters, reserved words). + + Does NOT check uniqueness -- that is application-specific. + + Args: + nickname: The nickname to validate + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if nickname == "": + return True, "" # Empty is valid (just means unassigned) + + if len(nickname) > NICKNAME_MAX_LENGTH: + return False, f"Too long ({len(nickname)}/24)" + + if nickname.startswith("_"): + return False, "Cannot start with _" + + if nickname.lower() in RESERVED_NICKNAMES: + return False, "Reserved keyword" + + invalid_chars = set(nickname) & FORBIDDEN_CHARS + if invalid_chars: + chars_display = "".join(sorted(invalid_chars)[:3]) + return False, f"Invalid: {chars_display}" + + return True, "" + + +def validate_comment(comment: str) -> tuple[bool, str]: + """Validate comment length. + + Does NOT check block tag uniqueness -- that requires blocks.py context. + + Args: + comment: The comment to validate + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if comment == "": + return True, "" # Empty is valid + + if len(comment) > COMMENT_MAX_LENGTH: + return False, f"Too long ({len(comment)}/128)" + + return True, "" + + +def validate_initial_value( + initial_value: str, + data_type: int, +) -> tuple[bool, str]: + """Validate an initial value against the data type rules. + + Args: + initial_value: The initial value string to validate + data_type: The DataType number (0=bit, 1=int, 2=int2, 3=float, 4=hex, 6=txt) + + Returns: + Tuple of (is_valid, error_message) - error_message is "" if valid + """ + if initial_value == "": + return True, "" # Empty is valid (means no initial value set) + + if data_type == DataType.BIT: + if initial_value not in ("0", "1"): + return False, "Must be 0 or 1" + return True, "" + + elif data_type == DataType.INT: + try: + val = int(initial_value) + if val < INT_MIN or val > INT_MAX: + return False, f"Range: {INT_MIN} to {INT_MAX}" + return True, "" + except ValueError: + return False, "Must be integer" + + elif data_type == DataType.INT2: + try: + val = int(initial_value) + if val < INT2_MIN or val > INT2_MAX: + return False, f"Range: {INT2_MIN} to {INT2_MAX}" + return True, "" + except ValueError: + return False, "Must be integer" + + elif data_type == DataType.FLOAT: + try: + val = float(initial_value) + if val < FLOAT_MIN or val > FLOAT_MAX: + return False, "Out of float range" + return True, "" + except ValueError: + return False, "Must be number" + + elif data_type == DataType.HEX: + if len(initial_value) > 4: + return False, "Max 4 hex digits" + try: + val = int(initial_value, 16) + if val < 0 or val > 0xFFFF: + return False, "Range: 0000 to FFFF" + return True, "" + except ValueError: + return False, "Must be hex (0-9, A-F)" + + elif data_type == DataType.TXT: + if len(initial_value) != 1: + return False, "Must be single char" + if ord(initial_value) > 127: + return False, "Must be ASCII" + return True, "" + + # Unknown data type + return True, "" diff --git a/tests/test_addresses.py b/tests/test_addresses.py new file mode 100644 index 0000000..cd7eb25 --- /dev/null +++ b/tests/test_addresses.py @@ -0,0 +1,287 @@ +"""Tests for pyclickplc.addresses module.""" + +from dataclasses import FrozenInstanceError, replace + +import pytest + +from pyclickplc.addresses import ( + AddressRecord, + format_address_display, + get_addr_key, + is_xd_yd_hidden_slot, + is_xd_yd_upper_byte, + normalize_address, + parse_addr_key, + parse_address, + parse_address_display, + xd_yd_display_to_mdb, + xd_yd_mdb_to_display, +) +from pyclickplc.banks import BANKS, DataType + +# ============================================================================== +# addr_key functions +# ============================================================================== + + +class TestAddrKey: + def test_get_parse_roundtrip_all_types(self): + for mem_type in BANKS: + addr = 1 + key = get_addr_key(mem_type, addr) + parsed_type, parsed_addr = parse_addr_key(key) + assert parsed_type == mem_type + assert parsed_addr == addr + + def test_specific_values(self): + assert get_addr_key("X", 1) == 0x0000001 + assert get_addr_key("DS", 100) == 0x6000064 + assert get_addr_key("TXT", 1) == 0xF000001 + + def test_unknown_type_raises(self): + with pytest.raises(KeyError): + get_addr_key("FAKE", 1) + + +# ============================================================================== +# XD/YD helpers +# ============================================================================== + + +class TestXdYdHelpers: + def test_upper_byte(self): + assert is_xd_yd_upper_byte("XD", 1) is True + assert is_xd_yd_upper_byte("YD", 1) is True + assert is_xd_yd_upper_byte("XD", 0) is False + assert is_xd_yd_upper_byte("XD", 2) is False + assert is_xd_yd_upper_byte("DS", 1) is False + + def test_hidden_slot(self): + assert is_xd_yd_hidden_slot("XD", 3) is True + assert is_xd_yd_hidden_slot("XD", 5) is True + assert is_xd_yd_hidden_slot("XD", 15) is True + assert is_xd_yd_hidden_slot("XD", 0) is False + assert is_xd_yd_hidden_slot("XD", 1) is False + assert is_xd_yd_hidden_slot("XD", 2) is False + assert is_xd_yd_hidden_slot("DS", 3) is False + + def test_mdb_to_display(self): + assert xd_yd_mdb_to_display(0) == 0 + assert xd_yd_mdb_to_display(1) == 0 # XD0u -> display 0 + assert xd_yd_mdb_to_display(2) == 1 + assert xd_yd_mdb_to_display(4) == 2 + assert xd_yd_mdb_to_display(16) == 8 + + def test_display_to_mdb(self): + assert xd_yd_display_to_mdb(0) == 0 + assert xd_yd_display_to_mdb(0, upper_byte=True) == 1 + assert xd_yd_display_to_mdb(1) == 2 + assert xd_yd_display_to_mdb(8) == 16 + + def test_mdb_display_roundtrip(self): + # display -> mdb -> display for 0-8 + for d in range(9): + mdb = xd_yd_display_to_mdb(d) + assert xd_yd_mdb_to_display(mdb) == d + + +# ============================================================================== +# format_address_display +# ============================================================================== + + +class TestFormatAddressDisplay: + def test_x_y_padded(self): + assert format_address_display("X", 1) == "X001" + assert format_address_display("Y", 816) == "Y816" + assert format_address_display("X", 36) == "X036" + + def test_xd_yd_special(self): + assert format_address_display("XD", 0) == "XD0" + assert format_address_display("XD", 1) == "XD0u" + assert format_address_display("XD", 2) == "XD1" + assert format_address_display("XD", 16) == "XD8" + + def test_ds_no_padding(self): + assert format_address_display("DS", 1) == "DS1" + assert format_address_display("DS", 100) == "DS100" + assert format_address_display("DS", 4500) == "DS4500" + + +# ============================================================================== +# parse_address_display +# ============================================================================== + + +class TestParseAddressDisplay: + def test_basic_types(self): + assert parse_address_display("X001") == ("X", 1) + assert parse_address_display("DS100") == ("DS", 100) + assert parse_address_display("C1") == ("C", 1) + + def test_case_insensitive(self): + assert parse_address_display("x001") == ("X", 1) + assert parse_address_display("ds100") == ("DS", 100) + + def test_xd_yd(self): + assert parse_address_display("XD0") == ("XD", 0) + assert parse_address_display("XD0U") == ("XD", 1) + assert parse_address_display("XD0u") == ("XD", 1) + assert parse_address_display("XD1") == ("XD", 2) + assert parse_address_display("XD8") == ("XD", 16) + + def test_invalid_returns_none(self): + assert parse_address_display("") is None + assert parse_address_display("FAKE1") is None + assert parse_address_display("XD1U") is None # Only XD0 can have U + assert parse_address_display("!!!") is None + + +# ============================================================================== +# parse_address +# ============================================================================== + + +class TestParseAddress: + def test_basic(self): + assert parse_address("DS100") == ("DS", 100) + assert parse_address("C1") == ("C", 1) + + def test_case_insensitive(self): + assert parse_address("ds100") == ("DS", 100) + assert parse_address("x001") == ("X", 1) + + def test_xd_returns_display_addr(self): + # parse_address returns display address, NOT MDB + assert parse_address("XD1") == ("XD", 1) + assert parse_address("XD8") == ("XD", 8) + assert parse_address("XD0") == ("XD", 0) + + def test_raises_on_invalid(self): + with pytest.raises(ValueError): + parse_address("") + with pytest.raises(ValueError): + parse_address("FAKE1") + with pytest.raises(ValueError): + parse_address("DS0") # Out of range + with pytest.raises(ValueError): + parse_address("DS4501") # Out of range + with pytest.raises(ValueError): + parse_address("!!!") + + def test_raises_on_sparse_gap(self): + with pytest.raises(ValueError): + parse_address("X017") # In gap between slots + + +# ============================================================================== +# normalize_address +# ============================================================================== + + +class TestNormalizeAddress: + def test_roundtrip(self): + assert normalize_address("x1") == "X001" + assert normalize_address("X001") == "X001" + assert normalize_address("ds100") == "DS100" + assert normalize_address("xd0u") == "XD0u" + assert normalize_address("XD0U") == "XD0u" + + def test_invalid_returns_none(self): + assert normalize_address("") is None + assert normalize_address("FAKE1") is None + + +# ============================================================================== +# AddressRecord +# ============================================================================== + + +class TestAddressRecord: + def test_frozen(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + with pytest.raises(FrozenInstanceError): + rec.nickname = "test" # type: ignore[misc] + + def test_replace(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + new_rec = replace(rec, nickname="Updated") + assert new_rec.nickname == "Updated" + assert rec.nickname == "" + + def test_addr_key_matches_standalone(self): + rec = AddressRecord(memory_type="DS", address=100, data_type=DataType.INT) + assert rec.addr_key == get_addr_key("DS", 100) + + def test_display_address_matches_standalone(self): + rec = AddressRecord(memory_type="X", address=1, data_type=DataType.BIT) + assert rec.display_address == format_address_display("X", 1) + assert rec.display_address == "X001" + + def test_data_type_display(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.data_type_display == "INT" + + def test_has_content_empty(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT, retentive=True) + assert rec.has_content is False # Default retentive for DS is True + + def test_has_content_with_nickname(self): + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + nickname="Test", + retentive=True, + ) + assert rec.has_content is True + + def test_has_content_non_default_retentive(self): + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + retentive=False, # DS default is True + ) + assert rec.has_content is True + + def test_can_edit_initial_value(self): + editable = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert editable.can_edit_initial_value is True + + non_editable = AddressRecord(memory_type="SC", address=1, data_type=DataType.BIT) + assert non_editable.can_edit_initial_value is False + + def test_can_edit_retentive(self): + editable = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert editable.can_edit_retentive is True + + non_editable = AddressRecord(memory_type="XD", address=0, data_type=DataType.HEX) + assert non_editable.can_edit_retentive is False + + def test_used_default_none(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.used is None + + def test_is_default_initial_value(self): + rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) + assert rec.is_default_initial_value is True + + rec_zero = replace(rec, initial_value="0") + assert rec_zero.is_default_initial_value is True + + rec_nonzero = replace(rec, initial_value="42") + assert rec_nonzero.is_default_initial_value is False + + def test_is_default_retentive(self): + # DS default is True + rec = AddressRecord( + memory_type="DS", + address=1, + data_type=DataType.INT, + retentive=True, + ) + assert rec.is_default_retentive is True + + rec2 = replace(rec, retentive=False) + assert rec2.is_default_retentive is False diff --git a/tests/test_banks.py b/tests/test_banks.py new file mode 100644 index 0000000..320da3f --- /dev/null +++ b/tests/test_banks.py @@ -0,0 +1,213 @@ +"""Tests for pyclickplc.banks module.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from pyclickplc.banks import ( + _INDEX_TO_TYPE, + _SPARSE_RANGES, + BANKS, + BIT_ONLY_TYPES, + DEFAULT_RETENTIVE, + INTERLEAVED_PAIRS, + INTERLEAVED_TYPE_PAIRS, + MEMORY_TYPE_BASES, + MEMORY_TYPE_TO_DATA_TYPE, + NON_EDITABLE_TYPES, + DataType, + is_valid_address, +) + +# ============================================================================== +# DataType +# ============================================================================== + + +class TestDataType: + def test_enum_values(self): + assert DataType.BIT == 0 + assert DataType.INT == 1 + assert DataType.INT2 == 2 + assert DataType.FLOAT == 3 + assert DataType.HEX == 4 + assert DataType.TXT == 6 + + def test_is_int_enum(self): + assert isinstance(DataType.BIT, int) + assert DataType.BIT + 1 == 1 + + def test_all_members(self): + assert len(DataType) == 6 + + +# ============================================================================== +# BankConfig +# ============================================================================== + + +class TestBankConfig: + def test_frozen(self): + bank = BANKS["DS"] + with pytest.raises(FrozenInstanceError): + bank.name = "other" # type: ignore[misc] + + def test_all_16_banks_defined(self): + assert len(BANKS) == 16 + + def test_bank_names(self): + expected = { + "X", + "Y", + "C", + "T", + "CT", + "SC", + "DS", + "DD", + "DH", + "DF", + "XD", + "YD", + "TD", + "CTD", + "SD", + "TXT", + } + assert set(BANKS) == expected + + def test_sparse_ranges_on_x_y_only(self): + for name, bank in BANKS.items(): + if name in ("X", "Y"): + assert bank.valid_ranges is not None + assert bank.valid_ranges == _SPARSE_RANGES + else: + assert bank.valid_ranges is None + + def test_sparse_ranges_structure(self): + assert len(_SPARSE_RANGES) == 10 + assert _SPARSE_RANGES[0] == (1, 16) + assert _SPARSE_RANGES[-1] == (821, 836) + + def test_interleaved_pairs(self): + assert BANKS["T"].interleaved_with == "TD" + assert BANKS["TD"].interleaved_with == "T" + assert BANKS["CT"].interleaved_with == "CTD" + assert BANKS["CTD"].interleaved_with == "CT" + + def test_non_interleaved_banks(self): + for name in ("X", "Y", "C", "SC", "DS", "DD", "DH", "DF", "XD", "YD", "SD", "TXT"): + assert BANKS[name].interleaved_with is None + + def test_xd_yd_start_at_zero(self): + assert BANKS["XD"].min_addr == 0 + assert BANKS["YD"].min_addr == 0 + + def test_specific_ranges(self): + assert BANKS["DS"].max_addr == 4500 + assert BANKS["C"].max_addr == 2000 + assert BANKS["T"].max_addr == 500 + assert BANKS["CT"].max_addr == 250 + assert BANKS["DD"].max_addr == 1000 + assert BANKS["TXT"].max_addr == 1000 + + def test_data_types(self): + assert BANKS["X"].data_type == DataType.BIT + assert BANKS["DS"].data_type == DataType.INT + assert BANKS["DD"].data_type == DataType.INT2 + assert BANKS["DF"].data_type == DataType.FLOAT + assert BANKS["DH"].data_type == DataType.HEX + assert BANKS["TXT"].data_type == DataType.TXT + + +# ============================================================================== +# Legacy Dicts +# ============================================================================== + + +class TestLegacyDicts: + def test_memory_type_bases_keys_match_banks(self): + assert set(MEMORY_TYPE_BASES) == set(BANKS) + + def test_memory_type_bases_unique_values(self): + values = list(MEMORY_TYPE_BASES.values()) + assert len(values) == len(set(values)) + + def test_index_to_type_roundtrip(self): + for name, base in MEMORY_TYPE_BASES.items(): + index = base >> 24 + assert _INDEX_TO_TYPE[index] == name + + def test_default_retentive_keys_match_banks(self): + assert set(DEFAULT_RETENTIVE) == set(BANKS) + + def test_interleaved_type_pairs(self): + assert frozenset({"T", "TD"}) in INTERLEAVED_TYPE_PAIRS + assert frozenset({"CT", "CTD"}) in INTERLEAVED_TYPE_PAIRS + assert len(INTERLEAVED_TYPE_PAIRS) == 2 + + def test_interleaved_pairs_bidirectional(self): + assert INTERLEAVED_PAIRS["T"] == "TD" + assert INTERLEAVED_PAIRS["TD"] == "T" + assert INTERLEAVED_PAIRS["CT"] == "CTD" + assert INTERLEAVED_PAIRS["CTD"] == "CT" + + def test_non_editable_types(self): + assert NON_EDITABLE_TYPES == frozenset({"SC", "SD", "XD", "YD"}) + + def test_memory_type_to_data_type(self): + for name, bank in BANKS.items(): + assert MEMORY_TYPE_TO_DATA_TYPE[name] == bank.data_type + + +# ============================================================================== +# Derived Constants +# ============================================================================== + + +class TestDerivedConstants: + def test_bit_only_types(self): + expected = {"X", "Y", "C", "T", "CT", "SC"} + assert BIT_ONLY_TYPES == expected + + +# ============================================================================== +# is_valid_address +# ============================================================================== + + +class TestIsValidAddress: + def test_contiguous_valid(self): + assert is_valid_address("DS", 1) is True + assert is_valid_address("DS", 4500) is True + assert is_valid_address("DS", 2000) is True + + def test_contiguous_invalid(self): + assert is_valid_address("DS", 0) is False + assert is_valid_address("DS", 4501) is False + + def test_sparse_valid(self): + assert is_valid_address("X", 1) is True + assert is_valid_address("X", 16) is True + assert is_valid_address("X", 101) is True + assert is_valid_address("Y", 821) is True + + def test_sparse_invalid_gap(self): + assert is_valid_address("X", 17) is False + assert is_valid_address("X", 20) is False + assert is_valid_address("X", 100) is False + + def test_sparse_boundary(self): + # X036 is the end of slot 2 + assert is_valid_address("X", 36) is True + # X037 is in the gap between slots + assert is_valid_address("X", 37) is False + + def test_xd_range(self): + assert is_valid_address("XD", 0) is True + assert is_valid_address("XD", 16) is True + assert is_valid_address("XD", 8) is True + assert is_valid_address("XD", 17) is False + + def test_unknown_bank(self): + assert is_valid_address("FAKE", 1) is False diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index dbdf392..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,3 +0,0 @@ -# Placeholder for an empty build. -def test_placeholder(): - assert True diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..32ef55f --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,171 @@ +"""Tests for pyclickplc.validation module.""" + +from pyclickplc.banks import DataType +from pyclickplc.validation import ( + FORBIDDEN_CHARS, + validate_comment, + validate_initial_value, + validate_nickname, +) + +# ============================================================================== +# validate_nickname +# ============================================================================== + + +class TestValidateNickname: + def test_empty_valid(self): + assert validate_nickname("") == (True, "") + + def test_simple_valid(self): + assert validate_nickname("Input1") == (True, "") + + def test_max_length(self): + name = "a" * 24 + assert validate_nickname(name) == (True, "") + + def test_too_long(self): + name = "a" * 25 + valid, error = validate_nickname(name) + assert valid is False + assert "Too long" in error + + def test_starts_with_underscore(self): + valid, error = validate_nickname("_invalid") + assert valid is False + assert "Cannot start with _" in error + + def test_reserved_keywords_case_insensitive(self): + for keyword in ("log", "LOG", "Log", "sin", "SIN", "and", "or", "pi"): + valid, error = validate_nickname(keyword) + assert valid is False, f"{keyword} should be rejected" + assert "Reserved" in error + + def test_forbidden_chars(self): + for char in FORBIDDEN_CHARS: + valid, error = validate_nickname(f"test{char}name") + assert valid is False, f"Char {char!r} should be rejected" + assert "Invalid" in error + + def test_space_allowed(self): + assert validate_nickname("my input") == (True, "") + + def test_no_uniqueness_check(self): + # This function does NOT check uniqueness + assert validate_nickname("duplicate") == (True, "") + + +# ============================================================================== +# validate_comment +# ============================================================================== + + +class TestValidateComment: + def test_empty_valid(self): + assert validate_comment("") == (True, "") + + def test_max_length(self): + comment = "a" * 128 + assert validate_comment(comment) == (True, "") + + def test_too_long(self): + comment = "a" * 129 + valid, error = validate_comment(comment) + assert valid is False + assert "Too long" in error + + def test_block_tags_not_rejected(self): + # Length-only validation -- block tags are NOT rejected here + assert validate_comment("[Block:MyBlock]") == (True, "") + assert validate_comment("[Block:Dup]") == (True, "") + + +# ============================================================================== +# validate_initial_value +# ============================================================================== + + +class TestValidateInitialValue: + def test_empty_valid_all_types(self): + for dt in DataType: + assert validate_initial_value("", dt) == (True, "") + + # --- BIT --- + def test_bit_valid(self): + assert validate_initial_value("0", DataType.BIT) == (True, "") + assert validate_initial_value("1", DataType.BIT) == (True, "") + + def test_bit_invalid(self): + valid, error = validate_initial_value("2", DataType.BIT) + assert valid is False + assert "0 or 1" in error + + # --- INT --- + def test_int_valid(self): + assert validate_initial_value("0", DataType.INT) == (True, "") + assert validate_initial_value("-32768", DataType.INT) == (True, "") + assert validate_initial_value("32767", DataType.INT) == (True, "") + + def test_int_out_of_range(self): + valid, error = validate_initial_value("32768", DataType.INT) + assert valid is False + assert "Range" in error + + def test_int_not_number(self): + valid, error = validate_initial_value("abc", DataType.INT) + assert valid is False + assert "integer" in error + + # --- INT2 --- + def test_int2_valid(self): + assert validate_initial_value("0", DataType.INT2) == (True, "") + assert validate_initial_value("-2147483648", DataType.INT2) == (True, "") + assert validate_initial_value("2147483647", DataType.INT2) == (True, "") + + def test_int2_out_of_range(self): + valid, error = validate_initial_value("2147483648", DataType.INT2) + assert valid is False + assert "Range" in error + + # --- FLOAT --- + def test_float_valid(self): + assert validate_initial_value("0.0", DataType.FLOAT) == (True, "") + assert validate_initial_value("1.5", DataType.FLOAT) == (True, "") + assert validate_initial_value("-1.5E+10", DataType.FLOAT) == (True, "") + + def test_float_invalid(self): + valid, error = validate_initial_value("notanumber", DataType.FLOAT) + assert valid is False + assert "number" in error + + # --- HEX --- + def test_hex_valid(self): + assert validate_initial_value("0000", DataType.HEX) == (True, "") + assert validate_initial_value("FFFF", DataType.HEX) == (True, "") + assert validate_initial_value("1A2B", DataType.HEX) == (True, "") + assert validate_initial_value("ff", DataType.HEX) == (True, "") + + def test_hex_too_long(self): + valid, error = validate_initial_value("12345", DataType.HEX) + assert valid is False + assert "4 hex" in error + + def test_hex_invalid_chars(self): + valid, error = validate_initial_value("GHIJ", DataType.HEX) + assert valid is False + assert "hex" in error + + # --- TXT --- + def test_txt_valid(self): + assert validate_initial_value("A", DataType.TXT) == (True, "") + assert validate_initial_value(" ", DataType.TXT) == (True, "") + + def test_txt_too_long(self): + valid, error = validate_initial_value("AB", DataType.TXT) + assert valid is False + assert "single char" in error + + def test_txt_non_ascii(self): + valid, error = validate_initial_value("\u00e9", DataType.TXT) + assert valid is False + assert "ASCII" in error From 04b2b8971d81721ce05f0098d1d6dad8c69b34e1 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:53:26 -0500 Subject: [PATCH 03/55] spec: Mark Step 2 as complete in HANDOFF.md Co-Authored-By: Claude Opus 4.6 --- spec/HANDOFF.md | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md index bf8c45a..fd1137b 100644 --- a/spec/HANDOFF.md +++ b/spec/HANDOFF.md @@ -4,32 +4,42 @@ See `ARCHITECTURE.md` for full module layout, dependency graph, and design decis --- -## Starting Point +## Current State -- pyclickplc: empty `src/pyclickplc/__init__.py`, no code yet +- **Step 1 complete** (commit `7094c46` on `dev`) - ClickNick: working app, Phase 0 (unique block names) done - Specs written: `CLICKDEVICE_SPEC.md` (client), `CLICKSERVER_SPEC.md` (server) -## Step 1: `banks.py` + `addresses.py` in pyclickplc +### What exists in pyclickplc -Build the foundation directly in pyclickplc — don't fix ClickNick first. +| Module | Contents | +|---|---| +| `banks.py` | `DataType` enum, `BankConfig` frozen dataclass, `BANKS` (16 banks), `_SPARSE_RANGES`, `MEMORY_TYPE_BASES`, `_INDEX_TO_TYPE`, `DEFAULT_RETENTIVE`, interleaved/paired dicts, `NON_EDITABLE_TYPES`, `BIT_ONLY_TYPES`, `MEMORY_TYPE_TO_DATA_TYPE`, `is_valid_address()` | +| `addresses.py` | `get_addr_key`/`parse_addr_key`, XD/YD helpers, `format_address_display`, `parse_address_display` (lenient, MDB), `parse_address` (strict, display), `normalize_address`, `AddressRecord` frozen dataclass | +| `validation.py` | `FORBIDDEN_CHARS`/`RESERVED_NICKNAMES` (frozenset), numeric limits, `validate_nickname` (format-only), `validate_comment` (length-only), `validate_initial_value` | +| `__init__.py` | Re-exports public API (XD/YD helpers excluded) | -- `BankConfig` frozen dataclass with `valid_ranges` for sparse X/Y -- `DataType` enum (canonical, from ClickNick's existing `DataType`) -- All bank definitions (`BANKS` dict) -- Address parsing/formatting functions (one parser, shared by all consumers) -- Sparse address validation using `valid_ranges` -- `AddressRecord` frozen dataclass -- `validation.py` (nickname/comment/initial value rules) +93 tests across `test_banks.py`, `test_addresses.py`, `test_validation.py`. Lint clean. -Test thoroughly — this is the foundation everything else builds on. +## ~~Step 1~~ Done -## Step 2: Wire ClickNick to pyclickplc +## Step 1.5 (optional): Plan Step 2 in detail -- Replace `from ..models.constants import ADDRESS_RANGES, DataType, ...` with pyclickplc imports -- Update ClickNick's X/Y display to filter using `BankConfig.valid_ranges` -- Delete moved code from ClickNick's `constants.py` and `address_row.py` -- Run ClickNick's existing tests to verify nothing broke +Read the ClickNick codebase to plan the exact import replacements before starting Step 2. + +## ~~Step 2~~ Done + +Wired ClickNick to import from pyclickplc: + +- Deleted `models/constants.py` entirely (all constants now from pyclickplc) +- Slimmed `models/address_row.py`: removed 9 helper functions, kept `AddressRow` dataclass +- Updated imports in 15 source files + 5 test files +- Replaced `ADDRESS_RANGES` dict with `BANKS` (using `.min_addr`/`.max_addr`) in 4 files +- XD/YD helpers imported from `pyclickplc.addresses` (not re-exported from `__init__`) +- `validate_initial_value` re-exported from pyclickplc via `validation.py` +- `validate_nickname`/`validate_comment` stay in ClickNick (have uniqueness params) +- Added 11 pre-switch tests (`TestAddressRowDerivedProperties`) before migration +- 558 ClickNick tests + 93 pyclickplc tests pass, lint clean ## Step 3: `blocks.py` + `nicknames.py` + `dataview.py` From 8d0e53267a539198fa90c475337f85736de04daa Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:23:34 -0500 Subject: [PATCH 04/55] Add blocks, dataview, and nicknames modules Extract shared code from ClickNick into pyclickplc: - blocks.py: BlockTag/BlockRange parsing and multi-row block operations - dataview.py: DataviewRow model, CDV file I/O, storage/display conversion - nicknames.py: CSV read/write for address data using AddressRecord Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 83 ++++++ src/pyclickplc/blocks.py | 501 +++++++++++++++++++++++++++++++ src/pyclickplc/dataview.py | 575 ++++++++++++++++++++++++++++++++++++ src/pyclickplc/nicknames.py | 220 ++++++++++++++ tests/test_blocks.py | 408 +++++++++++++++++++++++++ tests/test_dataview.py | 393 ++++++++++++++++++++++++ tests/test_nicknames.py | 213 +++++++++++++ 7 files changed, 2393 insertions(+) create mode 100644 src/pyclickplc/blocks.py create mode 100644 src/pyclickplc/dataview.py create mode 100644 src/pyclickplc/nicknames.py create mode 100644 tests/test_blocks.py create mode 100644 tests/test_dataview.py create mode 100644 tests/test_nicknames.py diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index e91357f..a6ef271 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -25,6 +25,49 @@ DataType, is_valid_address, ) +from .blocks import ( + BlockRange, + BlockTag, + HasComment, + compute_all_block_ranges, + extract_block_name, + find_block_range_indices, + find_paired_tag_index, + format_block_tag, + get_all_block_names, + get_block_type, + is_block_name_available, + is_block_tag, + parse_block_tag, + strip_block_tag, + validate_block_span, +) +from .dataview import ( + MAX_DATAVIEW_ROWS, + MEMORY_TYPE_TO_CODE, + WRITABLE_SC, + WRITABLE_SD, + DataviewRow, + TypeCode, + create_empty_dataview, + display_to_storage, + export_cdv, + get_dataview_folder, + get_type_code_for_address, + is_address_writable, + list_cdv_files, + load_cdv, + save_cdv, + storage_to_display, +) +from .nicknames import ( + CSV_COLUMNS, + DATA_TYPE_CODE_TO_STR, + DATA_TYPE_STR_TO_CODE, + read_csv, + read_mdb_csv, + write_csv, +) from .validation import ( COMMENT_MAX_LENGTH, FLOAT_MAX, @@ -65,6 +108,46 @@ "parse_address_display", "parse_address", "normalize_address", + # blocks + "BlockTag", + "BlockRange", + "HasComment", + "parse_block_tag", + "get_block_type", + "is_block_tag", + "extract_block_name", + "strip_block_tag", + "format_block_tag", + "get_all_block_names", + "is_block_name_available", + "find_paired_tag_index", + "find_block_range_indices", + "compute_all_block_ranges", + "validate_block_span", + # dataview + "TypeCode", + "DataviewRow", + "MEMORY_TYPE_TO_CODE", + "WRITABLE_SC", + "WRITABLE_SD", + "MAX_DATAVIEW_ROWS", + "get_type_code_for_address", + "is_address_writable", + "create_empty_dataview", + "storage_to_display", + "display_to_storage", + "export_cdv", + "load_cdv", + "save_cdv", + "get_dataview_folder", + "list_cdv_files", + # nicknames + "CSV_COLUMNS", + "DATA_TYPE_STR_TO_CODE", + "DATA_TYPE_CODE_TO_STR", + "read_csv", + "write_csv", + "read_mdb_csv", # validation "NICKNAME_MAX_LENGTH", "COMMENT_MAX_LENGTH", diff --git a/src/pyclickplc/blocks.py b/src/pyclickplc/blocks.py new file mode 100644 index 0000000..f13ed94 --- /dev/null +++ b/src/pyclickplc/blocks.py @@ -0,0 +1,501 @@ +"""Block tag parsing, matching, and range computation. + +Provides BlockTag/BlockRange dataclasses, parsing functions, and multi-row +block operations for CLICK PLC address editors. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Protocol + +if TYPE_CHECKING: + pass + + +class HasComment(Protocol): + """Protocol for objects that have a comment and optional memory_type attribute.""" + + comment: str + memory_type: str | None + + +@dataclass +class BlockRange: + """A matched block range with start/end indices and metadata. + + Represents a complete block from opening to closing tag (or self-closing). + """ + + start_idx: int + end_idx: int # Same as start_idx for self-closing tags + name: str + bg_color: str | None + memory_type: str | None = None # Memory type for filtering in interleaved views + + +@dataclass +class BlockTag: + """Result of parsing a block tag from a comment. + + Block tags mark sections in the Address Editor: + - - opening tag for a range + - - closing tag for a range + - - self-closing tag for a singular point + - - opening tag with background color + """ + + name: str | None + tag_type: Literal["open", "close", "self-closing"] | None + remaining_text: str + bg_color: str | None + + +def _extract_bg_attribute(tag_content: str) -> tuple[str, str | None]: + """Extract bg attribute from tag content. + + Args: + tag_content: The content between < and > (e.g., 'Name bg="#FFCDD2"') + + Returns: + Tuple of (name_part, bg_color) + - name_part: Tag content with bg attribute removed + - bg_color: The color value, or None if not present + """ + import re + + # Look for bg="..." or bg='...' + match = re.search(r'\s+bg=["\']([^"\']+)["\']', tag_content) + if match: + bg_color = match.group(1) + # Remove the bg attribute from the tag content + name_part = tag_content[: match.start()] + tag_content[match.end() :] + return name_part.strip(), bg_color + return tag_content, None + + +def _is_valid_tag_name(name: str) -> bool: + """Check if a tag name is valid (not a mathematical expression). + + Valid names must contain at least one letter. This prevents expressions + like '< 5 >' or '< 10 >' from being parsed as tags. + + Args: + name: The potential tag name to check + + Returns: + True if the name contains at least one letter + """ + return any(c.isalpha() for c in name) + + +def _try_parse_tag_at(comment: str, start_pos: int) -> BlockTag | None: + """Try to parse a block tag starting at the given position. + + Args: + comment: The full comment string + start_pos: Position of the '<' character + + Returns: + BlockTag if valid tag found, None otherwise + """ + end = comment.find(">", start_pos) + if end == -1: + return None + + tag_content = comment[start_pos + 1 : end] # content between < and > + + # Empty tag <> is invalid + if not tag_content or not tag_content.strip(): + return None + + # Calculate remaining text (text before + text after the tag) + text_before = comment[:start_pos] + text_after = comment[end + 1 :] + remaining = text_before + text_after + + # Self-closing: or + if tag_content.rstrip().endswith("/"): + content_without_slash = tag_content.rstrip()[:-1].strip() + name_part, bg_color = _extract_bg_attribute(content_without_slash) + name = name_part.strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "self-closing", remaining, bg_color) + return None + + # Closing: (no bg attribute on closing tags) + if tag_content.startswith("/"): + name = tag_content[1:].strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "close", remaining, None) + return None + + # Opening: or + name_part, bg_color = _extract_bg_attribute(tag_content) + name = name_part.strip() + if name and _is_valid_tag_name(name): + return BlockTag(name, "open", remaining, bg_color) + + return None + + +def parse_block_tag(comment: str) -> BlockTag: + """Parse block tag from anywhere in a comment. + + Block tags mark sections in the Address Editor: + - - opening tag for a range (can have text before/after) + - - closing tag for a range (can have text before/after) + - - self-closing tag for a singular point + - - opening tag with background color + - - self-closing tag with background color + + The function searches for tags anywhere in the comment, not just at the start. + Mathematical expressions like '< 5 >' are not parsed as tags. + + Args: + comment: The comment string to parse + + Returns: + BlockTag with name, tag_type, remaining_text, and bg_color + """ + if not comment: + return BlockTag(None, None, "", None) + + # Search for '<' anywhere in the comment + pos = 0 + while True: + start_pos = comment.find("<", pos) + if start_pos == -1: + break + + result = _try_parse_tag_at(comment, start_pos) + if result is not None: + return result + + # Try next '<' character + pos = start_pos + 1 + + return BlockTag(None, None, comment, None) + + +def get_block_type(comment: str) -> str | None: + """Determine the type of block tag in a comment. + + Args: + comment: The comment string to check + + Returns: + 'open' for , 'close' for , + 'self-closing' for , or None if not a block tag + """ + return parse_block_tag(comment).tag_type + + +def is_block_tag(comment: str) -> bool: + """Check if a comment starts with a block tag (any type). + + Block tags mark sections in the Address Editor: + - - opening tag for a range + - - closing tag for a range + - - self-closing tag for a singular point + + Args: + comment: The comment string to check + + Returns: + True if the comment starts with any type of block tag + """ + return get_block_type(comment) is not None + + +def extract_block_name(comment: str) -> str | None: + """Extract block name from a comment that starts with a block tag. + + Args: + comment: The comment string (e.g., "Valve info", "", "") + + Returns: + The block name (e.g., "Motor", "Spare"), or None if no tag + """ + return parse_block_tag(comment).name + + +def strip_block_tag(comment: str) -> str: + """Strip block tag from a comment, returning any text after the tag. + + Args: + comment: The comment string (e.g., "Valve info") + + Returns: + Text after the tag (e.g., "Valve info"), or original if no tag + """ + if not comment: + return "" + block_tag = parse_block_tag(comment) + if block_tag.tag_type is not None: + return block_tag.remaining_text + return comment + + +def format_block_tag( + name: str, + tag_type: Literal["open", "close", "self-closing"], + bg_color: str | None = None, +) -> str: + """Format a block tag string from its components. + + Args: + name: The block name (e.g., "Motor", "Alarms") + tag_type: "open", "close", or "self-closing" + bg_color: Optional background color (e.g., "#FFCDD2", "Red") + + Returns: + Formatted block tag string: + - open: "" or "" + - close: "" + - self-closing: "" or "" + """ + bg_attr = f' bg="{bg_color}"' if bg_color else "" + + if tag_type == "self-closing": + return f"<{name}{bg_attr} />" + elif tag_type == "close": + return f"" + else: # open + return f"<{name}{bg_attr}>" + + +# ============================================================================= +# Multi-Row Block Operations +# ============================================================================= + + +def get_all_block_names(rows: list[HasComment]) -> set[str]: + """Get all unique block names from a list of rows. + + Scans all comments for block tags and extracts their names. + Used for duplicate name validation. + + Args: + rows: List of objects with .comment attribute + + Returns: + Set of unique block names (case-sensitive) + """ + names: set[str] = set() + for row in rows: + tag = parse_block_tag(row.comment) + if tag.name: + names.add(tag.name) + return names + + +def is_block_name_available( + name: str, + rows: list[HasComment], + exclude_addr_keys: set[int] | None = None, +) -> bool: + """Check if a block name is available (not already in use). + + Args: + name: The block name to check + rows: List of objects with .comment and .addr_key attributes + exclude_addr_keys: Optional set of addr_keys to exclude from check + (used when renaming a block - excludes the block being renamed) + + Returns: + True if the name is available, False if already in use + """ + exclude = exclude_addr_keys or set() + for row in rows: + if hasattr(row, "addr_key") and row.addr_key in exclude: + continue + tag = parse_block_tag(row.comment) + if tag.name == name: + return False + return True + + +def find_paired_tag_index( + rows: list[HasComment], row_idx: int, tag: BlockTag | None = None +) -> int | None: + """Find the row index of the paired open/close block tag. + + Uses nesting depth to correctly match tags when there are multiple + blocks with the same name (nested or separate sections). + + Only matches tags within the same memory_type (if available) to correctly + handle interleaved views like T/TD where each type has its own tags. + + Args: + rows: List of objects with .comment and optional .memory_type attributes + row_idx: Index of the row containing the tag + tag: Parsed BlockTag, or None to parse from rows[row_idx].comment + + Returns: + Row index of the paired tag, or None if not found + """ + if tag is None: + tag = parse_block_tag(rows[row_idx].comment) + + if not tag.name or tag.tag_type == "self-closing": + return None + + # Get memory type of source row (if available) for filtering + source_type = getattr(rows[row_idx], "memory_type", None) + + if tag.tag_type == "open": + # Search forward for matching close tag, respecting nesting + depth = 1 + for i in range(row_idx + 1, len(rows)): + # Skip rows with different memory type + if source_type and getattr(rows[i], "memory_type", None) != source_type: + continue + other_tag = parse_block_tag(rows[i].comment) + if other_tag.name == tag.name: + if other_tag.tag_type == "open": + depth += 1 + elif other_tag.tag_type == "close": + depth -= 1 + if depth == 0: + return i + elif tag.tag_type == "close": + # Search backward for matching open tag, respecting nesting + depth = 1 + for i in range(row_idx - 1, -1, -1): + # Skip rows with different memory type + if source_type and getattr(rows[i], "memory_type", None) != source_type: + continue + other_tag = parse_block_tag(rows[i].comment) + if other_tag.name == tag.name: + if other_tag.tag_type == "close": + depth += 1 + elif other_tag.tag_type == "open": + depth -= 1 + if depth == 0: + return i + return None + + +def find_block_range_indices( + rows: list[HasComment], row_idx: int, tag: BlockTag | None = None +) -> tuple[int, int] | None: + """Find the (start_idx, end_idx) range for a block tag. + + Uses nesting depth to correctly match tags when there are multiple + blocks with the same name. + + Args: + rows: List of objects with a .comment attribute + row_idx: Index of the row containing the tag + tag: Parsed BlockTag, or None to parse from rows[row_idx].comment + + Returns: + Tuple of (start_idx, end_idx) inclusive, or None if tag is invalid + """ + if tag is None: + tag = parse_block_tag(rows[row_idx].comment) + + if not tag.name or not tag.tag_type: + return None + + if tag.tag_type == "self-closing": + return (row_idx, row_idx) + + if tag.tag_type == "open": + paired_idx = find_paired_tag_index(rows, row_idx, tag) + if paired_idx is not None: + return (row_idx, paired_idx) + # No close found - just the opening row + return (row_idx, row_idx) + + if tag.tag_type == "close": + paired_idx = find_paired_tag_index(rows, row_idx, tag) + if paired_idx is not None: + return (paired_idx, row_idx) + # No open found - just the closing row + return (row_idx, row_idx) + + return None + + +def compute_all_block_ranges(rows: list[HasComment]) -> list[BlockRange]: + """Compute all block ranges from a list of rows using stack-based matching. + + Correctly handles nested blocks and multiple blocks with the same name. + Only matches open/close tags within the same memory_type to handle + interleaved views like T/TD correctly. + + Args: + rows: List of objects with .comment and optional .memory_type attributes + + Returns: + List of BlockRange objects, sorted by start_idx + """ + ranges: list[BlockRange] = [] + + # Stack for tracking open tags: (memory_type, name) -> [(start_idx, bg_color), ...] + # Using (memory_type, name) as key ensures T's and TD's are separate + open_tags: dict[tuple[str | None, str], list[tuple[int, str | None]]] = {} + + for row_idx, row in enumerate(rows): + tag = parse_block_tag(row.comment) + if not tag.name: + continue + + memory_type = getattr(row, "memory_type", None) + stack_key = (memory_type, tag.name) + + if tag.tag_type == "self-closing": + ranges.append(BlockRange(row_idx, row_idx, tag.name, tag.bg_color, memory_type)) + elif tag.tag_type == "open": + if stack_key not in open_tags: + open_tags[stack_key] = [] + open_tags[stack_key].append((row_idx, tag.bg_color)) + elif tag.tag_type == "close": + if stack_key in open_tags and open_tags[stack_key]: + start_idx, bg_color = open_tags[stack_key].pop() + ranges.append(BlockRange(start_idx, row_idx, tag.name, bg_color, memory_type)) + + # Handle unclosed tags as singular points + for (mem_type, name), stack in open_tags.items(): + for start_idx, bg_color in stack: + ranges.append(BlockRange(start_idx, start_idx, name, bg_color, mem_type)) + + # Sort by start index + ranges.sort(key=lambda r: r.start_idx) + return ranges + + +def validate_block_span(rows: list) -> tuple[bool, str | None]: + """Validate that a block span doesn't cross memory type boundaries. + + Blocks should only contain addresses of the same memory type, + with the exception of paired types (T+TD, CT+CTD) which are + interleaved and can share blocks. + + Args: + rows: List of objects with .memory_type attribute + + Returns: + Tuple of (is_valid, error_message). + - (True, None) if all rows have compatible memory types + - (False, error_message) if rows span incompatible memory types + """ + from .banks import INTERLEAVED_TYPE_PAIRS + + if not rows: + return True, None + + # Get unique memory types in the selection + memory_types = {row.memory_type for row in rows} + + if len(memory_types) == 1: + return True, None + + # Check if it's a valid paired type combination + if frozenset(memory_types) in INTERLEAVED_TYPE_PAIRS: + return True, None + + types_str = ", ".join(sorted(memory_types)) + return False, f"Blocks cannot span multiple memory types ({types_str})" diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py new file mode 100644 index 0000000..1fe502f --- /dev/null +++ b/src/pyclickplc/dataview.py @@ -0,0 +1,575 @@ +"""DataView model and CDV file I/O for CLICK PLC DataView files. + +Provides the DataviewRow dataclass, type code mappings, CDV file read/write, +and new-value storage/display conversion functions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from .addresses import format_address_display, parse_address_display + + +# Type codes used in CDV files to identify address types +class TypeCode: + """Type codes for CDV file format.""" + + BIT = 768 + INT = 0 + INT2 = 256 + HEX = 3 + FLOAT = 257 + TXT = 1024 + + +# Map memory type prefixes to their type codes +MEMORY_TYPE_TO_CODE: dict[str, int] = { + "X": TypeCode.BIT, + "Y": TypeCode.BIT, + "C": TypeCode.BIT, + "T": TypeCode.BIT, + "CT": TypeCode.BIT, + "SC": TypeCode.BIT, + "DS": TypeCode.INT, + "TD": TypeCode.INT, + "SD": TypeCode.INT, + "DD": TypeCode.INT2, + "CTD": TypeCode.INT2, + "DH": TypeCode.HEX, + "XD": TypeCode.HEX, + "YD": TypeCode.HEX, + "DF": TypeCode.FLOAT, + "TXT": TypeCode.TXT, +} + +# Reverse mapping: type code to list of memory types +CODE_TO_MEMORY_TYPES: dict[int, list[str]] = { + TypeCode.BIT: ["X", "Y", "C", "T", "CT", "SC"], + TypeCode.INT: ["DS", "TD", "SD"], + TypeCode.INT2: ["DD", "CTD"], + TypeCode.HEX: ["DH", "XD", "YD"], + TypeCode.FLOAT: ["DF"], + TypeCode.TXT: ["TXT"], +} + +# SC addresses that are writable (most SC are read-only system controls) +WRITABLE_SC: frozenset[int] = frozenset({50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) + +# SD addresses that are writable (most SD are read-only system data) +WRITABLE_SD: frozenset[int] = frozenset( + { + 29, + 31, + 32, + 34, + 35, + 36, + 40, + 41, + 42, + 50, + 51, + 60, + 61, + 106, + 107, + 108, + 112, + 113, + 114, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 214, + 215, + } +) + +# Max rows in a dataview +MAX_DATAVIEW_ROWS = 100 + + +def get_type_code_for_address(address: str) -> int | None: + """Get the type code for an address. + + Args: + address: Address string like "X001", "DS1" + + Returns: + Type code or None if address is invalid. + """ + parsed = parse_address_display(address) + if not parsed: + return None + memory_type, _ = parsed + return MEMORY_TYPE_TO_CODE.get(memory_type) + + +def is_address_writable(address: str) -> bool: + """Check if an address is writable (can have a New Value set). + + Most addresses are writable, but SC and SD have specific writable addresses. + XD and YD are read-only. + + Args: + address: Address string like "X001", "SC50" + + Returns: + True if the address can have a New Value written to it. + """ + parsed = parse_address_display(address) + if not parsed: + return False + + memory_type, mdb_address = parsed + + # XD and YD are read-only + if memory_type in ("XD", "YD"): + return False + + # SC has specific writable addresses + if memory_type == "SC": + return mdb_address in WRITABLE_SC + + # SD has specific writable addresses + if memory_type == "SD": + return mdb_address in WRITABLE_SD + + # All other addresses are writable + return True + + +@dataclass +class DataviewRow: + """Represents a single row in a CLICK DataView. + + A dataview row contains an address to monitor and optionally a new value + to write to that address. The nickname and comment are display-only + fields populated from SharedAddressData. + """ + + # Core data (stored in CDV file) + address: str = "" # e.g., "X001", "DS1", "CTD250" + type_code: int = 0 # Type code for the address + new_value: str = "" # Optional new value to write + + # Display-only fields (populated from SharedAddressData) + nickname: str = field(default="", compare=False) + comment: str = field(default="", compare=False) + + @property + def is_empty(self) -> bool: + """Check if this row is empty (no address set).""" + return not self.address.strip() + + @property + def is_writable(self) -> bool: + """Check if this address can have a New Value written to it.""" + return is_address_writable(self.address) + + @property + def memory_type(self) -> str | None: + """Get the memory type prefix (X, Y, DS, etc.) or None if invalid.""" + parsed = parse_address_display(self.address) + return parsed[0] if parsed else None + + @property + def address_number(self) -> str | None: + """Get the address number as a display string, or None if invalid.""" + parsed = parse_address_display(self.address) + if not parsed: + return None + memory_type, mdb_address = parsed + # Return the display address portion (strip the memory type prefix) + display = format_address_display(memory_type, mdb_address) + return display[len(memory_type) :] + + def update_type_code(self) -> bool: + """Update the type code based on the current address. + + Returns: + True if type code was updated, False if address is invalid. + """ + code = get_type_code_for_address(self.address) + if code is not None: + self.type_code = code + return True + return False + + def clear(self) -> None: + """Clear all fields in this row.""" + self.address = "" + self.type_code = 0 + self.new_value = "" + self.nickname = "" + self.comment = "" + + +def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataviewRow]: + """Create a new empty dataview with the specified number of rows. + + Args: + count: Number of rows to create (default MAX_DATAVIEW_ROWS). + + Returns: + List of empty DataviewRow objects. + """ + return [DataviewRow() for _ in range(count)] + + +# --- New Value Conversion Functions --- +# CDV files store values in specific formats that need conversion for display + + +def storage_to_display(value: str, type_code: int) -> str: + """Convert a stored CDV value to display format. + + Args: + value: The raw value from the CDV file + type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + + Returns: + Human-readable display value + """ + if not value: + return "" + + try: + if type_code == TypeCode.BIT: + # BIT: 0 or 1 + return "1" if value == "1" else "0" + + elif type_code == TypeCode.INT: + # INT (16-bit signed): Stored as unsigned 32-bit with sign extension + # Convert back to signed 16-bit + unsigned_val = int(value) + # Mask to 16 bits and convert to signed + val_16bit = unsigned_val & 0xFFFF + if val_16bit >= 0x8000: + val_16bit -= 0x10000 + return str(val_16bit) + + elif type_code == TypeCode.INT2: + # INT2 (32-bit signed): Stored as unsigned 32-bit + # Convert back to signed 32-bit + unsigned_val = int(value) + if unsigned_val >= 0x80000000: + unsigned_val -= 0x100000000 + return str(unsigned_val) + + elif type_code == TypeCode.HEX: + # HEX: Display as 4-digit hex, uppercase, NO suffix. + decimal_val = int(value) + return format(decimal_val, "04X") + + elif type_code == TypeCode.FLOAT: + # FLOAT: Stored as IEEE 754 32-bit integer representation + import struct + + int_val = int(value) + # Convert integer to bytes (unsigned 32-bit) + bytes_val = struct.pack(">I", int_val & 0xFFFFFFFF) + # Interpret as big-endian float + float_val = struct.unpack(">f", bytes_val)[0] + + # Use 'G' for general format: + # 1. Automatically uses Scientific notation for large numbers + # 2. Automatically trims trailing zeros for small numbers + # 3. Uppercase 'E' + return f"{float_val:.7G}" + + elif type_code == TypeCode.TXT: + # TXT: Stored as ASCII code, display as character + ascii_code = int(value) + if 32 <= ascii_code <= 126: # Printable ASCII + return chr(ascii_code) + return str(ascii_code) # Non-printable: show as number + + else: + return value + + except (ValueError, struct.error): + return value + + +def display_to_storage(value: str, type_code: int) -> str: + """Convert a display value to CDV storage format. + + Args: + value: The human-readable display value + type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + + Returns: + Value formatted for CDV file storage + """ + if not value: + return "" + + try: + if type_code == TypeCode.BIT: + # BIT: 0 or 1 + return "1" if value in ("1", "True", "true", "ON", "on") else "0" + + elif type_code == TypeCode.INT: + # INT (16-bit signed): Convert to unsigned 32-bit with sign extension + signed_val = int(value) + # Clamp to 16-bit signed range + signed_val = max(-32768, min(32767, signed_val)) + # Convert to unsigned 32-bit representation + if signed_val < 0: + unsigned_val = signed_val + 0x100000000 + else: + unsigned_val = signed_val + return str(unsigned_val) + + elif type_code == TypeCode.INT2: + # INT2 (32-bit signed): Convert to unsigned 32-bit + signed_val = int(value) + # Clamp to 32-bit signed range + signed_val = max(-2147483648, min(2147483647, signed_val)) + if signed_val < 0: + unsigned_val = signed_val + 0x100000000 + else: + unsigned_val = signed_val + return str(unsigned_val) + + elif type_code == TypeCode.HEX: + # HEX: Convert hex string to decimal + # Support with or without 0x prefix + hex_val = value.strip() + if hex_val.lower().startswith("0x"): + hex_val = hex_val[2:] + decimal_val = int(hex_val, 16) + return str(decimal_val) + + elif type_code == TypeCode.FLOAT: + # Display (String) -> Float -> IEEE 754 Bytes -> Int -> Storage (String) + import struct + + float_val = float(value) + # Convert float to bytes + bytes_val = struct.pack(">f", float_val) + # Interpret as unsigned 32-bit integer + int_val = struct.unpack(">I", bytes_val)[0] + return str(int_val) + + elif type_code == TypeCode.TXT: + # TXT: Convert character to ASCII code + if len(value) == 1: + return str(ord(value)) + # If it's already a number, keep it + return str(int(value)) + + else: + return value + + except (ValueError, struct.error): + return value + + +# ============================================================================= +# CDV File I/O +# ============================================================================= + + +def load_cdv(path: Path | str) -> tuple[list[DataviewRow], bool, str]: + """Load a CDV file. + + Args: + path: Path to the CDV file. + + Returns: + Tuple of (rows, has_new_values, header) where: + - rows: List of DataviewRow objects (always MAX_DATAVIEW_ROWS length) + - has_new_values: True if the dataview has new values set + - header: The original header line from the file + + Raises: + FileNotFoundError: If the file doesn't exist. + ValueError: If the file format is invalid. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"CDV file not found: {path}") + + # Read file with UTF-16 encoding + content = path.read_text(encoding="utf-16") + lines = content.strip().split("\n") + + if not lines: + raise ValueError(f"Empty CDV file: {path}") + + # Parse header line - preserve the original + header = lines[0].strip() + header_parts = [p.strip() for p in header.split(",")] + if len(header_parts) < 1: + raise ValueError(f"Invalid CDV header: {header}") + + # First value: 0 = no new values, -1 = has new values + try: + has_new_values = int(header_parts[0]) == -1 + except ValueError: + has_new_values = False + + # Parse data rows + rows = create_empty_dataview() + data_lines = lines[1 : MAX_DATAVIEW_ROWS + 1] + + for i, line in enumerate(data_lines): + if i >= MAX_DATAVIEW_ROWS: + break + + line = line.strip() + if not line: + continue + + parts = [p.strip() for p in line.split(",")] + + # Empty row: ",0" or just "," + if not parts[0]: + continue + + # Parse address + address = parts[0] + rows[i].address = address + + # Parse type code + if len(parts) > 1 and parts[1]: + try: + rows[i].type_code = int(parts[1]) + except ValueError: + # Try to infer from address + code = get_type_code_for_address(address) + rows[i].type_code = code if code is not None else 0 + else: + # Infer type code from address + code = get_type_code_for_address(address) + rows[i].type_code = code if code is not None else 0 + + # Parse new value (if present and has_new_values flag is set) + if len(parts) > 2 and parts[2]: + rows[i].new_value = parts[2] + + return rows, has_new_values, header + + +def save_cdv( + path: Path | str, + rows: list[DataviewRow], + has_new_values: bool, + header: str | None = None, +) -> None: + """Save a CDV file. + + Args: + path: Path to save the CDV file. + rows: List of DataviewRow objects (may exceed MAX_DATAVIEW_ROWS). + has_new_values: True if any rows have new values set. + header: Original header line to preserve. If None, uses default format. + + Note: + Only the first MAX_DATAVIEW_ROWS (100) rows are saved to maintain + file format compatibility. Overflow rows (index 100+) are not persisted. + """ + path = Path(path) + + # Build content + lines: list[str] = [] + + # Header line - use original if provided, otherwise build default + if header is not None: + lines.append(header) + else: + header_flag = -1 if has_new_values else 0 + lines.append(f"{header_flag},0,0") + + # Data rows - only save first MAX_DATAVIEW_ROWS + rows_to_save = list(rows[:MAX_DATAVIEW_ROWS]) + + # Pad with empty rows if needed to always have exactly 100 lines + while len(rows_to_save) < MAX_DATAVIEW_ROWS: + rows_to_save.append(DataviewRow()) + + for row in rows_to_save: + if row.is_empty: + lines.append(",0") + else: + if row.new_value: + lines.append(f"{row.address},{row.type_code},{row.new_value}") + else: + lines.append(f"{row.address},{row.type_code}") + + # Join with newlines and add trailing newline + content = "\n".join(lines) + "\n" + + # Write with UTF-16 encoding (includes BOM automatically) + path.write_text(content, encoding="utf-16") + + +def export_cdv( + path: Path | str, + rows: list[DataviewRow], + has_new_values: bool, + header: str | None = None, +) -> None: + """Export a CDV file to a new location. + + This is identical to save_cdv but semantically indicates exporting + rather than saving to the original location. + + Args: + path: Path to export the CDV file. + rows: List of DataviewRow objects. + has_new_values: True if any rows have new values set. + header: Original header line to preserve. If None, uses default format. + """ + save_cdv(path, rows, has_new_values, header) + + +def get_dataview_folder(project_path: Path | str) -> Path | None: + """Get the DataView folder for a CLICK project. + + The DataView folder is located at: {project_path}/CLICK ({unique_id})/DataView + where {unique_id} is a hex identifier like "00010A98". + + Args: + project_path: Path to the CLICK project folder. + + Returns: + Path to the DataView folder, or None if not found. + """ + project_path = Path(project_path) + if not project_path.is_dir(): + return None + + # Look for CLICK (*) subdirectory + for child in project_path.iterdir(): + if child.is_dir() and child.name.startswith("CLICK ("): + dataview_path = child / "DataView" + if dataview_path.is_dir(): + return dataview_path + + return None + + +def list_cdv_files(dataview_folder: Path | str) -> list[Path]: + """List all CDV files in a DataView folder. + + Args: + dataview_folder: Path to the DataView folder. + + Returns: + List of Path objects for each CDV file, sorted by name. + """ + folder = Path(dataview_folder) + if not folder.is_dir(): + return [] + + return sorted(folder.glob("*.cdv"), key=lambda p: p.stem.lower()) diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py new file mode 100644 index 0000000..28ab212 --- /dev/null +++ b/src/pyclickplc/nicknames.py @@ -0,0 +1,220 @@ +"""CSV read/write for CLICK PLC address nicknames. + +Provides functions to read and write address data in CLICK software CSV format +(user-facing) and MDB-dump CSV format, using AddressRecord as the data model. +""" + +from __future__ import annotations + +import csv +from pathlib import Path + +from .addresses import AddressRecord, get_addr_key, parse_address_display +from .banks import BANKS, DEFAULT_RETENTIVE, MEMORY_TYPE_BASES, MEMORY_TYPE_TO_DATA_TYPE, DataType + +# CSV column names (matching CLICK software export format) +CSV_COLUMNS = ["Address", "Data Type", "Nickname", "Initial Value", "Retentive", "Address Comment"] + +# Data type string to code mapping +DATA_TYPE_STR_TO_CODE: dict[str, int] = { + "BIT": 0, + "INT": 1, + "INT2": 2, + "FLOAT": 3, + "HEX": 4, + "TXT": 6, + "TEXT": 6, # Alias +} + +# Data type code to string mapping (for saving csv) +DATA_TYPE_CODE_TO_STR: dict[int, str] = { + 0: "BIT", + 1: "INT", + 2: "INT2", + 3: "FLOAT", + 4: "HEX", + 6: "TEXT", +} + + +def read_csv(path: str | Path) -> dict[int, AddressRecord]: + """Read a user-format CSV file into AddressRecords. + + The user CSV has columns: Address, Data Type, Nickname, Initial Value, + Retentive, Address Comment. + + Args: + path: Path to the CSV file. + + Returns: + Dict mapping addr_key (int) to AddressRecord. + """ + result: dict[int, AddressRecord] = {} + + with open(path, newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + + for row in reader: + addr_str = row.get("Address", "").strip() + if not addr_str: + continue + + parsed = parse_address_display(addr_str) + if not parsed: + continue + + mem_type, mdb_address = parsed + + if mem_type not in BANKS: + continue + + # Get data type (default based on memory type) + default_data_type = MEMORY_TYPE_TO_DATA_TYPE.get(mem_type, 0) + data_type_str = row.get("Data Type", "").strip().upper() + data_type = DATA_TYPE_STR_TO_CODE.get(data_type_str, default_data_type) + + # Get retentive + default_retentive = DEFAULT_RETENTIVE.get(mem_type, False) + retentive_str = row.get("Retentive", "").strip() + retentive = retentive_str.lower() == "yes" if retentive_str else default_retentive + + # Get other fields + nickname = row.get("Nickname", "").strip() + comment = row.get("Address Comment", "").strip() + initial_value = row.get("Initial Value", "").strip() + + addr_key = get_addr_key(mem_type, mdb_address) + + record = AddressRecord( + memory_type=mem_type, + address=mdb_address, + nickname=nickname, + comment=comment, + initial_value=initial_value, + retentive=retentive, + data_type=data_type, + ) + + result[addr_key] = record + + return result + + +def write_csv(path: str | Path, records: dict[int, AddressRecord]) -> int: + """Write AddressRecords to a user-format CSV file. + + Only records with content (nickname, comment, non-default initial value + or retentive) are written. Records are sorted by memory type order then + address. + + Args: + path: Path to write the CSV file. + records: Dict mapping addr_key to AddressRecord. + + Returns: + Number of rows written. + """ + # Collect records with content, sorted by memory type order and address + rows_to_write = sorted( + (r for r in records.values() if r.has_content), + key=lambda r: (MEMORY_TYPE_BASES.get(r.memory_type, 0xFFFFFFFF), r.address), + ) + + with open(path, "w", newline="", encoding="utf-8") as csvfile: + # Write header manually (matching CLICK format) + csvfile.write(",".join(CSV_COLUMNS) + "\n") + + def format_quoted(text): + if text is None: + return '""' + escaped_text = str(text).replace('"', '""') + return f'"{escaped_text}"' + + for record in rows_to_write: + data_type_str = DATA_TYPE_CODE_TO_STR.get(record.data_type, "") + + # Format initial value: use "0" for numeric types when empty, "" for TXT + if record.initial_value: + initial_value_str = str(record.initial_value) + elif record.data_type == DataType.TXT: + initial_value_str = "" + else: + initial_value_str = "0" + + line_parts = [ + record.display_address, + data_type_str, + format_quoted(record.nickname), + initial_value_str, + "Yes" if record.retentive else "No", + format_quoted(record.comment), + ] + + csvfile.write(",".join(line_parts) + "\n") + + return len(rows_to_write) + + +def read_mdb_csv(path: str | Path) -> dict[int, AddressRecord]: + """Read an MDB-dump CSV file into AddressRecords. + + The MDB-format CSV (exported by CLICK software) has columns: + AddrKey, MemoryType, Address, DataType, Nickname, Use, InitialValue, + Retentive, Comment. + + Only rows with a nickname or comment are included. + + Args: + path: Path to MDB-format CSV file. + + Returns: + Dict mapping addr_key (int) to AddressRecord. + """ + result: dict[int, AddressRecord] = {} + + with open(path, newline="", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + + for row in reader: + nickname = row.get("Nickname", "").strip() + comment = row.get("Comment", "").strip() + if not nickname and not comment: + continue + + mem_type = row.get("MemoryType", "").strip() + if mem_type not in BANKS: + continue + + try: + address = int(row.get("Address", "0")) + except ValueError: + continue + + # Parse data type + try: + data_type = int(row.get("DataType", "0")) + except ValueError: + data_type = MEMORY_TYPE_TO_DATA_TYPE.get(mem_type, 0) + + # Parse retentive (0 or 1) + retentive_raw = row.get("Retentive", "").strip() + default_retentive = DEFAULT_RETENTIVE.get(mem_type, False) + retentive = retentive_raw in ("1",) if retentive_raw else default_retentive + + initial_value = row.get("InitialValue", "").strip() + + addr_key = get_addr_key(mem_type, address) + + record = AddressRecord( + memory_type=mem_type, + address=address, + nickname=nickname, + comment=comment, + initial_value=initial_value, + retentive=retentive, + data_type=data_type, + ) + + result[addr_key] = record + + return result diff --git a/tests/test_blocks.py b/tests/test_blocks.py new file mode 100644 index 0000000..501896b --- /dev/null +++ b/tests/test_blocks.py @@ -0,0 +1,408 @@ +"""Tests for pyclickplc.blocks — block tag parsing and matching.""" + +from dataclasses import dataclass + +from pyclickplc.blocks import ( + BlockRange, + BlockTag, + compute_all_block_ranges, + extract_block_name, + find_block_range_indices, + find_paired_tag_index, + get_block_type, + is_block_tag, + parse_block_tag, + strip_block_tag, + validate_block_span, +) + + +class TestParseBlockTag: + """Tests for parse_block_tag().""" + + def test_empty_comment(self): + result = parse_block_tag("") + assert result == BlockTag(None, None, "", None) + + def test_no_tag(self): + result = parse_block_tag("Just a comment") + assert result.name is None + assert result.tag_type is None + assert result.remaining_text == "Just a comment" + + def test_opening_tag(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "" + assert result.bg_color is None + + def test_opening_tag_with_text_after(self): + result = parse_block_tag("Valve controls") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "Valve controls" + + def test_closing_tag(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "" + + def test_closing_tag_with_text_after(self): + result = parse_block_tag("end of section") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "end of section" + + def test_self_closing_tag(self): + result = parse_block_tag("") + assert result.name == "Spare" + assert result.tag_type == "self-closing" + assert result.remaining_text == "" + + def test_self_closing_no_space(self): + result = parse_block_tag("") + assert result.name == "Spare" + assert result.tag_type == "self-closing" + + def test_opening_with_bg_color(self): + result = parse_block_tag('') + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.bg_color == "#FFCDD2" + + def test_opening_with_bg_single_quotes(self): + result = parse_block_tag("") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.bg_color == "Red" + + def test_self_closing_with_bg(self): + result = parse_block_tag('') + assert result.name == "Spare" + assert result.tag_type == "self-closing" + assert result.bg_color == "Blue" + + def test_closing_tag_no_bg(self): + """Closing tags don't have bg attribute.""" + result = parse_block_tag('') + assert result.tag_type == "close" + + def test_name_with_spaces(self): + result = parse_block_tag("") + assert result.name == "Alm Bits" + assert result.tag_type == "open" + + def test_unclosed_angle_bracket(self): + result = parse_block_tag("") + assert result.name is None + assert result.tag_type is None + + def test_whitespace_only_tag(self): + result = parse_block_tag("< >") + assert result.name is None + assert result.tag_type is None + + def test_opening_tag_after_text(self): + result = parse_block_tag("Leading text") + assert result.name == "Motor" + assert result.tag_type == "open" + assert result.remaining_text == "Leading text" + + def test_closing_tag_after_text(self): + result = parse_block_tag("Leading text ") + assert result.name == "Motor" + assert result.tag_type == "close" + assert result.remaining_text == "Leading text " + + def test_self_closing_tag_after_text(self): + result = parse_block_tag("Leading text ") + assert result.name == "Motor" + assert result.tag_type == "self-closing" + assert result.remaining_text == "Leading text " + + def test_tag_in_middle_of_text(self): + result = parse_block_tag("Before After") + assert result.name == "Motor" + assert result.remaining_text == "Before After" + + def test_mathematical_inequality_not_a_tag(self): + """Angle brackets used for comparisons should not be parsed as tags.""" + text = "The range can be Min < 5 > Max" + result = parse_block_tag(text) + assert result.name is None + assert result.tag_type is None + assert result.remaining_text == text + + +class TestHelperFunctions: + """Tests for is_block_tag, get_block_type, extract_block_name, strip_block_tag.""" + + def test_is_block_tag_open(self): + assert is_block_tag("") is True + + def test_is_block_tag_close(self): + assert is_block_tag("") is True + + def test_is_block_tag_self_closing(self): + assert is_block_tag("") is True + + def test_is_block_tag_not_tag(self): + assert is_block_tag("Just a comment") is False + + def test_is_block_tag_with_surrounding_text(self): + assert is_block_tag("Text then ") is True + assert is_block_tag(" then text") is True + assert is_block_tag("Inequality < 5 > 2") is False + + def test_get_block_type_open(self): + assert get_block_type("") == "open" + + def test_get_block_type_close(self): + assert get_block_type("") == "close" + + def test_get_block_type_self_closing(self): + assert get_block_type("") == "self-closing" + + def test_get_block_type_none(self): + assert get_block_type("comment") is None + + def test_extract_block_name(self): + assert extract_block_name("") == "Motor" + assert extract_block_name("") == "Motor" + assert extract_block_name("") == "Spare" + assert extract_block_name("no tag") is None + + def test_strip_block_tag_open(self): + assert strip_block_tag("Valve info") == "Valve info" + + def test_strip_block_tag_close(self): + assert strip_block_tag("end") == "end" + + def test_strip_block_tag_self_closing(self): + assert strip_block_tag("") == "" + + def test_strip_block_tag_no_tag(self): + assert strip_block_tag("just comment") == "just comment" + + def test_strip_block_tag_empty(self): + assert strip_block_tag("") == "" + + def test_strip_block_tag_middle(self): + assert strip_block_tag("Before After") == "Before After" + + def test_strip_block_tag_with_inequality(self): + text = "Value < 10" + assert strip_block_tag(text) == text + + +# Helper dataclass for testing matching functions +@dataclass +class MockRow: + comment: str + memory_type: str | None = None + + +class TestFindPairedTagIndex: + """Tests for find_paired_tag_index().""" + + def test_simple_open_close(self): + rows = [ + MockRow(""), + MockRow("middle"), + MockRow(""), + ] + assert find_paired_tag_index(rows, 0) == 2 + assert find_paired_tag_index(rows, 2) == 0 + + def test_self_closing_no_pair(self): + rows = [MockRow("")] + assert find_paired_tag_index(rows, 0) is None + + def test_nested_blocks_same_name(self): + rows = [ + MockRow(""), # 0 -> 5 + MockRow(""), # 1 -> 3 + MockRow("inner"), + MockRow(""), # 3 <- 1 + MockRow("outer"), + MockRow(""), # 5 <- 0 + ] + assert find_paired_tag_index(rows, 0) == 5 + assert find_paired_tag_index(rows, 1) == 3 + assert find_paired_tag_index(rows, 3) == 1 + assert find_paired_tag_index(rows, 5) == 0 + + def test_unmatched_open(self): + rows = [MockRow(""), MockRow("no close")] + assert find_paired_tag_index(rows, 0) is None + + def test_unmatched_close(self): + rows = [MockRow("no open"), MockRow("")] + assert find_paired_tag_index(rows, 1) is None + + def test_memory_type_filtering(self): + rows = [ + MockRow("", "T"), + MockRow("T data", "T"), + MockRow("", "TD"), + MockRow("TD data", "TD"), + MockRow("", "TD"), + MockRow("", "T"), + ] + assert find_paired_tag_index(rows, 0) == 5 + assert find_paired_tag_index(rows, 2) == 4 + + +class TestFindBlockRangeIndices: + """Tests for find_block_range_indices().""" + + def test_open_tag_range(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + assert find_block_range_indices(rows, 0) == (0, 2) + + def test_close_tag_range(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + assert find_block_range_indices(rows, 2) == (0, 2) + + def test_self_closing_range(self): + rows = [MockRow("")] + assert find_block_range_indices(rows, 0) == (0, 0) + + def test_unmatched_open_singular(self): + rows = [MockRow(""), MockRow("no close")] + assert find_block_range_indices(rows, 0) == (0, 0) + + def test_no_tag(self): + rows = [MockRow("just comment")] + assert find_block_range_indices(rows, 0) is None + + +class TestComputeAllBlockRanges: + """Tests for compute_all_block_ranges().""" + + def test_empty_rows(self): + assert compute_all_block_ranges([]) == [] + + def test_no_blocks(self): + rows = [MockRow("comment1"), MockRow("comment2")] + assert compute_all_block_ranges(rows) == [] + + def test_single_block(self): + rows = [ + MockRow(""), + MockRow("data"), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 2 + assert ranges[0].name == "Motor" + + def test_self_closing_block(self): + rows = [MockRow("")] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 0 + + def test_multiple_blocks(self): + rows = [ + MockRow(""), + MockRow(""), + MockRow(""), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 2 + assert ranges[0].name == "A" + assert ranges[1].name == "B" + + def test_nested_blocks(self): + rows = [ + MockRow(""), + MockRow(""), + MockRow(""), + MockRow(""), + ] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 2 + assert ranges[0] == BlockRange(0, 3, "Outer", None, None) + assert ranges[1] == BlockRange(1, 2, "Inner", None, None) + + def test_unclosed_tag_becomes_singular(self): + rows = [MockRow(""), MockRow("no close")] + ranges = compute_all_block_ranges(rows) + assert len(ranges) == 1 + assert ranges[0].start_idx == 0 + assert ranges[0].end_idx == 0 + + def test_bg_color_preserved(self): + rows = [MockRow(''), MockRow("")] + ranges = compute_all_block_ranges(rows) + assert ranges[0].bg_color == "Red" + + def test_memory_type_preserved(self): + rows = [MockRow("", "T"), MockRow("", "T")] + ranges = compute_all_block_ranges(rows) + assert ranges[0].memory_type == "T" + + +class TestValidateBlockSpan: + """Tests for validate_block_span().""" + + @dataclass + class FakeAddressRow: + memory_type: str + + def test_empty_rows(self): + is_valid, error = validate_block_span([]) + assert is_valid is True + assert error is None + + def test_single_type(self): + rows = [self.FakeAddressRow("DS"), self.FakeAddressRow("DS")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_paired_types_t_td(self): + rows = [self.FakeAddressRow("T"), self.FakeAddressRow("TD")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_paired_types_ct_ctd(self): + rows = [self.FakeAddressRow("CT"), self.FakeAddressRow("CTD")] + is_valid, error = validate_block_span(rows) + assert is_valid is True + + def test_incompatible_types(self): + rows = [self.FakeAddressRow("DS"), self.FakeAddressRow("DD")] + is_valid, error = validate_block_span(rows) + assert is_valid is False + assert error is not None + assert "DS" in error + assert "DD" in error + + def test_three_types_invalid(self): + rows = [ + self.FakeAddressRow("T"), + self.FakeAddressRow("TD"), + self.FakeAddressRow("DS"), + ] + is_valid, error = validate_block_span(rows) + assert is_valid is False diff --git a/tests/test_dataview.py b/tests/test_dataview.py new file mode 100644 index 0000000..63a10fa --- /dev/null +++ b/tests/test_dataview.py @@ -0,0 +1,393 @@ +"""Tests for pyclickplc.dataview — DataView model and CDV file I/O.""" + +from pyclickplc.dataview import ( + MAX_DATAVIEW_ROWS, + WRITABLE_SC, + WRITABLE_SD, + DataviewRow, + TypeCode, + create_empty_dataview, + display_to_storage, + get_type_code_for_address, + is_address_writable, + load_cdv, + save_cdv, + storage_to_display, +) + + +class TestGetTypeCodeForAddress: + """Tests for get_type_code_for_address function.""" + + def test_bit_addresses(self): + assert get_type_code_for_address("X001") == TypeCode.BIT + assert get_type_code_for_address("Y001") == TypeCode.BIT + assert get_type_code_for_address("C1") == TypeCode.BIT + assert get_type_code_for_address("T1") == TypeCode.BIT + assert get_type_code_for_address("CT1") == TypeCode.BIT + assert get_type_code_for_address("SC1") == TypeCode.BIT + + def test_int_addresses(self): + assert get_type_code_for_address("DS1") == TypeCode.INT + assert get_type_code_for_address("TD1") == TypeCode.INT + assert get_type_code_for_address("SD1") == TypeCode.INT + + def test_int2_addresses(self): + assert get_type_code_for_address("DD1") == TypeCode.INT2 + assert get_type_code_for_address("CTD1") == TypeCode.INT2 + + def test_hex_addresses(self): + assert get_type_code_for_address("DH1") == TypeCode.HEX + assert get_type_code_for_address("XD0") == TypeCode.HEX + assert get_type_code_for_address("YD0") == TypeCode.HEX + + def test_float_addresses(self): + assert get_type_code_for_address("DF1") == TypeCode.FLOAT + + def test_txt_addresses(self): + assert get_type_code_for_address("TXT1") == TypeCode.TXT + + def test_invalid_address(self): + assert get_type_code_for_address("INVALID") is None + assert get_type_code_for_address("") is None + + +class TestIsAddressWritable: + """Tests for is_address_writable function.""" + + def test_regular_addresses_writable(self): + assert is_address_writable("X001") is True + assert is_address_writable("Y001") is True + assert is_address_writable("C1") is True + assert is_address_writable("DS1") is True + assert is_address_writable("DD1") is True + assert is_address_writable("DF1") is True + + def test_xd_yd_readonly(self): + assert is_address_writable("XD0") is False + assert is_address_writable("XD0u") is False + assert is_address_writable("YD0") is False + assert is_address_writable("YD8") is False + + def test_sc_writable_addresses(self): + for addr in WRITABLE_SC: + assert is_address_writable(f"SC{addr}") is True + + def test_sc_readonly_addresses(self): + assert is_address_writable("SC1") is False + assert is_address_writable("SC100") is False + + def test_sd_writable_addresses(self): + for addr in WRITABLE_SD: + assert is_address_writable(f"SD{addr}") is True + + def test_sd_readonly_addresses(self): + assert is_address_writable("SD1") is False + assert is_address_writable("SD100") is False + + def test_invalid_address(self): + assert is_address_writable("INVALID") is False + assert is_address_writable("") is False + + +class TestDataviewRow: + """Tests for DataviewRow dataclass.""" + + def test_default_values(self): + row = DataviewRow() + assert row.address == "" + assert row.type_code == 0 + assert row.new_value == "" + assert row.nickname == "" + assert row.comment == "" + + def test_is_empty(self): + row = DataviewRow() + assert row.is_empty is True + + row.address = "X001" + assert row.is_empty is False + + row.address = " " + assert row.is_empty is True + + def test_is_writable(self): + row = DataviewRow(address="X001") + assert row.is_writable is True + + row.address = "XD0" + assert row.is_writable is False + + def test_memory_type(self): + row = DataviewRow(address="DS100") + assert row.memory_type == "DS" + + row.address = "" + assert row.memory_type is None + + def test_address_number(self): + row = DataviewRow(address="DS100") + assert row.address_number == "100" + + row.address = "XD0u" + assert row.address_number == "0u" + + def test_update_type_code(self): + row = DataviewRow(address="DS100") + assert row.update_type_code() is True + assert row.type_code == TypeCode.INT + + row.address = "INVALID" + assert row.update_type_code() is False + + def test_clear(self): + row = DataviewRow( + address="X001", + type_code=TypeCode.BIT, + new_value="1", + nickname="Test", + comment="Comment", + ) + row.clear() + assert row.address == "" + assert row.type_code == 0 + assert row.new_value == "" + assert row.nickname == "" + assert row.comment == "" + + +class TestCreateEmptyDataview: + """Tests for create_empty_dataview function.""" + + def test_creates_correct_count(self): + rows = create_empty_dataview() + assert len(rows) == MAX_DATAVIEW_ROWS + + def test_all_rows_empty(self): + rows = create_empty_dataview() + assert all(row.is_empty for row in rows) + + def test_rows_are_independent(self): + rows = create_empty_dataview() + rows[0].address = "X001" + assert rows[1].address == "" + + +class TestStorageToDisplay: + """Tests for storage_to_display conversion.""" + + def test_bit_values(self): + assert storage_to_display("1", TypeCode.BIT) == "1" + assert storage_to_display("0", TypeCode.BIT) == "0" + + def test_int_positive(self): + assert storage_to_display("0", TypeCode.INT) == "0" + assert storage_to_display("100", TypeCode.INT) == "100" + assert storage_to_display("32767", TypeCode.INT) == "32767" + + def test_int_negative(self): + assert storage_to_display("4294934528", TypeCode.INT) == "-32768" + assert storage_to_display("4294967295", TypeCode.INT) == "-1" + assert storage_to_display("65535", TypeCode.INT) == "-1" + + def test_int2_positive(self): + assert storage_to_display("0", TypeCode.INT2) == "0" + assert storage_to_display("100", TypeCode.INT2) == "100" + assert storage_to_display("2147483647", TypeCode.INT2) == "2147483647" + + def test_int2_negative(self): + assert storage_to_display("2147483648", TypeCode.INT2) == "-2147483648" + assert storage_to_display("4294967294", TypeCode.INT2) == "-2" + assert storage_to_display("4294967295", TypeCode.INT2) == "-1" + + def test_float_values(self): + assert storage_to_display("0", TypeCode.FLOAT) == "0" + assert storage_to_display("1065353216", TypeCode.FLOAT) == "1" + val = storage_to_display("1078523331", TypeCode.FLOAT) + assert val.startswith("3.14") + assert "-" in storage_to_display("4286578685", TypeCode.FLOAT) + + def test_hex_values(self): + assert storage_to_display("65535", TypeCode.HEX) == "FFFF" + assert storage_to_display("255", TypeCode.HEX) == "00FF" + assert storage_to_display("0", TypeCode.HEX) == "0000" + + def test_txt_values(self): + assert storage_to_display("48", TypeCode.TXT) == "0" + assert storage_to_display("65", TypeCode.TXT) == "A" + assert storage_to_display("90", TypeCode.TXT) == "Z" + assert storage_to_display("49", TypeCode.TXT) == "1" + + def test_empty_value(self): + assert storage_to_display("", TypeCode.INT) == "" + assert storage_to_display("", TypeCode.HEX) == "" + + def test_txt_space(self): + assert storage_to_display("32", TypeCode.TXT) == " " + + +class TestDisplayToStorage: + """Tests for display_to_storage conversion.""" + + def test_bit_values(self): + assert display_to_storage("1", TypeCode.BIT) == "1" + assert display_to_storage("0", TypeCode.BIT) == "0" + + def test_int_positive(self): + assert display_to_storage("0", TypeCode.INT) == "0" + assert display_to_storage("100", TypeCode.INT) == "100" + assert display_to_storage("32767", TypeCode.INT) == "32767" + + def test_int_negative(self): + assert display_to_storage("-32768", TypeCode.INT) == "4294934528" + assert display_to_storage("-1", TypeCode.INT) == "4294967295" + + def test_int2_positive(self): + assert display_to_storage("0", TypeCode.INT2) == "0" + assert display_to_storage("100", TypeCode.INT2) == "100" + + def test_int2_negative(self): + assert display_to_storage("-2147483648", TypeCode.INT2) == "2147483648" + assert display_to_storage("-2", TypeCode.INT2) == "4294967294" + + def test_float_values(self): + assert display_to_storage("0.0", TypeCode.FLOAT) == "0" + assert display_to_storage("1.0", TypeCode.FLOAT) == "1065353216" + assert display_to_storage("-1.0", TypeCode.FLOAT) == "3212836864" + + def test_hex_values(self): + assert display_to_storage("FFFF", TypeCode.HEX) == "65535" + assert display_to_storage("FF", TypeCode.HEX) == "255" + assert display_to_storage("0xFF", TypeCode.HEX) == "255" + assert display_to_storage("0", TypeCode.HEX) == "0" + + def test_txt_values(self): + assert display_to_storage("0", TypeCode.TXT) == "48" + assert display_to_storage("A", TypeCode.TXT) == "65" + assert display_to_storage("Z", TypeCode.TXT) == "90" + assert display_to_storage("1", TypeCode.TXT) == "49" + + def test_empty_value(self): + assert display_to_storage("", TypeCode.INT) == "" + assert display_to_storage("", TypeCode.HEX) == "" + + def test_txt_space(self): + assert display_to_storage(" ", TypeCode.TXT) == "32" + + def test_snapshot_data_consistency(self): + assert storage_to_display("4286578685", TypeCode.FLOAT) == "-3.402823E+38" + assert storage_to_display("2139095037", TypeCode.FLOAT) == "3.402823E+38" + assert storage_to_display("1078523331", TypeCode.FLOAT).startswith("3.14") + assert storage_to_display("0", TypeCode.HEX) == "0000" + assert storage_to_display("65535", TypeCode.HEX) == "FFFF" + assert storage_to_display("1", TypeCode.HEX) == "0001" + assert storage_to_display("4294967295", TypeCode.INT) == "-1" + assert storage_to_display("4294967295", TypeCode.INT2) == "-1" + + +class TestRoundTripConversion: + """Tests for round-trip storage <-> display conversion.""" + + def test_int_roundtrip(self): + for val in ["-32768", "-1", "0", "100", "32767"]: + storage = display_to_storage(val, TypeCode.INT) + display = storage_to_display(storage, TypeCode.INT) + assert display == val, f"Round-trip failed for {val}" + + def test_int2_roundtrip(self): + for val in ["-2147483648", "-2", "-1", "0", "100", "2147483647"]: + storage = display_to_storage(val, TypeCode.INT2) + display = storage_to_display(storage, TypeCode.INT2) + assert display == val, f"Round-trip failed for {val}" + + def test_hex_roundtrip(self): + test_cases = [ + ("0", "0000"), + ("FF", "00FF"), + ("FFFF", "FFFF"), + ] + for input_val, expected_display in test_cases: + storage = display_to_storage(input_val, TypeCode.HEX) + display = storage_to_display(storage, TypeCode.HEX) + assert display == expected_display, f"Round-trip failed for {input_val}" + + def test_txt_roundtrip(self): + for val in ["0", "A", "Z", "1"]: + storage = display_to_storage(val, TypeCode.TXT) + display = storage_to_display(storage, TypeCode.TXT) + assert display == val, f"Round-trip failed for {val}" + + +class TestLoadCdv: + """Tests for load_cdv function.""" + + def test_load_basic_cdv(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["0,0,0\n"] + lines.append("X001,768\n") + lines.append("DS1,0\n") + for _ in range(98): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, has_new_values, header = load_cdv(cdv) + assert len(rows) == MAX_DATAVIEW_ROWS + assert has_new_values is False + assert rows[0].address == "X001" + assert rows[0].type_code == 768 + assert rows[1].address == "DS1" + assert rows[1].type_code == 0 + assert rows[2].is_empty + + def test_load_with_new_values(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n"] + lines.append("X001,768,1\n") + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, has_new_values, _header = load_cdv(cdv) + assert has_new_values is True + assert rows[0].new_value == "1" + + def test_load_nonexistent(self, tmp_path): + import pytest + + with pytest.raises(FileNotFoundError): + load_cdv(tmp_path / "missing.cdv") + + +class TestSaveCdv: + """Tests for save_cdv function.""" + + def test_save_and_reload(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].type_code = TypeCode.BIT + rows[1].address = "DS1" + rows[1].type_code = TypeCode.INT + + save_cdv(cdv, rows, has_new_values=False) + + loaded_rows, has_new_values, header = load_cdv(cdv) + assert has_new_values is False + assert loaded_rows[0].address == "X001" + assert loaded_rows[0].type_code == TypeCode.BIT + assert loaded_rows[1].address == "DS1" + assert loaded_rows[1].type_code == TypeCode.INT + assert loaded_rows[2].is_empty + + def test_save_with_new_values(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].type_code = TypeCode.BIT + rows[0].new_value = "1" + + save_cdv(cdv, rows, has_new_values=True) + + loaded_rows, has_new_values, _header = load_cdv(cdv) + assert has_new_values is True + assert loaded_rows[0].new_value == "1" diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py new file mode 100644 index 0000000..aa62505 --- /dev/null +++ b/tests/test_nicknames.py @@ -0,0 +1,213 @@ +"""Tests for pyclickplc.nicknames — CSV read/write for address data.""" + +from pyclickplc.addresses import AddressRecord +from pyclickplc.banks import DataType +from pyclickplc.nicknames import ( + CSV_COLUMNS, + DATA_TYPE_CODE_TO_STR, + DATA_TYPE_STR_TO_CODE, + read_csv, + read_mdb_csv, + write_csv, +) + + +class TestConstants: + """Tests for CSV constants.""" + + def test_csv_columns(self): + assert CSV_COLUMNS == [ + "Address", + "Data Type", + "Nickname", + "Initial Value", + "Retentive", + "Address Comment", + ] + + def test_data_type_str_to_code(self): + assert DATA_TYPE_STR_TO_CODE["BIT"] == 0 + assert DATA_TYPE_STR_TO_CODE["INT"] == 1 + assert DATA_TYPE_STR_TO_CODE["TEXT"] == 6 + assert DATA_TYPE_STR_TO_CODE["TXT"] == 6 + + def test_data_type_code_to_str(self): + assert DATA_TYPE_CODE_TO_STR[0] == "BIT" + assert DATA_TYPE_CODE_TO_STR[1] == "INT" + assert DATA_TYPE_CODE_TO_STR[6] == "TEXT" + + +class TestReadCsv: + """Tests for read_csv function.""" + + def test_read_basic(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Input1",0,No,"First input"\n' + 'DS1,INT,"Temp",100,Yes,"Temperature"\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert len(records) == 2 + + # Find the X001 record + x_records = [r for r in records.values() if r.memory_type == "X"] + assert len(x_records) == 1 + assert x_records[0].nickname == "Input1" + assert x_records[0].comment == "First input" + assert x_records[0].data_type == DataType.BIT + assert x_records[0].retentive is False + + # Find the DS1 record + ds_records = [r for r in records.values() if r.memory_type == "DS"] + assert len(ds_records) == 1 + assert ds_records[0].nickname == "Temp" + assert ds_records[0].retentive is True + + def test_read_empty_rows_skipped(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + ",BIT,,0,No,\n" + 'X001,BIT,"Input1",0,No,""\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert len(records) == 1 + + def test_read_invalid_address_skipped(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'INVALID,BIT,"Bad",0,No,""\n' + 'X001,BIT,"Good",0,No,""\n', + encoding="utf-8", + ) + + records = read_csv(csv_path) + assert len(records) == 1 + + +class TestWriteCsv: + """Tests for write_csv function.""" + + def test_write_basic(self, tmp_path): + csv_path = tmp_path / "test.csv" + records = { + 1: AddressRecord(memory_type="X", address=1, nickname="Input1", comment="First input"), + } + + count = write_csv(csv_path, records) + assert count == 1 + + content = csv_path.read_text(encoding="utf-8") + lines = content.strip().split("\n") + assert lines[0] == ",".join(CSV_COLUMNS) + assert '"Input1"' in lines[1] + assert '"First input"' in lines[1] + + def test_write_skips_empty_records(self, tmp_path): + csv_path = tmp_path / "test.csv" + records = { + 1: AddressRecord(memory_type="X", address=1), # No content + 2: AddressRecord(memory_type="X", address=2, nickname="HasNick"), + } + + count = write_csv(csv_path, records) + assert count == 1 # Only one record has content + + def test_write_sorted_by_memory_type(self, tmp_path): + csv_path = tmp_path / "test.csv" + # DS comes after X in MEMORY_TYPE_BASES ordering + records = { + 100_000_001: AddressRecord(memory_type="DS", address=1, nickname="DS_Nick"), + 1: AddressRecord(memory_type="X", address=1, nickname="X_Nick"), + } + + write_csv(csv_path, records) + + content = csv_path.read_text(encoding="utf-8") + lines = content.strip().split("\n") + # X should come before DS + assert "X001" in lines[1] + assert "DS1" in lines[2] + + +class TestReadMdbCsv: + """Tests for read_mdb_csv function.""" + + def test_read_basic(self, tmp_path): + csv_path = tmp_path / "Address.csv" + csv_path.write_text( + "AddrKey,MemoryType,Address,DataType,Nickname,Use,InitialValue,Retentive,Comment\n" + "1,X,1,0,Input1,1,0,0,First input\n" + "100663297,DS,1,1,Temp,1,100,1,Temperature\n", + encoding="utf-8", + ) + + records = read_mdb_csv(csv_path) + assert len(records) == 2 + + x_records = [r for r in records.values() if r.memory_type == "X"] + assert len(x_records) == 1 + assert x_records[0].nickname == "Input1" + assert x_records[0].comment == "First input" + + def test_read_skips_empty_nicknames(self, tmp_path): + csv_path = tmp_path / "Address.csv" + csv_path.write_text( + "AddrKey,MemoryType,Address,DataType,Nickname,Use,InitialValue,Retentive,Comment\n" + "1,X,1,0,,,0,0,\n" + "2,X,2,0,Input2,1,0,0,Second\n", + encoding="utf-8", + ) + + records = read_mdb_csv(csv_path) + assert len(records) == 1 + + +class TestRoundTrip: + """Tests for write then read round-trip.""" + + def test_roundtrip(self, tmp_path): + csv_path = tmp_path / "roundtrip.csv" + original = { + 1: AddressRecord( + memory_type="X", + address=1, + nickname="Motor1", + comment="Main motor", + data_type=DataType.BIT, + retentive=False, + ), + 100_663_297: AddressRecord( + memory_type="DS", + address=1, + nickname="Speed", + comment="Motor speed", + initial_value="500", + data_type=DataType.INT, + retentive=True, + ), + } + + write_csv(csv_path, original) + loaded = read_csv(csv_path) + + assert len(loaded) == 2 + + # Check X001 + x_records = [r for r in loaded.values() if r.memory_type == "X"] + assert len(x_records) == 1 + assert x_records[0].nickname == "Motor1" + assert x_records[0].comment == "Main motor" + + # Check DS1 + ds_records = [r for r in loaded.values() if r.memory_type == "DS"] + assert len(ds_records) == 1 + assert ds_records[0].nickname == "Speed" + assert ds_records[0].comment == "Motor speed" + assert ds_records[0].retentive is True From 215378d5210a7b1b87bb276289e511e2b852cdb6 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:32:53 -0500 Subject: [PATCH 05/55] Fix _SPARSE_RANGES to match actual CLICK PLC X/Y hardware slots Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/banks.py | 8 ++++---- tests/test_banks.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pyclickplc/banks.py b/src/pyclickplc/banks.py index 9922092..637b13f 100644 --- a/src/pyclickplc/banks.py +++ b/src/pyclickplc/banks.py @@ -56,13 +56,13 @@ class DataType(IntEnum): (1, 16), (21, 36), (101, 116), - (121, 136), (201, 216), - (221, 236), (301, 316), - (321, 336), + (401, 416), + (501, 516), + (601, 616), + (701, 716), (801, 816), - (821, 836), ) diff --git a/tests/test_banks.py b/tests/test_banks.py index 320da3f..f515783 100644 --- a/tests/test_banks.py +++ b/tests/test_banks.py @@ -87,7 +87,7 @@ def test_sparse_ranges_on_x_y_only(self): def test_sparse_ranges_structure(self): assert len(_SPARSE_RANGES) == 10 assert _SPARSE_RANGES[0] == (1, 16) - assert _SPARSE_RANGES[-1] == (821, 836) + assert _SPARSE_RANGES[-1] == (801, 816) def test_interleaved_pairs(self): assert BANKS["T"].interleaved_with == "TD" @@ -190,7 +190,7 @@ def test_sparse_valid(self): assert is_valid_address("X", 1) is True assert is_valid_address("X", 16) is True assert is_valid_address("X", 101) is True - assert is_valid_address("Y", 821) is True + assert is_valid_address("Y", 801) is True def test_sparse_invalid_gap(self): assert is_valid_address("X", 17) is False From f6725245087cbd58306f6200d9fb2a7835e66422 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:38:08 -0500 Subject: [PATCH 06/55] Add modbus protocol mapping module ModbusMapping dataclass, MODBUS_MAPPINGS for all 16 banks, forward/reverse address mapping (with sparse coil and XD/YD stride-2 support), and struct-based pack/unpack for register values. Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 21 ++ src/pyclickplc/modbus.py | 383 ++++++++++++++++++++ tests/test_modbus.py | 725 +++++++++++++++++++++++++++++++++++++ 3 files changed, 1129 insertions(+) create mode 100644 src/pyclickplc/modbus.py create mode 100644 tests/test_modbus.py diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index a6ef271..e523659 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -60,6 +60,17 @@ save_cdv, storage_to_display, ) +from .modbus import ( + MODBUS_MAPPINGS, + MODBUS_SIGNED, + MODBUS_WIDTH, + STRUCT_FORMATS, + ModbusMapping, + modbus_to_plc, + pack_value, + plc_to_modbus, + unpack_value, +) from .nicknames import ( CSV_COLUMNS, DATA_TYPE_CODE_TO_STR, @@ -124,6 +135,16 @@ "find_block_range_indices", "compute_all_block_ranges", "validate_block_span", + # modbus + "ModbusMapping", + "MODBUS_MAPPINGS", + "MODBUS_WIDTH", + "MODBUS_SIGNED", + "STRUCT_FORMATS", + "plc_to_modbus", + "modbus_to_plc", + "pack_value", + "unpack_value", # dataview "TypeCode", "DataviewRow", diff --git a/src/pyclickplc/modbus.py b/src/pyclickplc/modbus.py new file mode 100644 index 0000000..8e212f7 --- /dev/null +++ b/src/pyclickplc/modbus.py @@ -0,0 +1,383 @@ +"""Modbus protocol mapping layer for CLICK PLCs. + +Maps PLC addresses to/from Modbus coil/register addresses, +and packs/unpacks Python values into Modbus registers. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from .banks import BANKS, DataType + +# ============================================================================== +# DataType-derived constants +# ============================================================================== + +# Number of Modbus registers per value for each DataType +MODBUS_WIDTH: dict[DataType, int] = { + DataType.BIT: 1, + DataType.INT: 1, + DataType.INT2: 2, + DataType.FLOAT: 2, + DataType.HEX: 1, + DataType.TXT: 1, +} + +# Whether the DataType is signed +MODBUS_SIGNED: dict[DataType, bool] = { + DataType.INT: True, + DataType.INT2: True, + DataType.HEX: False, + DataType.FLOAT: False, +} + +# struct format characters for numeric types +STRUCT_FORMATS: dict[DataType, str] = { + DataType.INT: "h", + DataType.INT2: "i", + DataType.FLOAT: "f", + DataType.HEX: "H", +} + +# ============================================================================== +# ModbusMapping +# ============================================================================== + + +@dataclass(frozen=True) +class ModbusMapping: + """Modbus address mapping configuration for a PLC memory bank.""" + + bank: str + base: int + function_codes: frozenset[int] + is_coil: bool + width: int = 1 + signed: bool = True + writable: frozenset[int] | None = None + + @property + def is_writable(self) -> bool: + """True if any write FC (5, 6, 15, 16) is in function_codes.""" + return bool(self.function_codes & {5, 6, 15, 16}) + + +# ============================================================================== +# Writable subsets (Modbus-specific, distinct from dataview.py) +# ============================================================================== + +# SC: Modbus-writable subset (excludes 50/51 which are ladder-only) +_MODBUS_WRITABLE_SC: frozenset[int] = frozenset({53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) + +# SD: Modbus-writable subset +_MODBUS_WRITABLE_SD: frozenset[int] = frozenset( + { + 29, + 31, + 32, + 34, + 35, + 36, + 40, + 41, + 42, + 50, + 51, + 60, + 61, + 106, + 107, + 108, + 112, + 113, + 114, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 214, + 215, + } +) + +# ============================================================================== +# MODBUS_MAPPINGS — all 16 banks +# ============================================================================== + +MODBUS_MAPPINGS: dict[str, ModbusMapping] = { + # --- Coil banks --- + "X": ModbusMapping("X", 0, frozenset({2}), is_coil=True), + "Y": ModbusMapping("Y", 8192, frozenset({1, 5, 15}), is_coil=True), + "C": ModbusMapping("C", 16384, frozenset({1, 5, 15}), is_coil=True), + "T": ModbusMapping("T", 45057, frozenset({2}), is_coil=True), + "CT": ModbusMapping("CT", 49152, frozenset({2}), is_coil=True), + "SC": ModbusMapping("SC", 61440, frozenset({2}), is_coil=True, writable=_MODBUS_WRITABLE_SC), + # --- Register banks --- + "DS": ModbusMapping("DS", 0, frozenset({3, 6, 16}), is_coil=False, width=1, signed=True), + "DD": ModbusMapping("DD", 16384, frozenset({3, 6, 16}), is_coil=False, width=2, signed=True), + "DH": ModbusMapping("DH", 24576, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "DF": ModbusMapping("DF", 28672, frozenset({3, 6, 16}), is_coil=False, width=2, signed=False), + "TXT": ModbusMapping("TXT", 36864, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "TD": ModbusMapping("TD", 45056, frozenset({3, 6, 16}), is_coil=False, width=1, signed=True), + "CTD": ModbusMapping("CTD", 49152, frozenset({3, 6, 16}), is_coil=False, width=2, signed=True), + "XD": ModbusMapping("XD", 57344, frozenset({4}), is_coil=False, width=1, signed=False), + "YD": ModbusMapping("YD", 57856, frozenset({3, 6, 16}), is_coil=False, width=1, signed=False), + "SD": ModbusMapping( + "SD", + 61440, + frozenset({4}), + is_coil=False, + width=1, + signed=False, + writable=_MODBUS_WRITABLE_SD, + ), +} + +# Split mappings by type for reverse lookups +_COIL_MAPPINGS: list[tuple[str, ModbusMapping]] = [ + (k, v) for k, v in MODBUS_MAPPINGS.items() if v.is_coil +] +_REGISTER_MAPPINGS: list[tuple[str, ModbusMapping]] = [ + (k, v) for k, v in MODBUS_MAPPINGS.items() if not v.is_coil +] + +# ============================================================================== +# Sparse coil helpers (private) +# ============================================================================== + +# X/Y sparse ranges from BankConfig +_SPARSE_RANGES_OPT = BANKS["X"].valid_ranges +assert _SPARSE_RANGES_OPT is not None +_SPARSE_RANGES: tuple[tuple[int, int], ...] = _SPARSE_RANGES_OPT + + +def _sparse_plc_to_coil(base: int, index: int) -> int: + """Forward map sparse PLC index to coil address. + + Uses the formula from CLICKDEVICE_SPEC: + - CPU slot 1 (001-016): base + index - 1 + - CPU slot 2 (021-036): base + 16 + (index - 21) + - Expansion (101+): base + 32 * hundred + (unit - 1) + """ + if index <= 16: + return base + index - 1 + if index <= 36: + return base + 16 + (index - 21) + hundred = index // 100 + unit = index % 100 + return base + 32 * hundred + (unit - 1) + + +def _sparse_coil_to_plc(bank: str, base: int, coil: int) -> tuple[str, int] | None: + """Reverse map coil address to (bank, index) or None for gaps. + + Uses the formula from CLICKSERVER_SPEC: + - offset < 16: CPU slot 1 -> *001-*016 + - offset < 32: CPU slot 2 -> *021-*036 + - else: expansion -> hundred*100 + unit + """ + offset = coil - base + if offset < 0: + return None + + if offset < 16: + return bank, offset + 1 + if offset < 32: + return bank, 21 + (offset - 16) + + # Expansion slots + hundred = offset // 32 + unit = (offset % 32) + 1 + if unit > 16: + return None # Gap + index = hundred * 100 + unit + # Validate against known ranges + if not any(lo <= index <= hi for lo, hi in _SPARSE_RANGES): + return None + return bank, index + + +# Total coil address space for sparse banks +# Last valid slot is 8xx (hundred=8), max offset = 8*32 + 15 = 271 +_SPARSE_COIL_SPAN = 8 * 32 + 16 # 272 + +# Coil spans for non-sparse banks +_COIL_SPANS: dict[str, int] = { + "C": BANKS["C"].max_addr, # 2000 + "T": BANKS["T"].max_addr, # 500 + "CT": BANKS["CT"].max_addr, # 250 + "SC": BANKS["SC"].max_addr, # 1000 +} + +# ============================================================================== +# Forward mapping: plc_to_modbus +# ============================================================================== + + +def plc_to_modbus(bank: str, index: int) -> tuple[int, int]: + """Map PLC address to (modbus_address, register_count). + + Args: + bank: Bank name (e.g. "X", "DS", "XD") + index: PLC display address (e.g. 1 for X001, 0 for XD0) + + Returns: + Tuple of (modbus_address, register_count) + + Raises: + ValueError: If bank or index is invalid + """ + mapping = MODBUS_MAPPINGS.get(bank) + if mapping is None: + raise ValueError(f"Unknown bank: {bank!r}") + + if not _is_valid_index(bank, index): + raise ValueError(f"Invalid address: {bank}{index}") + + if mapping.is_coil: + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + # Sparse coils (X/Y) + return _sparse_plc_to_coil(mapping.base, index), 1 + # Standard coils + return mapping.base + (index - 1), 1 + + # Registers + if bank in ("XD", "YD"): + # XD/YD: stride 2, each value is 1 register + return mapping.base + index * 2, 1 + + # Standard registers + return mapping.base + mapping.width * (index - 1), mapping.width + + +def _is_valid_index(bank: str, index: int) -> bool: + """Check if index is valid for the given bank.""" + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + return any(lo <= index <= hi for lo, hi in bank_cfg.valid_ranges) + return bank_cfg.min_addr <= index <= bank_cfg.max_addr + + +# ============================================================================== +# Reverse mapping: modbus_to_plc +# ============================================================================== + + +def modbus_to_plc(address: int, is_coil: bool) -> tuple[str, int] | None: + """Reverse map Modbus address to (bank, display_index) or None. + + Args: + address: Raw Modbus coil or register address + is_coil: True for coil address space, False for register space + + Returns: + Tuple of (bank_name, display_index) or None if unmapped + """ + if is_coil: + return _reverse_coil(address) + return _reverse_register(address) + + +def _reverse_coil(address: int) -> tuple[str, int] | None: + """Reverse map a Modbus coil address to (bank, index).""" + for bank, mapping in _COIL_MAPPINGS: + bank_cfg = BANKS[bank] + if bank_cfg.valid_ranges is not None: + # Sparse bank (X/Y) + if mapping.base <= address < mapping.base + _SPARSE_COIL_SPAN: + return _sparse_coil_to_plc(bank, mapping.base, address) + else: + span = _COIL_SPANS[bank] + if mapping.base <= address < mapping.base + span: + return bank, address - mapping.base + 1 + return None + + +def _reverse_register(address: int) -> tuple[str, int] | None: + """Reverse map a Modbus register address to (bank, index).""" + for bank, mapping in _REGISTER_MAPPINGS: + if bank in ("XD", "YD"): + max_display = 8 # XD0..XD8 / YD0..YD8 + end = mapping.base + max_display * 2 + 1 + if mapping.base <= address < end: + offset = address - mapping.base + if offset % 2 != 0: + return None # Upper byte slot (XD0u etc.) + return bank, offset // 2 + continue + + # Standard register banks + max_addr = BANKS[bank].max_addr + end = mapping.base + mapping.width * max_addr + if mapping.base <= address < end: + offset = address - mapping.base + if offset % mapping.width != 0: + return None # Mid-value register + return bank, offset // mapping.width + 1 + return None + + +# ============================================================================== +# Pack / Unpack +# ============================================================================== + + +def pack_value(value: bool | int | float | str, data_type: DataType) -> list[int]: + """Pack a Python value into Modbus register(s). + + Args: + value: The value to pack + data_type: The DataType determining the encoding + + Returns: + List of 16-bit register values + + Raises: + ValueError: If data_type is BIT (coils don't use register packing) + """ + if data_type == DataType.BIT: + raise ValueError("BIT values use coils, not registers") + + if data_type == DataType.TXT: + return [ord(str(value)) & 0xFFFF] + + fmt = STRUCT_FORMATS[data_type] + raw = struct.pack(f"<{fmt}", value) + + if len(raw) == 2: + return [struct.unpack(" int | float | str: + """Unpack Modbus register(s) into a Python value. + + Args: + registers: List of 16-bit register values + data_type: The DataType determining the decoding + + Returns: + The unpacked Python value + """ + if data_type == DataType.TXT: + return chr(registers[0] & 0xFF) + + fmt = STRUCT_FORMATS[data_type] + + if len(registers) == 1: + raw = struct.pack(" gap.""" + assert modbus_to_plc(48, is_coil=True) is None + + def test_coil_8240_gap(self): + """Coil 8240 = Y base + 48: hundred=1, unit=17 -> gap.""" + assert modbus_to_plc(8240, is_coil=True) is None + + # --- Unmapped coils --- + + def test_coil_10000_unmapped(self): + assert modbus_to_plc(10000, is_coil=True) is None + + # --- Standard registers --- + + def test_reg_0_ds1(self): + assert modbus_to_plc(0, is_coil=False) == ("DS", 1) + + def test_reg_4499_ds4500(self): + assert modbus_to_plc(4499, is_coil=False) == ("DS", 4500) + + def test_reg_16384_dd1(self): + assert modbus_to_plc(16384, is_coil=False) == ("DD", 1) + + def test_reg_16386_dd2(self): + assert modbus_to_plc(16386, is_coil=False) == ("DD", 2) + + def test_reg_16385_dd1_mid(self): + """Register 16385 is the second register of DD1 — mid-value.""" + assert modbus_to_plc(16385, is_coil=False) is None + + def test_reg_24576_dh1(self): + assert modbus_to_plc(24576, is_coil=False) == ("DH", 1) + + def test_reg_28672_df1(self): + assert modbus_to_plc(28672, is_coil=False) == ("DF", 1) + + def test_reg_28674_df2(self): + assert modbus_to_plc(28674, is_coil=False) == ("DF", 2) + + def test_reg_36864_txt1(self): + assert modbus_to_plc(36864, is_coil=False) == ("TXT", 1) + + def test_reg_45056_td1(self): + assert modbus_to_plc(45056, is_coil=False) == ("TD", 1) + + def test_reg_49152_ctd1(self): + assert modbus_to_plc(49152, is_coil=False) == ("CTD", 1) + + def test_reg_49154_ctd2(self): + assert modbus_to_plc(49154, is_coil=False) == ("CTD", 2) + + def test_reg_61440_sd1(self): + assert modbus_to_plc(61440, is_coil=False) == ("SD", 1) + + # --- XD/YD registers --- + + def test_reg_57344_xd0(self): + assert modbus_to_plc(57344, is_coil=False) == ("XD", 0) + + def test_reg_57346_xd1(self): + assert modbus_to_plc(57346, is_coil=False) == ("XD", 1) + + def test_reg_57345_xd0u_gap(self): + """Register 57345 is XD0u (odd offset) -> None.""" + assert modbus_to_plc(57345, is_coil=False) is None + + def test_reg_57856_yd0(self): + assert modbus_to_plc(57856, is_coil=False) == ("YD", 0) + + def test_reg_57858_yd1(self): + assert modbus_to_plc(57858, is_coil=False) == ("YD", 1) + + # --- Unmapped registers --- + + def test_reg_5000_unmapped(self): + assert modbus_to_plc(5000, is_coil=False) is None + + +# ============================================================================== +# Forward/Reverse round-trip +# ============================================================================== + + +class TestRoundTrip: + """Verify that plc_to_modbus and modbus_to_plc are inverses.""" + + @pytest.mark.parametrize( + "bank,index", + [ + ("X", 1), + ("X", 16), + ("X", 21), + ("X", 36), + ("X", 101), + ("X", 116), + ("X", 201), + ("X", 816), + ("Y", 1), + ("Y", 16), + ("Y", 101), + ("Y", 816), + ("C", 1), + ("C", 1000), + ("C", 2000), + ("T", 1), + ("T", 500), + ("CT", 1), + ("CT", 250), + ("SC", 1), + ("SC", 1000), + ], + ) + def test_coil_round_trip(self, bank: str, index: int): + addr, _ = plc_to_modbus(bank, index) + result = modbus_to_plc(addr, is_coil=True) + assert result == (bank, index) + + @pytest.mark.parametrize( + "bank,index", + [ + ("DS", 1), + ("DS", 4500), + ("DD", 1), + ("DD", 1000), + ("DH", 1), + ("DH", 500), + ("DF", 1), + ("DF", 500), + ("TXT", 1), + ("TXT", 1000), + ("TD", 1), + ("TD", 500), + ("CTD", 1), + ("CTD", 250), + ("SD", 1), + ("SD", 1000), + ("XD", 0), + ("XD", 1), + ("XD", 8), + ("YD", 0), + ("YD", 1), + ("YD", 8), + ], + ) + def test_register_round_trip(self, bank: str, index: int): + addr, _ = plc_to_modbus(bank, index) + result = modbus_to_plc(addr, is_coil=False) + assert result == (bank, index) + + +# ============================================================================== +# Pack / Unpack +# ============================================================================== + + +class TestPackValue: + """Tests for pack_value.""" + + # --- INT (int16 signed) --- + + def test_int_zero(self): + assert pack_value(0, DataType.INT) == [0] + + def test_int_positive(self): + assert pack_value(1, DataType.INT) == [1] + + def test_int_negative(self): + regs = pack_value(-1, DataType.INT) + assert regs == [0xFFFF] + + def test_int_max(self): + assert pack_value(32767, DataType.INT) == [32767] + + def test_int_min(self): + regs = pack_value(-32768, DataType.INT) + assert regs == [0x8000] + + # --- HEX (uint16) --- + + def test_hex_zero(self): + assert pack_value(0, DataType.HEX) == [0] + + def test_hex_ffff(self): + assert pack_value(0xFFFF, DataType.HEX) == [0xFFFF] + + def test_hex_abcd(self): + assert pack_value(0xABCD, DataType.HEX) == [0xABCD] + + # --- INT2 (int32 signed) --- + + def test_int2_zero(self): + assert pack_value(0, DataType.INT2) == [0, 0] + + def test_int2_positive(self): + regs = pack_value(100000, DataType.INT2) + raw = struct.pack(" Date: Fri, 6 Feb 2026 13:51:46 -0500 Subject: [PATCH 07/55] Add ClickClient (async Modbus TCP driver) and ClickServer (Modbus TCP simulator) with full CLICK PLC address space support. - client.py: ClickClient with AddressAccessor bank accessors, AddressInterface for string-based access, TagInterface for nickname-based access, TXT 2-chars-per-register handling, sparse X/Y coil support - server.py: DataProvider protocol, MemoryDataProvider (in-memory backend), _ClickDeviceContext (custom pymodbus datastore with reverse mapping, FC 06 read-modify-write for width-2 types, SC/SD writability enforcement) - modbus.py: Add modbus_to_plc_register() extended reverse mapping (returns reg_position for mid-value registers) - 142 new tests (559 total): unit tests for client/server + integration round-trips for all data types --- .claude/settings.local.json | 2 + pyproject.toml | 3 + spec/ARCHITECTURE.md | 14 +- spec/CLICKDEVICE_SPEC.md | 12 +- spec/CLICKSERVER_SPEC.md | 8 +- spec/HANDOFF.md | 33 ++- src/pyclickplc/__init__.py | 9 + src/pyclickplc/client.py | 577 ++++++++++++++++++++++++++++++++++++ src/pyclickplc/modbus.py | 33 +++ src/pyclickplc/server.py | 397 +++++++++++++++++++++++++ tests/test_addresses.py | 2 +- tests/test_banks.py | 2 +- tests/test_client.py | 490 ++++++++++++++++++++++++++++++ tests/test_integration.py | 229 ++++++++++++++ tests/test_modbus.py | 2 +- tests/test_server.py | 448 ++++++++++++++++++++++++++++ uv.lock | 37 +++ 17 files changed, 2264 insertions(+), 34 deletions(-) create mode 100644 src/pyclickplc/client.py create mode 100644 src/pyclickplc/server.py create mode 100644 tests/test_client.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_server.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f93063b..65d04ff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,6 +4,8 @@ "Bash(dir:*)", "Bash(findstr:*)", "Bash(make:*)", + "Bash(uv sync:*)", + "WebFetch(domain:raw.githubusercontent.com)" ] } } diff --git a/pyproject.toml b/pyproject.toml index caf7827..5ca2aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ # ---- Main dependencies ---- dependencies = [ + "pymodbus>=3.8", ] @@ -47,6 +48,7 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.3.5", + "pytest-asyncio>=1.0", "ruff>=0.11.0", "codespell>=2.4.1", "rich>=13.9.4", @@ -159,3 +161,4 @@ testpaths = [ ] norecursedirs = [] filterwarnings = [] +asyncio_mode = "auto" diff --git a/spec/ARCHITECTURE.md b/spec/ARCHITECTURE.md index 56ebccc..833dd0b 100644 --- a/spec/ARCHITECTURE.md +++ b/spec/ARCHITECTURE.md @@ -14,7 +14,7 @@ High-level plan for the `pyclickplc` package — shared CLICK PLC knowledge cons | BlockTag parsing & computation | ClickNick, pyrung | | DataView CDV file I/O | ClickNick | | Nickname/field validation | ClickNick | -| Modbus client (ClickDriver) | pyrung, standalone | +| Modbus client (ClickClient) | pyrung, standalone | | Modbus server (ClickServer) | pyrung (testing), standalone | --- @@ -31,7 +31,7 @@ pyclickplc/ ├── nicknames.py # Nickname CSV read/write, NicknameProject ├── dataview.py # DataView .cdv file I/O ├── modbus.py # Modbus mapping, register packing, sparse logic -├── client.py # ClickDriver (Modbus TCP client) +├── client.py # ClickClient (Modbus TCP client) └── server.py # ClickServer (Modbus TCP server) ``` @@ -149,12 +149,12 @@ New code (from CLICKDEVICE_SPEC / CLICKSERVER_SPEC): Note: Sparse addressing *validity* (which addresses exist) is in `banks.py`. Sparse *Modbus offset calculation* (mapping valid addresses to sequential coil numbers) is here. -### `client.py` — ClickDriver +### `client.py` — ClickClient Depends on: `modbus.py`, `nicknames.py` (optional, for tag loading) From CLICKDEVICE_SPEC: -- `ClickDriver` class (async Modbus TCP client) +- `ClickClient` class (async Modbus TCP client) - `AddressAccessor` — `plc.df.read(1)` - `AddressInterface` — `plc.addr.read('df1')` - `TagInterface` — `plc.tag.read('MyTag')` @@ -385,7 +385,7 @@ XD and YD are byte-grouped views of X/Y inputs/outputs exposed by the CLICK prog **Module:** `client.py` -- New code — `ClickDriver` per CLICKDEVICE_SPEC +- New code — `ClickClient` per CLICKDEVICE_SPEC - `AddressAccessor`, `AddressInterface`, `TagInterface` - Uses `modbus.py` for mapping/packing, `nicknames.py` for tag loading - Tests with mocked Modbus client @@ -402,7 +402,7 @@ XD and YD are byte-grouped views of X/Y inputs/outputs exposed by the CLICK prog ### Phase 7: Integration - Update ClickNick imports (mechanical find-and-replace) -- Integration tests: ClickDriver ↔ ClickServer round-trips +- Integration tests: ClickClient ↔ ClickServer round-trips - Wire into pyrung - Delete moved code from ClickNick @@ -442,6 +442,6 @@ from pyclickplc.nicknames import read_csv, write_csv, load_nickname_file, Nickna from pyclickplc.dataview import read_cdv, write_cdv # Modbus (import-guarded, requires pyclickplc[modbus]) -from pyclickplc.client import ClickDriver +from pyclickplc.client import ClickClient from pyclickplc.server import ClickServer, MemoryDataProvider, DataProvider ``` diff --git a/spec/CLICKDEVICE_SPEC.md b/spec/CLICKDEVICE_SPEC.md index 74dd3b6..265c411 100644 --- a/spec/CLICKDEVICE_SPEC.md +++ b/spec/CLICKDEVICE_SPEC.md @@ -10,7 +10,7 @@ A Python driver for AutomationDirect CLICK Plcs using Modbus TCP/IP. 2. [Dependencies](#dependencies) 3. [Data Structures](#data-structures) 4. [Address Types & Configuration](#address-types--configuration) -5. [ClickDriver Class](#clickclient-class) +5. [ClickClient Class](#clickclient-class) 6. [AddressAccessor Class](#addressaccessor-class) 7. [AddressInterface Class](#addressinterface-class) 8. [TagInterface Class](#taginterface-class) @@ -35,7 +35,7 @@ The driver provides asynchronous communication with AutomationDirect CLICK Plcs ### Interface Summary ```python -async with ClickDriver('192.168.1.100') as plc: +async with ClickClient('192.168.1.100') as plc: # Category accessors (recommended for raw addresses) value = await plc.df.read(1) # Single value values = await plc.df.read(1, 10) # Range (inclusive) @@ -120,7 +120,7 @@ The following address types must be supported: --- -## ClickDriver Class +## ClickClient Class Inherits from `AsyncioModbusClient`. @@ -170,7 +170,7 @@ Provides method-based access to a specific address banks. ### Constructor ```python -def __init__(self, plc: ClickDriver, bank: str) +def __init__(self, plc: ClickClient, bank: str) ``` ### Methods @@ -213,7 +213,7 @@ Provides string-based access to raw PLC addresses. ### Constructor ```python -def __init__(self, plc: ClickDriver) +def __init__(self, plc: ClickClient) ``` ### Methods @@ -254,7 +254,7 @@ Provides access via tag nicknames. Requires tags to be loaded. ### Constructor ```python -def __init__(self, plc: ClickDriver) +def __init__(self, plc: ClickClient) ``` ### Methods diff --git a/spec/CLICKSERVER_SPEC.md b/spec/CLICKSERVER_SPEC.md index b8ad54f..f49d01f 100644 --- a/spec/CLICKSERVER_SPEC.md +++ b/spec/CLICKSERVER_SPEC.md @@ -44,12 +44,12 @@ async with ClickServer(provider, port=5020) as server: ``` ```python -# Integration test with ClickDriver +# Integration test with ClickClient provider = MemoryDataProvider() provider.set('DF1', 3.14) async with ClickServer(provider, port=5020) as server: - async with ClickDriver('localhost:5020') as plc: + async with ClickClient('localhost:5020') as plc: value = await plc.df.read(1) # Returns 3.14 await plc.ds.write(1, 42) @@ -83,7 +83,7 @@ Modbus Client ClickServer DataProvider ### Shared Core (`pyclickplc.core`) -The following components are shared between ClickDriver and ClickServer. They are currently defined in the [ClickDevice Spec](CLICKDEVICE_SPEC.md) and must be extracted into a shared core module: +The following components are shared between ClickClient and ClickServer. They are currently defined in the [ClickDevice Spec](CLICKDEVICE_SPEC.md) and must be extracted into a shared core module: - `AddressType` dataclass (frozen) - All address type configurations (x, y, c, t, ct, sc, ds, dd, dh, df, td, ctd, sd, txt) @@ -563,7 +563,7 @@ The server never crashes due to a DataProvider error. 81. Provider `write()` raises → Modbus `SlaveDeviceFailure` returned 82. Server continues operating after provider error -### Integration (ClickDriver ↔ ClickServer) +### Integration (ClickClient ↔ ClickServer) 83. Driver writes DF, provider sees value via `get()` 84. Provider `set()` value, driver reads it diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md index fd1137b..a586d6b 100644 --- a/spec/HANDOFF.md +++ b/spec/HANDOFF.md @@ -6,7 +6,7 @@ See `ARCHITECTURE.md` for full module layout, dependency graph, and design decis ## Current State -- **Step 1 complete** (commit `7094c46` on `dev`) +- **Steps 1–4 complete** (latest commit `f672524` on `dev`) - ClickNick: working app, Phase 0 (unique block names) done - Specs written: `CLICKDEVICE_SPEC.md` (client), `CLICKSERVER_SPEC.md` (server) @@ -17,9 +17,13 @@ See `ARCHITECTURE.md` for full module layout, dependency graph, and design decis | `banks.py` | `DataType` enum, `BankConfig` frozen dataclass, `BANKS` (16 banks), `_SPARSE_RANGES`, `MEMORY_TYPE_BASES`, `_INDEX_TO_TYPE`, `DEFAULT_RETENTIVE`, interleaved/paired dicts, `NON_EDITABLE_TYPES`, `BIT_ONLY_TYPES`, `MEMORY_TYPE_TO_DATA_TYPE`, `is_valid_address()` | | `addresses.py` | `get_addr_key`/`parse_addr_key`, XD/YD helpers, `format_address_display`, `parse_address_display` (lenient, MDB), `parse_address` (strict, display), `normalize_address`, `AddressRecord` frozen dataclass | | `validation.py` | `FORBIDDEN_CHARS`/`RESERVED_NICKNAMES` (frozenset), numeric limits, `validate_nickname` (format-only), `validate_comment` (length-only), `validate_initial_value` | +| `blocks.py` | `BlockTag`, `BlockRange`, block parsing/formatting/validation | +| `dataview.py` | `DataviewRow`, CDV file I/O, type codes, writable sets, storage/display conversion | +| `nicknames.py` | CSV read/write, data type code mappings | +| `modbus.py` | `ModbusMapping` frozen dataclass, `MODBUS_MAPPINGS` (16 banks), `plc_to_modbus`/`modbus_to_plc` forward/reverse mapping, `pack_value`/`unpack_value` register encoding, sparse coil helpers, XD/YD stride-2 support | | `__init__.py` | Re-exports public API (XD/YD helpers excluded) | -93 tests across `test_banks.py`, `test_addresses.py`, `test_validation.py`. Lint clean. +417 tests across `test_banks.py`, `test_addresses.py`, `test_validation.py`, `test_blocks.py`, `test_dataview.py`, `test_nicknames.py`, `test_modbus.py`. Lint clean. ## ~~Step 1~~ Done @@ -41,30 +45,31 @@ Wired ClickNick to import from pyclickplc: - Added 11 pre-switch tests (`TestAddressRowDerivedProperties`) before migration - 558 ClickNick tests + 93 pyclickplc tests pass, lint clean -## Step 3: `blocks.py` + `nicknames.py` + `dataview.py` +## ~~Step 3~~ Done -Extract remaining ClickNick shared code into pyclickplc: +Extracted remaining ClickNick shared code into pyclickplc: - BlockTag system → `blocks.py` - CSV read/write → `nicknames.py` - CDV file I/O → `dataview.py` -- Update ClickNick imports, delete moved code +- Updated ClickNick imports, deleted moved code -## Step 4: `modbus.py` +## ~~Step 4~~ Done -New code — Modbus protocol mapping layer: +New code — Modbus protocol mapping layer (`modbus.py`): -- `ModbusMapping` definitions for all banks (XD/YD pending hardware testing) -- Forward/reverse address mapping -- Register packing/unpacking -- Sparse coil offset calculation (reads `valid_ranges` from `BankConfig`) - -Can be developed in parallel with Step 3. +- `ModbusMapping` frozen dataclass with `is_writable` property +- `MODBUS_MAPPINGS` for all 16 banks (6 coil, 10 register) +- Forward mapping `plc_to_modbus(bank, index)` with sparse coil and XD/YD stride-2 support +- Reverse mapping `modbus_to_plc(address, is_coil)` with gap detection +- `pack_value`/`unpack_value` — struct-based register encoding (little-endian word order) +- `_MODBUS_WRITABLE_SC` excludes 50/51 (ladder-only); `_MODBUS_WRITABLE_SD` matches spec +- 194 new tests (417 total), lint clean ## Step 5: `client.py` + `server.py` New code per the existing specs: -- `ClickDriver` (Modbus TCP client) per `CLICKDEVICE_SPEC.md` +- `ClickClient` (Modbus TCP client) per `CLICKDEVICE_SPEC.md` - `ClickServer` (Modbus TCP server) per `CLICKSERVER_SPEC.md` - Integration tests: driver ↔ server round-trips diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index e523659..12d647a 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -42,6 +42,7 @@ strip_block_tag, validate_block_span, ) +from .client import ClickClient from .dataview import ( MAX_DATAVIEW_ROWS, MEMORY_TYPE_TO_CODE, @@ -67,6 +68,7 @@ STRUCT_FORMATS, ModbusMapping, modbus_to_plc, + modbus_to_plc_register, pack_value, plc_to_modbus, unpack_value, @@ -79,6 +81,7 @@ read_mdb_csv, write_csv, ) +from .server import ClickServer, MemoryDataProvider from .validation import ( COMMENT_MAX_LENGTH, FLOAT_MAX, @@ -135,6 +138,8 @@ "find_block_range_indices", "compute_all_block_ranges", "validate_block_span", + # client + "ClickClient", # modbus "ModbusMapping", "MODBUS_MAPPINGS", @@ -143,8 +148,12 @@ "STRUCT_FORMATS", "plc_to_modbus", "modbus_to_plc", + "modbus_to_plc_register", "pack_value", "unpack_value", + # server + "ClickServer", + "MemoryDataProvider", # dataview "TypeCode", "DataviewRow", diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py new file mode 100644 index 0000000..45dc43c --- /dev/null +++ b/src/pyclickplc/client.py @@ -0,0 +1,577 @@ +"""Async Modbus TCP client driver for AutomationDirect CLICK PLCs. + +Provides ClickClient with bank accessors, address interface, and tag interface. +""" + +from __future__ import annotations + +from typing import ClassVar + +from pymodbus.client import AsyncModbusTcpClient + +from .addresses import parse_address +from .banks import BANKS +from .modbus import ( + MODBUS_MAPPINGS, + pack_value, + plc_to_modbus, + unpack_value, +) +from .nicknames import DATA_TYPE_CODE_TO_STR, read_csv + +# ============================================================================== +# Constants +# ============================================================================== + +# Map bank name -> data type string for validation +_DATA_TYPE_STR: dict[str, str] = { + "X": "bool", + "Y": "bool", + "C": "bool", + "T": "bool", + "CT": "bool", + "SC": "bool", + "DS": "int16", + "DD": "int32", + "DH": "hex", + "DF": "float", + "TXT": "str", + "TD": "int16", + "CTD": "int32", + "SD": "int16", + "XD": "hex", + "YD": "hex", +} + +# Map data type string -> accepted Python types for write validation +TYPE_MAP: dict[str, type | tuple[type, ...]] = { + "bool": bool, + "int16": int, + "int32": int, + "float": (int, float), + "hex": int, + "str": str, +} + + +# ============================================================================== +# Tag loading +# ============================================================================== + + +def _load_tags(filepath: str) -> dict[str, dict[str, str]]: + """Load tags from nicknames CSV using nicknames.read_csv.""" + records = read_csv(filepath) + tags: dict[str, dict[str, str]] = {} + for r in records.values(): + if r.nickname and not r.nickname.startswith("_"): + tags[r.nickname] = { + "address": r.display_address, + "type": DATA_TYPE_CODE_TO_STR.get(r.data_type, ""), + "comment": r.comment, + } + # Sort by address + return dict(sorted(tags.items(), key=lambda item: item[1]["address"])) + + +# ============================================================================== +# AddressAccessor +# ============================================================================== + + +def _format_bank_address(bank: str, index: int) -> str: + """Format address string for return dicts.""" + if bank in ("X", "Y"): + return f"{bank.lower()}{index:03d}" + return f"{bank.lower()}{index}" + + +class AddressAccessor: + """Provides read/write access to a specific PLC memory bank.""" + + def __init__(self, plc: ClickClient, bank: str) -> None: + self._plc = plc + self._bank = bank + self._mapping = MODBUS_MAPPINGS[bank] + self._bank_cfg = BANKS[bank] + + async def read( + self, start: int, end: int | None = None + ) -> dict[str, bool | int | float | str] | bool | int | float | str: + """Read single value or range (inclusive).""" + bank = self._bank + + if end is None: + # Single value + return await self._read_single(start) + + if end <= start: + raise ValueError("End address must be greater than start address.") + + # Range read + if bank == "TXT": + return await self._read_txt_range(start, end) + if self._bank_cfg.valid_ranges is not None: + return await self._read_sparse_range(start, end) + return await self._read_range(start, end) + + async def _read_single(self, index: int) -> bool | int | float | str: + """Read a single PLC address.""" + bank = self._bank + self._validate_index(index) + + if bank == "TXT": + return await self._read_txt(index) + + if self._mapping.is_coil: + addr, _ = plc_to_modbus(bank, index) + result = await self._plc._read_coils(addr, 1, bank) + return result[0] + + addr, count = plc_to_modbus(bank, index) + regs = await self._plc._read_registers(addr, count, bank) + return unpack_value(regs, self._bank_cfg.data_type) + + async def _read_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + """Read a contiguous range.""" + bank = self._bank + self._validate_index(start) + self._validate_index(end) + result: dict[str, bool | int | float | str] = {} + + if self._mapping.is_coil: + addr_start, _ = plc_to_modbus(bank, start) + addr_end, _ = plc_to_modbus(bank, end) + count = addr_end - addr_start + 1 + bits = await self._plc._read_coils(addr_start, count, bank) + for i, idx in enumerate(range(start, end + 1)): + key = _format_bank_address(bank, idx) + result[key] = bits[i] + else: + addr_start, _ = plc_to_modbus(bank, start) + addr_end, count_last = plc_to_modbus(bank, end) + total_regs = (addr_end + count_last) - addr_start + regs = await self._plc._read_registers(addr_start, total_regs, bank) + width = self._mapping.width + data_type = self._bank_cfg.data_type + for i, idx in enumerate(range(start, end + 1)): + offset = i * width + val = unpack_value(regs[offset : offset + width], data_type) + key = _format_bank_address(bank, idx) + result[key] = val + + return result + + async def _read_sparse_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + """Read a sparse (X/Y) range, skipping gaps.""" + bank = self._bank + # Enumerate valid addresses in [start, end] + valid_addrs: list[int] = [] + ranges = self._bank_cfg.valid_ranges + assert ranges is not None + for lo, hi in ranges: + for a in range(max(lo, start), min(hi, end) + 1): + valid_addrs.append(a) + + if not valid_addrs: + raise ValueError(f"No valid {bank} addresses in range {start}-{end}") + + # Read the full coil span + addr_first, _ = plc_to_modbus(bank, valid_addrs[0]) + addr_last, _ = plc_to_modbus(bank, valid_addrs[-1]) + count = addr_last - addr_first + 1 + bits = await self._plc._read_coils(addr_first, count, bank) + + result: dict[str, bool | int | float | str] = {} + for a in valid_addrs: + addr, _ = plc_to_modbus(bank, a) + bit_idx = addr - addr_first + key = _format_bank_address(bank, a) + result[key] = bits[bit_idx] + + return result + + async def _read_txt(self, index: int) -> str: + """Read a single TXT address.""" + # TXT packs 2 chars per register: odd index = low byte, even = high byte + reg_index = (index - 1) // 2 + reg_addr = MODBUS_MAPPINGS["TXT"].base + reg_index + regs = await self._plc._read_registers(reg_addr, 1, "TXT") + reg_val = regs[0] + if index % 2 == 1: + # Odd: low byte + return chr(reg_val & 0xFF) + else: + # Even: high byte + return chr((reg_val >> 8) & 0xFF) + + async def _read_txt_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + """Read a range of TXT addresses.""" + self._validate_index(start) + self._validate_index(end) + # Compute register range + first_reg = (start - 1) // 2 + last_reg = (end - 1) // 2 + reg_base = MODBUS_MAPPINGS["TXT"].base + regs = await self._plc._read_registers( + reg_base + first_reg, last_reg - first_reg + 1, "TXT" + ) + + result: dict[str, bool | int | float | str] = {} + for idx in range(start, end + 1): + reg_offset = (idx - 1) // 2 - first_reg + reg_val = regs[reg_offset] + if idx % 2 == 1: + ch = chr(reg_val & 0xFF) + else: + ch = chr((reg_val >> 8) & 0xFF) + result[_format_bank_address("TXT", idx)] = ch + + return result + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + """Write single value or list of consecutive values.""" + if isinstance(data, list): + await self._write_list(start, data) + else: + await self._write_single(start, data) + + async def _write_single(self, index: int, value: bool | int | float | str) -> None: + """Write a single value.""" + bank = self._bank + self._validate_index(index) + self._validate_writable(index) + self._validate_type(value) + + if bank == "TXT": + await self._write_txt(index, str(value)) + return + + if self._mapping.is_coil: + addr, _ = plc_to_modbus(bank, index) + await self._plc._write_coils(addr, [bool(value)]) + return + + addr, count = plc_to_modbus(bank, index) + regs = pack_value(value, self._bank_cfg.data_type) + await self._plc._write_registers(addr, regs) + + async def _write_list(self, start: int, values: list) -> None: + """Write a list of consecutive values.""" + bank = self._bank + + if bank == "TXT": + # Write TXT as string chars + for i, v in enumerate(values): + idx = start + i + self._validate_index(idx) + self._validate_writable(idx) + self._validate_type(v) + await self._write_txt(idx, str(v)) + return + + # Validate all addresses and values first + for i, v in enumerate(values): + idx = start + i + self._validate_index(idx) + self._validate_writable(idx) + self._validate_type(v) + + if self._mapping.is_coil: + if self._bank_cfg.valid_ranges is not None: + await self._write_sparse_coils(start, values) + else: + addr, _ = plc_to_modbus(bank, start) + await self._plc._write_coils(addr, [bool(v) for v in values]) + else: + addr, _ = plc_to_modbus(bank, start) + data_type = self._bank_cfg.data_type + all_regs: list[int] = [] + for v in values: + all_regs.extend(pack_value(v, data_type)) + await self._plc._write_registers(addr, all_regs) + + async def _write_sparse_coils(self, start: int, values: list) -> None: + """Write coils over a sparse (X/Y) range, padding gaps with False.""" + bank = self._bank + ranges = self._bank_cfg.valid_ranges + assert ranges is not None + + # Build list of valid addresses + end_index = start + len(values) - 1 + valid_addrs: list[int] = [] + for lo, hi in ranges: + for a in range(max(lo, start), min(hi, end_index) + 1): + valid_addrs.append(a) + + if not valid_addrs: + return + + # Map values to valid addresses + value_map = dict(zip(valid_addrs, [bool(v) for v in values], strict=False)) + + addr_first, _ = plc_to_modbus(bank, valid_addrs[0]) + addr_last, _ = plc_to_modbus(bank, valid_addrs[-1]) + total_coils = addr_last - addr_first + 1 + + # Build coil data with False padding for gaps + coil_data: list[bool] = [] + for i in range(total_coils): + coil_addr = addr_first + i + from .modbus import modbus_to_plc + + mapped = modbus_to_plc(coil_addr, is_coil=True) + if mapped is not None and mapped[1] in value_map: + coil_data.append(value_map[mapped[1]]) + else: + coil_data.append(False) + + await self._plc._write_coils(addr_first, coil_data) + + async def _write_txt(self, index: int, char: str) -> None: + """Write a single TXT character, preserving the twin byte.""" + reg_index = (index - 1) // 2 + reg_addr = MODBUS_MAPPINGS["TXT"].base + reg_index + + # Read current register to preserve the other byte + regs = await self._plc._read_registers(reg_addr, 1, "TXT") + reg_val = regs[0] + byte_val = ord(char) & 0xFF + + if index % 2 == 1: + # Odd: low byte + new_val = (reg_val & 0xFF00) | byte_val + else: + # Even: high byte + new_val = (reg_val & 0x00FF) | (byte_val << 8) + + await self._plc._write_registers(reg_addr, [new_val]) + + def _validate_index(self, index: int) -> None: + """Validate address index for this bank.""" + bank = self._bank + cfg = self._bank_cfg + if cfg.valid_ranges is not None: + if not any(lo <= index <= hi for lo, hi in cfg.valid_ranges): + raise ValueError(f"{bank} address must be *01-*16.") + else: + if index < cfg.min_addr or index > cfg.max_addr: + raise ValueError(f"{bank} must be in [{cfg.min_addr}, {cfg.max_addr}]") + + def _validate_writable(self, index: int) -> None: + """Validate that the address is writable.""" + mapping = self._mapping + if mapping.writable is not None: + # Bank has a specific writable subset (SC, SD) + if index not in mapping.writable: + raise ValueError(f"{self._bank}{index} is not writable.") + elif not mapping.is_writable: + raise ValueError(f"{self._bank}{index} is not writable.") + + def _validate_type(self, value: bool | int | float | str) -> None: + """Validate Python type matches expected bank type.""" + expected_type_str = _DATA_TYPE_STR[self._bank] + expected = TYPE_MAP[expected_type_str] + if not isinstance(value, expected): + raise ValueError( + f"Expected {self._bank} as {expected_type_str}, got {type(value).__name__}." + ) + + def __repr__(self) -> str: + return f"" + + +# ============================================================================== +# AddressInterface +# ============================================================================== + + +class AddressInterface: + """String-based access to raw PLC addresses.""" + + def __init__(self, plc: ClickClient) -> None: + self._plc = plc + + async def read( + self, address: str + ) -> dict[str, bool | int | float | str] | bool | int | float | str: + """Read by address string. Supports 'df1' or 'df1-df10'.""" + if "-" in address: + parts = address.split("-", 1) + bank1, start = parse_address(parts[0]) + bank2, end = parse_address(parts[1]) + if bank1 != bank2: + raise ValueError("Inter-bank ranges are unsupported.") + if end <= start: + raise ValueError("End address must be greater than start address.") + accessor = self._plc._get_accessor(bank1) + return await accessor.read(start, end) + + bank, index = parse_address(address) + accessor = self._plc._get_accessor(bank) + return await accessor.read(index) + + async def write( + self, + address: str, + data: bool | int | float | str | list, + ) -> None: + """Write by address string.""" + bank, index = parse_address(address) + accessor = self._plc._get_accessor(bank) + await accessor.write(index, data) + + +# ============================================================================== +# TagInterface +# ============================================================================== + + +class TagInterface: + """Access PLC values via tag nicknames.""" + + def __init__(self, plc: ClickClient) -> None: + self._plc = plc + + async def read( + self, tag_name: str | None = None + ) -> dict[str, bool | int | float | str] | bool | int | float | str: + """Read single tag or all tags.""" + tags = self._plc.tags + if tag_name is not None: + if tag_name not in tags: + available = list(tags.keys())[:5] + raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") + tag_info = tags[tag_name] + return await self._plc.addr.read(tag_info["address"]) + + if not tags: + raise ValueError("No tags loaded. Provide a tag file or specify a tag name.") + + result: dict[str, bool | int | float | str] = {} + for name, info in tags.items(): + val = await self._plc.addr.read(info["address"]) + assert not isinstance(val, dict) + result[name] = val + return result + + async def write( + self, + tag_name: str, + data: bool | int | float | str | list, + ) -> None: + """Write value by tag name.""" + tags = self._plc.tags + if tag_name not in tags: + available = list(tags.keys())[:5] + raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") + tag_info = tags[tag_name] + await self._plc.addr.write(tag_info["address"], data) + + def read_all(self) -> dict[str, dict[str, str]]: + """Return a copy of all tag definitions (synchronous).""" + return dict(self._plc.tags) + + +# ============================================================================== +# ClickClient +# ============================================================================== + + +class ClickClient: + """Async Modbus TCP driver for CLICK PLCs.""" + + data_types: ClassVar[dict[str, str]] = _DATA_TYPE_STR + + def __init__( + self, + address: str, + tag_filepath: str = "", + timeout: int = 1, + ) -> None: + # Parse host:port + if ":" in address: + host, port_str = address.rsplit(":", 1) + port = int(port_str) + else: + host = address + port = 502 + + self._client = AsyncModbusTcpClient(host, port=port, timeout=timeout) + self._accessors: dict[str, AddressAccessor] = {} + self.tags: dict[str, dict[str, str]] = {} + self.addr = AddressInterface(self) + self.tag = TagInterface(self) + + if tag_filepath: + self.tags = _load_tags(tag_filepath) + + def _get_accessor(self, bank: str) -> AddressAccessor: + """Get or create an AddressAccessor for a bank.""" + bank_upper = bank.upper() + if bank_upper not in MODBUS_MAPPINGS: + raise AttributeError(f"'{bank}' is not a supported address type.") + if bank_upper not in self._accessors: + self._accessors[bank_upper] = AddressAccessor(self, bank_upper) + return self._accessors[bank_upper] + + def __getattr__(self, name: str) -> AddressAccessor: + if name.startswith("_"): + raise AttributeError(name) + upper = name.upper() + if upper not in MODBUS_MAPPINGS: + raise AttributeError(f"'{name}' is not a supported address type.") + return self._get_accessor(upper) + + async def __aenter__(self) -> ClickClient: + await self._client.connect() + return self + + async def __aexit__(self, *args: object) -> None: + self._client.close() + + # --- Internal Modbus wrappers --- + + async def _read_coils(self, address: int, count: int, bank: str) -> list[bool]: + """Read coils using appropriate function code.""" + mapping = MODBUS_MAPPINGS[bank] + if 2 in mapping.function_codes: + result = await self._client.read_discrete_inputs(address, count=count) + else: + result = await self._client.read_coils(address, count=count) + if result.isError(): + raise OSError(f"Modbus read error at coil {address}: {result}") + return list(result.bits[:count]) + + async def _write_coils(self, address: int, values: list[bool]) -> None: + """Write coils.""" + if len(values) == 1: + result = await self._client.write_coil(address, values[0]) + else: + result = await self._client.write_coils(address, values) + if result.isError(): + raise OSError(f"Modbus write error at coil {address}: {result}") + + async def _read_registers(self, address: int, count: int, bank: str) -> list[int]: + """Read registers using appropriate function code.""" + mapping = MODBUS_MAPPINGS[bank] + if 4 in mapping.function_codes: + result = await self._client.read_input_registers(address, count=count) + else: + result = await self._client.read_holding_registers(address, count=count) + if result.isError(): + raise OSError(f"Modbus read error at register {address}: {result}") + return list(result.registers[:count]) + + async def _write_registers(self, address: int, values: list[int]) -> None: + """Write registers.""" + if len(values) == 1: + result = await self._client.write_register(address, values[0]) + else: + result = await self._client.write_registers(address, values) + if result.isError(): + raise OSError(f"Modbus write error at register {address}: {result}") diff --git a/src/pyclickplc/modbus.py b/src/pyclickplc/modbus.py index 8e212f7..5c667df 100644 --- a/src/pyclickplc/modbus.py +++ b/src/pyclickplc/modbus.py @@ -324,6 +324,39 @@ def _reverse_register(address: int) -> tuple[str, int] | None: return None +def modbus_to_plc_register(address: int) -> tuple[str, int, int] | None: + """Extended reverse register mapping. + + Unlike modbus_to_plc(is_coil=False), this does NOT return None for + mid-value registers of width-2 types. Instead it returns the + reg_position (0 or 1) within the value. + + Needed by the server for FC 06 on width-2 types (read-modify-write). + + Returns: + (bank, index, reg_position) or None if unmapped + """ + for bank, mapping in _REGISTER_MAPPINGS: + if bank in ("XD", "YD"): + max_display = 8 + end = mapping.base + max_display * 2 + 1 + if mapping.base <= address < end: + offset = address - mapping.base + if offset % 2 != 0: + return None # Upper byte slot + return bank, offset // 2, 0 + continue + + max_addr = BANKS[bank].max_addr + end = mapping.base + mapping.width * max_addr + if mapping.base <= address < end: + offset = address - mapping.base + index = offset // mapping.width + 1 + reg_position = offset % mapping.width + return bank, index, reg_position + return None + + # ============================================================================== # Pack / Unpack # ============================================================================== diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py new file mode 100644 index 0000000..b9d0c09 --- /dev/null +++ b/src/pyclickplc/server.py @@ -0,0 +1,397 @@ +"""Modbus TCP server simulating an AutomationDirect CLICK PLC. + +Incoming Modbus requests are reverse-mapped to PLC addresses and +routed to a user-supplied DataProvider. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from pymodbus.constants import ExcCodes +from pymodbus.datastore import ModbusBaseDeviceContext, ModbusServerContext +from pymodbus.server import ModbusTcpServer + +from .addresses import format_address_display, parse_address +from .banks import BANKS, DataType +from .modbus import ( + MODBUS_MAPPINGS, + modbus_to_plc, + modbus_to_plc_register, + pack_value, + unpack_value, +) + +# ============================================================================== +# DataProvider Protocol +# ============================================================================== + +PlcValue = bool | int | float | str + + +@runtime_checkable +class DataProvider(Protocol): + """Backend protocol for PLC value storage.""" + + def read(self, address: str) -> PlcValue: ... + def write(self, address: str, value: PlcValue) -> None: ... + + +# ============================================================================== +# MemoryDataProvider +# ============================================================================== + +# Default values by DataType +_DEFAULTS: dict[DataType, PlcValue] = { + DataType.BIT: False, + DataType.INT: 0, + DataType.INT2: 0, + DataType.FLOAT: 0.0, + DataType.HEX: 0, + DataType.TXT: "\x00", +} + + +class MemoryDataProvider: + """In-memory DataProvider for testing and simple use cases.""" + + def __init__(self) -> None: + self._data: dict[str, PlcValue] = {} + + def _normalize(self, address: str) -> tuple[str, str]: + """Normalize address and return (normalized, bank).""" + bank, index = parse_address(address) + normalized: str = format_address_display(bank, index) + # For X/Y, format_address_display expects MDB address; parse_address returns display + # But for X/Y display==MDB, and for XD/YD we need to handle differently + if bank in ("X", "Y"): + normalized = f"{bank}{index:03d}" + elif bank in ("XD", "YD"): + normalized = f"{bank}{index}" + else: + normalized = f"{bank}{index}" + return normalized, bank + + def _default(self, bank: str) -> PlcValue: + """Get default value for a bank.""" + data_type = BANKS[bank].data_type + return _DEFAULTS[data_type] + + def read(self, address: str) -> PlcValue: + normalized, bank = self._normalize(address) + return self._data.get(normalized, self._default(bank)) + + def write(self, address: str, value: PlcValue) -> None: + normalized, _bank = self._normalize(address) + self._data[normalized] = value + + def get(self, address: str) -> PlcValue: + """Synchronous read convenience.""" + return self.read(address) + + def set(self, address: str, value: PlcValue) -> None: + """Synchronous write convenience.""" + self.write(address, value) + + def bulk_set(self, values: dict[str, PlcValue]) -> None: + """Set multiple values at once.""" + for address, value in values.items(): + self.set(address, value) + + +# ============================================================================== +# _ClickDeviceContext +# ============================================================================== + + +def _is_address_writable(bank: str, index: int) -> bool: + """Check if a PLC address is writable via Modbus.""" + mapping = MODBUS_MAPPINGS[bank] + if mapping.writable is not None: + return index in mapping.writable + return mapping.is_writable + + +def _format_plc_address(bank: str, index: int) -> str: + """Format a PLC address string for DataProvider calls.""" + if bank in ("X", "Y"): + return f"{bank}{index:03d}" + return f"{bank}{index}" + + +class _ClickDeviceContext(ModbusBaseDeviceContext): + """Custom pymodbus context routing Modbus requests to a DataProvider.""" + + def __init__(self, provider: DataProvider) -> None: + self.provider = provider + super().__init__() + + def reset(self) -> None: + """Required by base class.""" + + def validate(self, func_code: int, address: int, count: int = 1) -> bool: # noqa: ARG002 + """Accept all addresses; errors are handled in get/setValues.""" + return True + + def getValues(self, func_code: int, address: int, count: int = 1) -> list[int] | list[bool]: + """Read values from the DataProvider.""" + try: + if func_code in (1, 2, 5, 15): + return self._get_coils(address, count) + return self._get_registers(address, count) + except Exception: + return [] + + def setValues( + self, func_code: int, address: int, values: list[int] | list[bool] + ) -> ExcCodes | None: + """Write values to the DataProvider.""" + try: + if func_code in (5, 15): + return self._set_coils(address, values) + if func_code == 6: + return self._set_single_register(address, values[0]) + # FC 16 + return self._set_registers(address, values) + except Exception: + return ExcCodes.DEVICE_FAILURE + + # --- Coil helpers --- + + def _get_coils(self, address: int, count: int) -> list[bool]: + result: list[bool] = [] + for i in range(count): + mapped = modbus_to_plc(address + i, is_coil=True) + if mapped is None: + result.append(False) + else: + bank, index = mapped + plc_addr = _format_plc_address(bank, index) + val = self.provider.read(plc_addr) + result.append(bool(val)) + return result + + def _set_coils(self, address: int, values: list[int] | list[bool]) -> ExcCodes | None: + for i, val in enumerate(values): + mapped = modbus_to_plc(address + i, is_coil=True) + if mapped is None: + if len(values) == 1: + return ExcCodes.ILLEGAL_ADDRESS + continue # FC 15: skip gaps silently + bank, index = mapped + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + plc_addr = _format_plc_address(bank, index) + self.provider.write(plc_addr, bool(val)) + return None + + # --- Register helpers --- + + def _get_registers(self, address: int, count: int) -> list[int]: + result: list[int] = [] + i = 0 + while i < count: + reg_addr = address + i + mapped = modbus_to_plc_register(reg_addr) + if mapped is None: + result.append(0) + i += 1 + continue + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + plc_addr = _format_plc_address(bank, index) + + # Handle TXT: 2 chars per register + if bank == "TXT": + # TXT packs 2 addresses per register + # Odd TXT index = low byte, even = high byte + txt_base_index = (index - 1) // 2 * 2 + 1 # Base of pair + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + low_val = self.provider.read(low_addr) + high_val = self.provider.read(high_addr) + low_byte = ord(str(low_val)) & 0xFF + high_byte = ord(str(high_val)) & 0xFF + result.append(low_byte | (high_byte << 8)) + i += 1 + continue + + # Read the PLC value + val = self.provider.read(plc_addr) + + # Pack the value + data_type = BANKS[bank].data_type + if data_type == DataType.BIT: + result.append(int(bool(val))) + i += 1 + continue + + regs = pack_value(val, data_type) + + if mapping.width == 1: + result.append(regs[0]) + i += 1 + else: + # Width-2: might only need one register + if reg_position == 0: + # Starting at first register of pair + remaining = count - i + if remaining >= 2: + result.extend(regs) + i += 2 + else: + result.append(regs[0]) + i += 1 + else: + # Starting at second register of pair + result.append(regs[1]) + i += 1 + return result + + def _set_single_register(self, address: int, value: int) -> ExcCodes | None: + """Handle FC 06: write single register.""" + mapped = modbus_to_plc_register(address) + if mapped is None: + return ExcCodes.ILLEGAL_ADDRESS + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + + plc_addr = _format_plc_address(bank, index) + data_type = BANKS[bank].data_type + + # Handle TXT + if bank == "TXT": + low_byte = value & 0xFF + high_byte = (value >> 8) & 0xFF + txt_base_index = (index - 1) // 2 * 2 + 1 + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + self.provider.write(low_addr, chr(low_byte)) + self.provider.write(high_addr, chr(high_byte)) + return None + + if mapping.width == 1: + unpacked = unpack_value([value], data_type) + self.provider.write(plc_addr, unpacked) + else: + # Width-2: read-modify-write + current = self.provider.read(plc_addr) + current_regs = pack_value(current, data_type) + current_regs[reg_position] = value + unpacked = unpack_value(current_regs, data_type) + self.provider.write(plc_addr, unpacked) + return None + + def _set_registers(self, address: int, values: list[int] | list[bool]) -> ExcCodes | None: + """Handle FC 16: write multiple registers.""" + i = 0 + while i < len(values): + reg_addr = address + i + mapped = modbus_to_plc_register(reg_addr) + if mapped is None: + return ExcCodes.ILLEGAL_ADDRESS + + bank, index, reg_position = mapped + mapping = MODBUS_MAPPINGS[bank] + + # Check writability + if not _is_address_writable(bank, index): + return ExcCodes.ILLEGAL_ADDRESS + + plc_addr = _format_plc_address(bank, index) + data_type = BANKS[bank].data_type + + # Handle TXT + if bank == "TXT": + reg_val = int(values[i]) + low_byte = reg_val & 0xFF + high_byte = (reg_val >> 8) & 0xFF + txt_base_index = (index - 1) // 2 * 2 + 1 + low_addr = _format_plc_address("TXT", txt_base_index) + high_addr = _format_plc_address("TXT", txt_base_index + 1) + self.provider.write(low_addr, chr(low_byte)) + self.provider.write(high_addr, chr(high_byte)) + i += 1 + continue + + if mapping.width == 1: + unpacked = unpack_value([int(values[i])], data_type) + self.provider.write(plc_addr, unpacked) + i += 1 + else: + # Width-2 + if reg_position == 0 and i + 1 < len(values): + # Complete pair + regs = [int(values[i]), int(values[i + 1])] + unpacked = unpack_value(regs, data_type) + self.provider.write(plc_addr, unpacked) + i += 2 + else: + # Partial: read-modify-write + current = self.provider.read(plc_addr) + current_regs = pack_value(current, data_type) + current_regs[reg_position] = int(values[i]) + unpacked = unpack_value(current_regs, data_type) + self.provider.write(plc_addr, unpacked) + i += 1 + return None + + +# ============================================================================== +# ClickServer +# ============================================================================== + + +class ClickServer: + """Modbus TCP server simulating a CLICK PLC.""" + + def __init__( + self, + provider: DataProvider, + host: str = "localhost", + port: int = 502, + ) -> None: + self.provider = provider + self.host = host + self.port = port + self._server: ModbusTcpServer | None = None + + async def start(self) -> None: + """Start the Modbus TCP server.""" + context = _ClickDeviceContext(self.provider) + server_context = ModbusServerContext(devices=context, single=True) + self._server = ModbusTcpServer( + context=server_context, + address=(self.host, self.port), + ) + await self._server.serve_forever(background=True) + + async def stop(self) -> None: + """Stop the server gracefully.""" + if self._server is not None: + await self._server.shutdown() + self._server = None + + async def serve_forever(self) -> None: + """Start the server and block until stopped.""" + context = _ClickDeviceContext(self.provider) + server_context = ModbusServerContext(devices=context, single=True) + self._server = ModbusTcpServer( + context=server_context, + address=(self.host, self.port), + ) + await self._server.serve_forever(background=False) + + async def __aenter__(self) -> ClickServer: + await self.start() + return self + + async def __aexit__(self, *args: object) -> None: + await self.stop() diff --git a/tests/test_addresses.py b/tests/test_addresses.py index cd7eb25..cbbf3ea 100644 --- a/tests/test_addresses.py +++ b/tests/test_addresses.py @@ -201,7 +201,7 @@ class TestAddressRecord: def test_frozen(self): rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) with pytest.raises(FrozenInstanceError): - rec.nickname = "test" # type: ignore[misc] + rec.nickname = "test" def test_replace(self): rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) diff --git a/tests/test_banks.py b/tests/test_banks.py index f515783..afd0546 100644 --- a/tests/test_banks.py +++ b/tests/test_banks.py @@ -50,7 +50,7 @@ class TestBankConfig: def test_frozen(self): bank = BANKS["DS"] with pytest.raises(FrozenInstanceError): - bank.name = "other" # type: ignore[misc] + bank.name = "other" def test_all_16_banks_defined(self): assert len(BANKS) == 16 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..415e4d8 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,490 @@ +"""Tests for pyclickplc.client — ClickClient, AddressAccessor, etc. + +Uses mocked transport (patching internal _read/_write methods). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from pyclickplc.banks import DataType +from pyclickplc.client import ( + AddressAccessor, + AddressInterface, + ClickClient, + TagInterface, +) +from pyclickplc.modbus import pack_value + +# ============================================================================== +# Helpers +# ============================================================================== + + +def _make_plc(tag_filepath: str = "") -> ClickClient: + """Create a ClickClient without connecting.""" + plc = ClickClient("localhost:5020", tag_filepath=tag_filepath) + # Mock internal transport methods + plc._read_coils = AsyncMock(return_value=[False]) + plc._write_coils = AsyncMock() + plc._read_registers = AsyncMock(return_value=[0]) + plc._write_registers = AsyncMock() + return plc + + +# ============================================================================== +# ClickClient construction and __getattr__ +# ============================================================================== + + +class TestClickClient: + @pytest.mark.asyncio + async def test_construction(self): + plc = ClickClient("192.168.1.100") + assert plc.addr is not None + assert plc.tag is not None + assert plc.tags == {} + + @pytest.mark.asyncio + async def test_construction_with_port(self): + plc = ClickClient("192.168.1.100:5020") + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 5020 + + @pytest.mark.asyncio + async def test_getattr_df(self): + plc = _make_plc() + accessor = plc.df + assert isinstance(accessor, AddressAccessor) + + @pytest.mark.asyncio + async def test_getattr_case_insensitive(self): + plc = _make_plc() + accessor1 = plc.df + accessor2 = plc.DF + assert accessor1 is accessor2 + + @pytest.mark.asyncio + async def test_getattr_cached(self): + plc = _make_plc() + a1 = plc.ds + a2 = plc.ds + assert a1 is a2 + + @pytest.mark.asyncio + async def test_getattr_underscore_raises(self): + plc = _make_plc() + with pytest.raises(AttributeError): + plc._private + + @pytest.mark.asyncio + async def test_getattr_unknown_raises(self): + plc = _make_plc() + with pytest.raises(AttributeError, match="not a supported"): + plc.invalid_bank + + @pytest.mark.asyncio + async def test_addr_is_address_interface(self): + plc = _make_plc() + assert isinstance(plc.addr, AddressInterface) + + @pytest.mark.asyncio + async def test_tag_is_tag_interface(self): + plc = _make_plc() + assert isinstance(plc.tag, TagInterface) + + +# ============================================================================== +# AddressAccessor — repr +# ============================================================================== + + +class TestAddressAccessorRepr: + @pytest.mark.asyncio + async def test_repr_df(self): + plc = _make_plc() + assert repr(plc.df) == "" + + @pytest.mark.asyncio + async def test_repr_ds(self): + plc = _make_plc() + assert repr(plc.ds) == "" + + @pytest.mark.asyncio + async def test_repr_x(self): + plc = _make_plc() + assert repr(plc.x) == "" + + +# ============================================================================== +# AddressAccessor — read single +# ============================================================================== + + +class TestAddressAccessorReadSingle: + @pytest.mark.asyncio + async def test_read_float(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.df.read(1) + import math + + assert math.isclose(value, 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_int16(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[42]) + value = await plc.ds.read(1) + assert value == 42 + + @pytest.mark.asyncio + async def test_read_int32(self): + plc = _make_plc() + regs = pack_value(100000, DataType.INT2) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.dd.read(1) + assert value == 100000 + + @pytest.mark.asyncio + async def test_read_unsigned(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[0xABCD]) + value = await plc.dh.read(1) + assert value == 0xABCD + + @pytest.mark.asyncio + async def test_read_bool(self): + plc = _make_plc() + plc._read_coils = AsyncMock(return_value=[True]) + value = await plc.c.read(1) + assert value is True + + @pytest.mark.asyncio + async def test_read_sparse_bool(self): + plc = _make_plc() + plc._read_coils = AsyncMock(return_value=[True]) + value = await plc.x.read(101) + assert value is True + + @pytest.mark.asyncio + async def test_read_txt(self): + plc = _make_plc() + # TXT1 is low byte of register + plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) + value = await plc.txt.read(1) + assert value == "A" + + @pytest.mark.asyncio + async def test_read_txt_even(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) + value = await plc.txt.read(2) + assert value == "B" + + +# ============================================================================== +# AddressAccessor — read range +# ============================================================================== + + +class TestAddressAccessorReadRange: + @pytest.mark.asyncio + async def test_read_df_range(self): + plc = _make_plc() + r1 = pack_value(1.0, DataType.FLOAT) + r2 = pack_value(2.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=r1 + r2) + result = await plc.df.read(1, 2) + assert isinstance(result, dict) + assert len(result) == 2 + import math + + assert math.isclose(result["df1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["df2"], 2.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_c_range(self): + plc = _make_plc() + plc._read_coils = AsyncMock(return_value=[True, False, True]) + result = await plc.c.read(1, 3) + assert result == {"c1": True, "c2": False, "c3": True} + + @pytest.mark.asyncio + async def test_read_end_le_start_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="greater than start"): + await plc.df.read(10, 5) + + +# ============================================================================== +# AddressAccessor — write +# ============================================================================== + + +class TestAddressAccessorWrite: + @pytest.mark.asyncio + async def test_write_float(self): + plc = _make_plc() + await plc.df.write(1, 3.14) + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_int16(self): + plc = _make_plc() + await plc.ds.write(1, 42) + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_bool(self): + plc = _make_plc() + await plc.c.write(1, True) + plc._write_coils.assert_called_once() + + @pytest.mark.asyncio + async def test_write_list(self): + plc = _make_plc() + await plc.df.write(1, [1.0, 2.0, 3.0]) + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_wrong_type_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="Expected"): + await plc.df.write(1, "string") + + @pytest.mark.asyncio + async def test_write_float_to_int_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="Expected"): + await plc.ds.write(1, 3.14) + + @pytest.mark.asyncio + async def test_write_not_writable_x(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.x.write(1, True) + + @pytest.mark.asyncio + async def test_write_not_writable_sc(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.sc.write(1, True) + + @pytest.mark.asyncio + async def test_write_writable_sc53(self): + plc = _make_plc() + await plc.sc.write(53, True) + plc._write_coils.assert_called_once() + + @pytest.mark.asyncio + async def test_write_not_writable_sd(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.sd.write(1, 42) + + @pytest.mark.asyncio + async def test_write_writable_sd29(self): + plc = _make_plc() + await plc.sd.write(29, 100) + plc._write_registers.assert_called_once() + + +# ============================================================================== +# AddressAccessor — validation errors +# ============================================================================== + + +class TestAddressAccessorValidation: + @pytest.mark.asyncio + async def test_out_of_range_low(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df.read(0) + + @pytest.mark.asyncio + async def test_out_of_range_high(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df.read(501) + + @pytest.mark.asyncio + async def test_sparse_gap(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.x.read(17) + + @pytest.mark.asyncio + async def test_sparse_gap_37(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.x.read(37) + + @pytest.mark.asyncio + async def test_read_max_df(self): + """Reading at max address should work.""" + plc = _make_plc() + regs = pack_value(0.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.df.read(500) + assert value == 0.0 + + +# ============================================================================== +# AddressInterface +# ============================================================================== + + +class TestAddressInterface: + @pytest.mark.asyncio + async def test_read_single(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.addr.read("df1") + import math + + assert math.isclose(value, 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_range(self): + plc = _make_plc() + r1 = pack_value(1.0, DataType.FLOAT) + r2 = pack_value(2.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=r1 + r2) + result = await plc.addr.read("df1-df2") + assert isinstance(result, dict) + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_read_case_insensitive(self): + plc = _make_plc() + regs = pack_value(0.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.addr.read("DF1") + assert value == 0.0 + + @pytest.mark.asyncio + async def test_inter_bank_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="Inter-bank"): + await plc.addr.read("df1-dd10") + + @pytest.mark.asyncio + async def test_end_le_start_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="greater than start"): + await plc.addr.read("df10-df5") + + @pytest.mark.asyncio + async def test_invalid_address_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.addr.read("invalid1") + + @pytest.mark.asyncio + async def test_write_single(self): + plc = _make_plc() + await plc.addr.write("df1", 3.14) + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_list(self): + plc = _make_plc() + await plc.addr.write("df1", [1.0, 2.0]) + plc._write_registers.assert_called_once() + + +# ============================================================================== +# TagInterface (without actual CSV file) +# ============================================================================== + + +class TestTagInterface: + def _plc_with_tags(self) -> ClickClient: + plc = _make_plc() + plc.tags = { + "Temp": {"address": "DF1", "type": "FLOAT", "comment": "Temperature"}, + "Valve": {"address": "C1", "type": "BIT", "comment": "Valve open"}, + } + return plc + + @pytest.mark.asyncio + async def test_read_single_tag(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.tag.read("Temp") + import math + + assert math.isclose(value, 25.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_missing_tag_raises(self): + plc = self._plc_with_tags() + with pytest.raises(KeyError, match="not found"): + await plc.tag.read("NonExistent") + + @pytest.mark.asyncio + async def test_read_all_tags(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + plc._read_coils = AsyncMock(return_value=[True]) + result = await plc.tag.read() + assert isinstance(result, dict) + assert "Temp" in result + assert "Valve" in result + + @pytest.mark.asyncio + async def test_read_all_no_tags_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="No tags loaded"): + await plc.tag.read() + + @pytest.mark.asyncio + async def test_write_tag(self): + plc = self._plc_with_tags() + await plc.tag.write("Temp", 30.0) + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_missing_tag_raises(self): + plc = self._plc_with_tags() + with pytest.raises(KeyError, match="not found"): + await plc.tag.write("NonExistent", 42) + + @pytest.mark.asyncio + async def test_read_all_definitions(self): + plc = self._plc_with_tags() + result = plc.tag.read_all() + assert "Temp" in result + assert result["Temp"]["address"] == "DF1" + # Should be a copy + result["New"] = {"address": "DS1"} + assert "New" not in plc.tags + + +# ============================================================================== +# TXT write tests (mocked) +# ============================================================================== + + +class TestAddressAccessorTxtWrite: + @pytest.mark.asyncio + async def test_write_single_txt(self): + plc = _make_plc() + # Mock read of current register value (for twin byte preservation) + plc._read_registers = AsyncMock(return_value=[0]) + await plc.txt.write(1, "A") + plc._write_registers.assert_called_once() + + @pytest.mark.asyncio + async def test_write_txt_list(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[0]) + await plc.txt.write(1, ["H", "i"]) + assert plc._write_registers.call_count == 2 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..bf9c989 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,229 @@ +"""Integration tests: ClickClient <-> ClickServer round-trips. + +Tests the full stack: driver -> pymodbus client -> TCP -> pymodbus server +-> _ClickDeviceContext -> MemoryDataProvider. +""" + +from __future__ import annotations + +import asyncio +import math + +import pytest + +from pyclickplc.client import ClickClient +from pyclickplc.server import ClickServer, MemoryDataProvider + +# Port for integration tests (avoid 502 which needs root) +TEST_PORT = 15020 + + +@pytest.fixture +async def plc_fixture(): + """Provide a connected ClickClient and MemoryDataProvider.""" + provider = MemoryDataProvider() + async with ClickServer(provider, port=TEST_PORT): + # Small delay for server to be ready + await asyncio.sleep(0.1) + async with ClickClient(f"localhost:{TEST_PORT}") as plc: + yield plc, provider + + +# ============================================================================== +# Basic round-trips by data type +# ============================================================================== + + +class TestRoundTrips: + @pytest.mark.asyncio + async def test_float_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.df.write(1, 3.14) + value = await plc.df.read(1) + assert math.isclose(value, 3.14, rel_tol=1e-6) + assert math.isclose(provider.get("DF1"), 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_int32_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.dd.write(1, 100000) + value = await plc.dd.read(1) + assert value == 100000 + assert provider.get("DD1") == 100000 + + @pytest.mark.asyncio + async def test_int16_signed_positive(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, 1234) + value = await plc.ds.read(1) + assert value == 1234 + + @pytest.mark.asyncio + async def test_int16_signed_negative(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, -1234) + value = await plc.ds.read(1) + assert value == -1234 + + @pytest.mark.asyncio + async def test_int16_unsigned_dh(self, plc_fixture): + plc, provider = plc_fixture + await plc.dh.write(1, 0xABCD) + value = await plc.dh.read(1) + assert value == 0xABCD + + @pytest.mark.asyncio + async def test_bool_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.c.write(1, True) + value = await plc.c.read(1) + assert value is True + assert provider.get("C1") is True + + @pytest.mark.asyncio + async def test_bool_false(self, plc_fixture): + plc, provider = plc_fixture + await plc.c.write(1, True) + await plc.c.write(1, False) + value = await plc.c.read(1) + assert value is False + + @pytest.mark.asyncio + async def test_text_round_trip(self, plc_fixture): + plc, provider = plc_fixture + await plc.txt.write(1, "A") + value = await plc.txt.read(1) + assert value == "A" + + +# ============================================================================== +# Provider -> Driver reads +# ============================================================================== + + +class TestProviderToDriver: + @pytest.mark.asyncio + async def test_provider_set_driver_reads(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DF1", 99.5) + value = await plc.df.read(1) + assert math.isclose(value, 99.5, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_provider_set_bool(self, plc_fixture): + plc, provider = plc_fixture + provider.set("C1", True) + value = await plc.c.read(1) + assert value is True + + @pytest.mark.asyncio + async def test_provider_set_ds(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DS1", -42) + value = await plc.ds.read(1) + assert value == -42 + + +# ============================================================================== +# Range reads +# ============================================================================== + + +class TestRangeReads: + @pytest.mark.asyncio + async def test_read_df_range(self, plc_fixture): + plc, provider = plc_fixture + provider.set("DF1", 1.0) + provider.set("DF2", 2.0) + provider.set("DF3", 3.0) + result = await plc.df.read(1, 3) + assert len(result) == 3 + assert math.isclose(result["df1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["df2"], 2.0, rel_tol=1e-6) + assert math.isclose(result["df3"], 3.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_read_ds_range(self, plc_fixture): + plc, provider = plc_fixture + for i in range(1, 6): + provider.set(f"DS{i}", i * 10) + result = await plc.ds.read(1, 5) + assert len(result) == 5 + assert result["ds1"] == 10 + assert result["ds5"] == 50 + + +# ============================================================================== +# Range writes +# ============================================================================== + + +class TestRangeWrites: + @pytest.mark.asyncio + async def test_write_df_list(self, plc_fixture): + plc, provider = plc_fixture + await plc.df.write(1, [1.0, 2.0, 3.0]) + assert math.isclose(provider.get("DF1"), 1.0, rel_tol=1e-6) + assert math.isclose(provider.get("DF2"), 2.0, rel_tol=1e-6) + assert math.isclose(provider.get("DF3"), 3.0, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_write_ds_list(self, plc_fixture): + plc, provider = plc_fixture + await plc.ds.write(1, [10, 20, 30]) + assert provider.get("DS1") == 10 + assert provider.get("DS2") == 20 + assert provider.get("DS3") == 30 + + +# ============================================================================== +# Writability enforcement +# ============================================================================== + + +class TestWritability: + @pytest.mark.asyncio + async def test_sc_not_writable_through_stack(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.sc.write(1, True) + + @pytest.mark.asyncio + async def test_sd_not_writable_through_stack(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.sd.write(1, 42) + + @pytest.mark.asyncio + async def test_x_not_writable(self, plc_fixture): + plc, _ = plc_fixture + with pytest.raises(ValueError, match="not writable"): + await plc.x.write(1, True) + + +# ============================================================================== +# Sparse coils +# ============================================================================== + + +class TestSparseCoils: + @pytest.mark.asyncio + async def test_y_write_read(self, plc_fixture): + plc, provider = plc_fixture + await plc.y.write(1, True) + value = await plc.y.read(1) + assert value is True + + @pytest.mark.asyncio + async def test_x_read_from_provider(self, plc_fixture): + plc, provider = plc_fixture + provider.set("X001", True) + value = await plc.x.read(1) + assert value is True + + @pytest.mark.asyncio + async def test_x_expansion_slot(self, plc_fixture): + plc, provider = plc_fixture + provider.set("X101", True) + value = await plc.x.read(101) + assert value is True diff --git a/tests/test_modbus.py b/tests/test_modbus.py index 3a581a4..3a6b2f7 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -52,7 +52,7 @@ def test_all_16_banks_present(self): def test_frozen_dataclass(self): m = MODBUS_MAPPINGS["DS"] with pytest.raises(AttributeError): - m.base = 999 # type: ignore[misc] + m.base = 999 def test_coil_banks(self): coil_banks = {k for k, v in MODBUS_MAPPINGS.items() if v.is_coil} diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..bdbe4ce --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,448 @@ +"""Tests for pyclickplc.server — Modbus TCP server for CLICK PLCs.""" + +from __future__ import annotations + +from pyclickplc.banks import DataType +from pyclickplc.modbus import modbus_to_plc_register, pack_value, plc_to_modbus +from pyclickplc.server import ( + ClickServer, + MemoryDataProvider, + _ClickDeviceContext, +) + +# ============================================================================== +# MemoryDataProvider +# ============================================================================== + + +class TestMemoryDataProvider: + """Tests for MemoryDataProvider defaults, set/get, and normalization.""" + + def test_default_bool(self): + p = MemoryDataProvider() + assert p.get("C1") is False + + def test_default_int16(self): + p = MemoryDataProvider() + assert p.get("DS1") == 0 + + def test_default_int32(self): + p = MemoryDataProvider() + assert p.get("DD1") == 0 + + def test_default_float(self): + p = MemoryDataProvider() + assert p.get("DF1") == 0.0 + + def test_default_hex(self): + p = MemoryDataProvider() + assert p.get("DH1") == 0 + + def test_default_txt(self): + p = MemoryDataProvider() + assert p.get("TXT1") == "\x00" + + def test_set_get_round_trip(self): + p = MemoryDataProvider() + p.set("DF1", 3.14) + assert p.get("DF1") == 3.14 + + def test_write_read_round_trip(self): + p = MemoryDataProvider() + p.write("DS100", 42) + assert p.read("DS100") == 42 + + def test_set_then_read(self): + p = MemoryDataProvider() + p.set("C1", True) + assert p.read("C1") is True + + def test_bulk_set(self): + p = MemoryDataProvider() + p.bulk_set({"DF1": 1.0, "DF2": 2.0, "DS1": 42}) + assert p.get("DF1") == 1.0 + assert p.get("DF2") == 2.0 + assert p.get("DS1") == 42 + + def test_address_normalization(self): + """Case-insensitive address normalization.""" + p = MemoryDataProvider() + p.set("df1", 1.0) + assert p.get("DF1") == 1.0 + + def test_address_normalization_x(self): + p = MemoryDataProvider() + p.set("x1", True) + assert p.get("X001") is True + + def test_overwrite(self): + p = MemoryDataProvider() + p.set("DS1", 10) + p.set("DS1", 20) + assert p.get("DS1") == 20 + + def test_sc_default(self): + p = MemoryDataProvider() + assert p.get("SC1") is False + + def test_sd_default(self): + p = MemoryDataProvider() + assert p.get("SD1") == 0 + + def test_td_default(self): + p = MemoryDataProvider() + assert p.get("TD1") == 0 + + def test_ctd_default(self): + p = MemoryDataProvider() + assert p.get("CTD1") == 0 + + def test_xd_default(self): + p = MemoryDataProvider() + assert p.get("XD0") == 0 + + def test_yd_default(self): + p = MemoryDataProvider() + assert p.get("YD0") == 0 + + +# ============================================================================== +# modbus_to_plc_register +# ============================================================================== + + +class TestModbusToPlcRegister: + """Tests for extended reverse register mapping.""" + + def test_ds1(self): + assert modbus_to_plc_register(0) == ("DS", 1, 0) + + def test_ds4500(self): + assert modbus_to_plc_register(4499) == ("DS", 4500, 0) + + def test_dd1_first_reg(self): + assert modbus_to_plc_register(16384) == ("DD", 1, 0) + + def test_dd1_second_reg(self): + """Unlike modbus_to_plc, this returns the mid-value register.""" + assert modbus_to_plc_register(16385) == ("DD", 1, 1) + + def test_dd2(self): + assert modbus_to_plc_register(16386) == ("DD", 2, 0) + + def test_df1_first_reg(self): + assert modbus_to_plc_register(28672) == ("DF", 1, 0) + + def test_df1_second_reg(self): + assert modbus_to_plc_register(28673) == ("DF", 1, 1) + + def test_df2(self): + assert modbus_to_plc_register(28674) == ("DF", 2, 0) + + def test_dh1(self): + assert modbus_to_plc_register(24576) == ("DH", 1, 0) + + def test_txt1(self): + assert modbus_to_plc_register(36864) == ("TXT", 1, 0) + + def test_td1(self): + assert modbus_to_plc_register(45056) == ("TD", 1, 0) + + def test_ctd1_first(self): + assert modbus_to_plc_register(49152) == ("CTD", 1, 0) + + def test_ctd1_second(self): + assert modbus_to_plc_register(49153) == ("CTD", 1, 1) + + def test_sd1(self): + assert modbus_to_plc_register(61440) == ("SD", 1, 0) + + def test_xd0(self): + assert modbus_to_plc_register(57344) == ("XD", 0, 0) + + def test_xd_odd_none(self): + """XD odd offset (upper byte) still returns None.""" + assert modbus_to_plc_register(57345) is None + + def test_yd0(self): + assert modbus_to_plc_register(57856) == ("YD", 0, 0) + + def test_unmapped(self): + assert modbus_to_plc_register(5000) is None + + +# ============================================================================== +# _ClickDeviceContext — coil reads +# ============================================================================== + + +class TestContextCoilReads: + """Test _ClickDeviceContext.getValues for coils.""" + + def test_read_single_coil_c1(self): + p = MemoryDataProvider() + p.set("C1", True) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 16384, 1) + assert result == [True] + + def test_read_coil_range_c1_c5(self): + p = MemoryDataProvider() + for i in range(1, 6): + p.set(f"C{i}", i % 2 == 1) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 16384, 5) + assert result == [True, False, True, False, True] + + def test_read_sparse_x001(self): + p = MemoryDataProvider() + p.set("X001", True) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(2, 0, 1) + assert result == [True] + + def test_read_unmapped_coil(self): + p = MemoryDataProvider() + ctx = _ClickDeviceContext(p) + result = ctx.getValues(1, 10000, 1) + assert result == [False] + + def test_read_sparse_gap(self): + """Coil 48 is a sparse gap -> False.""" + p = MemoryDataProvider() + ctx = _ClickDeviceContext(p) + result = ctx.getValues(2, 48, 1) + assert result == [False] + + +# ============================================================================== +# _ClickDeviceContext — register reads +# ============================================================================== + + +class TestContextRegisterReads: + """Test _ClickDeviceContext.getValues for registers.""" + + def test_read_ds1(self): + p = MemoryDataProvider() + p.set("DS1", 42) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(3, 0, 1) + # DS1=42 -> pack_value(42, INT) = [42] + assert result == [42] + + def test_read_df1(self): + p = MemoryDataProvider() + p.set("DF1", 3.14) + ctx = _ClickDeviceContext(p) + result = ctx.getValues(3, 28672, 2) + # Verify round-trip + import struct + + raw = struct.pack(" Date: Fri, 6 Feb 2026 13:59:15 -0500 Subject: [PATCH 08/55] Fix _write_txt crash when writing empty string to TXT register Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/client.py | 2 +- tests/test_client.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 45dc43c..1e13fc0 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -340,7 +340,7 @@ async def _write_txt(self, index: int, char: str) -> None: # Read current register to preserve the other byte regs = await self._plc._read_registers(reg_addr, 1, "TXT") reg_val = regs[0] - byte_val = ord(char) & 0xFF + byte_val = ord(char) & 0xFF if char else 0 if index % 2 == 1: # Odd: low byte diff --git a/tests/test_client.py b/tests/test_client.py index 415e4d8..c415ea1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,7 +16,7 @@ ClickClient, TagInterface, ) -from pyclickplc.modbus import pack_value +from pyclickplc.modbus import MODBUS_MAPPINGS, pack_value # ============================================================================== # Helpers @@ -482,6 +482,16 @@ async def test_write_single_txt(self): await plc.txt.write(1, "A") plc._write_registers.assert_called_once() + @pytest.mark.asyncio + async def test_write_empty_string_clears_txt(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[0x4142]) # "AB" + await plc.txt.write(1, "") + # Empty string → null byte in low position, high byte preserved + plc._write_registers.assert_called_once_with( + MODBUS_MAPPINGS["TXT"].base, [0x4100] + ) + @pytest.mark.asyncio async def test_write_txt_list(self): plc = _make_plc() From ea88432205feb64325897e95d3f40ee48a9e79f0 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 14:46:43 -0500 Subject: [PATCH 09/55] Unify address parsing around MDB-style indices Delete display-style parse_address, rename parse_address_display to parse_address (strict, raises ValueError). The entire Modbus layer now uses MDB indices, making XD/YD registers contiguous (base + index) and eliminating stride-2 special-case branches. Key fixes: - XD0u is now addressable (MDB index 1, Modbus register 57345) - _reverse_register and modbus_to_plc_register use generic 0-based logic - Server and client delegate formatting to format_address_display - T coil base corrected from 45057 to 45056 (0-based Modbus convention) Co-Authored-By: Claude Opus 4.6 --- spec/ARCHITECTURE.md | 16 ++++---- spec/HANDOFF.md | 4 +- src/pyclickplc/__init__.py | 2 - src/pyclickplc/addresses.py | 76 +++++++++---------------------------- src/pyclickplc/client.py | 6 +-- src/pyclickplc/dataview.py | 28 +++++++------- src/pyclickplc/modbus.py | 54 ++++++++++++-------------- src/pyclickplc/nicknames.py | 9 ++--- src/pyclickplc/server.py | 16 ++------ tests/test_addresses.py | 47 ++++++----------------- tests/test_modbus.py | 76 ++++++++++++++++++++----------------- tests/test_server.py | 6 +-- 12 files changed, 131 insertions(+), 209 deletions(-) diff --git a/spec/ARCHITECTURE.md b/spec/ARCHITECTURE.md index 833dd0b..7c48410 100644 --- a/spec/ARCHITECTURE.md +++ b/spec/ARCHITECTURE.md @@ -70,15 +70,12 @@ From ClickNick extraction: - `get_addr_key(memory_type, address)` → unique int key - `parse_addr_key(addr_key)` → `(memory_type, address)` - `format_address_display(memory_type, address)` → `"X001"`, `"DS100"` -- `parse_address_display(display_str)` → `(memory_type, address)` +- `parse_address(display_str)` → `(memory_type, mdb_address)` — strict, raises ValueError - `normalize_address(address_str)` → canonical form - XD/YD helpers (`is_xd_yd_upper_byte`, etc.) - -New: - `AddressRecord` frozen dataclass (shared between ClickNick and pyrung) -- `parse_address(address_str)` → `(bank_name, index)` — used by Modbus layer too -The Modbus spec's "address parsing" and the extraction plan's `parse_address_display` are the **same function**. One parser, used everywhere. +One unified `parse_address` returns MDB indices for all banks (including XD/YD). Used everywhere: Modbus layer, client, server, nicknames, dataview. ### `validation.py` — CLICK Validation Rules @@ -292,16 +289,17 @@ Note: `sparse` and `valid_ranges` live on `BankConfig`, not `ModbusMapping`. The ## Key Design Decision: One Address Parser -**Problem:** Both the extraction plan (`parse_address_display`) and the Modbus spec mention address parsing. +**Problem:** Address parsing previously had two functions with different return conventions. -**Decision:** Single parser in `addresses.py`, used by everyone: +**Decision:** Single strict `parse_address()` in `addresses.py`, returning MDB indices for all banks: ```python def parse_address(address_str: str) -> tuple[str, int]: - """Parse 'DF1', 'X001', 'ds100' → ('DF', 1), ('X', 1), ('DS', 100)""" + """Parse 'DF1' → ('DF', 1), 'XD1' → ('XD', 2), 'XD0u' → ('XD', 1) + Raises ValueError on invalid input.""" ``` -The Modbus layer calls `parse_address()` then looks up `ModbusMapping` by bank name. ClickNick calls `parse_address()` then looks up `BankConfig`. Same function, different downstream lookups. +The Modbus layer calls `parse_address()` then looks up `ModbusMapping` by bank name. ClickNick calls `parse_address()` then looks up `BankConfig`. Same function, different downstream lookups. XD/YD use contiguous MDB indices (0-16), eliminating stride-2 special cases in the Modbus layer. --- diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md index a586d6b..bb20d65 100644 --- a/spec/HANDOFF.md +++ b/spec/HANDOFF.md @@ -15,12 +15,12 @@ See `ARCHITECTURE.md` for full module layout, dependency graph, and design decis | Module | Contents | |---|---| | `banks.py` | `DataType` enum, `BankConfig` frozen dataclass, `BANKS` (16 banks), `_SPARSE_RANGES`, `MEMORY_TYPE_BASES`, `_INDEX_TO_TYPE`, `DEFAULT_RETENTIVE`, interleaved/paired dicts, `NON_EDITABLE_TYPES`, `BIT_ONLY_TYPES`, `MEMORY_TYPE_TO_DATA_TYPE`, `is_valid_address()` | -| `addresses.py` | `get_addr_key`/`parse_addr_key`, XD/YD helpers, `format_address_display`, `parse_address_display` (lenient, MDB), `parse_address` (strict, display), `normalize_address`, `AddressRecord` frozen dataclass | +| `addresses.py` | `get_addr_key`/`parse_addr_key`, XD/YD helpers, `format_address_display`, `parse_address` (strict, MDB indices), `normalize_address`, `AddressRecord` frozen dataclass | | `validation.py` | `FORBIDDEN_CHARS`/`RESERVED_NICKNAMES` (frozenset), numeric limits, `validate_nickname` (format-only), `validate_comment` (length-only), `validate_initial_value` | | `blocks.py` | `BlockTag`, `BlockRange`, block parsing/formatting/validation | | `dataview.py` | `DataviewRow`, CDV file I/O, type codes, writable sets, storage/display conversion | | `nicknames.py` | CSV read/write, data type code mappings | -| `modbus.py` | `ModbusMapping` frozen dataclass, `MODBUS_MAPPINGS` (16 banks), `plc_to_modbus`/`modbus_to_plc` forward/reverse mapping, `pack_value`/`unpack_value` register encoding, sparse coil helpers, XD/YD stride-2 support | +| `modbus.py` | `ModbusMapping` frozen dataclass, `MODBUS_MAPPINGS` (16 banks), `plc_to_modbus`/`modbus_to_plc` forward/reverse mapping, `pack_value`/`unpack_value` register encoding, sparse coil helpers | | `__init__.py` | Re-exports public API (XD/YD helpers excluded) | 417 tests across `test_banks.py`, `test_addresses.py`, `test_validation.py`, `test_blocks.py`, `test_dataview.py`, `test_nicknames.py`, `test_modbus.py`. Lint clean. diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 12d647a..13c4436 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -7,7 +7,6 @@ normalize_address, parse_addr_key, parse_address, - parse_address_display, ) from .banks import ( BANKS, @@ -119,7 +118,6 @@ "get_addr_key", "parse_addr_key", "format_address_display", - "parse_address_display", "parse_address", "normalize_address", # blocks diff --git a/src/pyclickplc/addresses.py b/src/pyclickplc/addresses.py index 801da05..ec13c5f 100644 --- a/src/pyclickplc/addresses.py +++ b/src/pyclickplc/addresses.py @@ -125,54 +125,17 @@ def format_address_display(memory_type: str, mdb_address: int) -> str: return f"{memory_type}{mdb_address}" -def parse_address_display(address_str: str) -> tuple[str, int] | None: - """Parse a display address string to memory type and MDB address. - - Lenient: returns None on invalid input. - For XD/YD, returns MDB address: "XD1" -> ("XD", 2). - - Args: - address_str: Address string like "X001", "XD0", "XD0u", "XD8" - - Returns: - Tuple of (memory_type, mdb_address) or None if invalid - """ - if not address_str: - return None - - address_str = address_str.strip().upper() - - match = re.match(r"^([A-Z]+)(\d+)(U?)$", address_str) - if not match: - return None - - memory_type = match.group(1) - display_addr = int(match.group(2)) - is_upper = match.group(3) == "U" - - if memory_type not in MEMORY_TYPE_BASES: - return None - - if memory_type in ("XD", "YD"): - if is_upper and display_addr != 0: - return None # Invalid: XD1u, XD2u, etc. don't exist - return memory_type, xd_yd_display_to_mdb(display_addr, is_upper) - - return memory_type, display_addr - - def parse_address(address_str: str) -> tuple[str, int]: - """Parse an address string to (bank_name, display_address). + """Parse a display address string to (memory_type, mdb_address). Strict: raises ValueError on invalid input. - Returns display/logical address, NOT MDB encoding. - For XD/YD: "XD1" -> ("XD", 1), unlike parse_address_display which returns ("XD", 2). + For XD/YD, returns MDB address: "XD1" -> ("XD", 2), "XD0u" -> ("XD", 1). Args: - address_str: Address string like "X001", "DS100", "XD1" + address_str: Address string like "X001", "XD0", "XD0u", "XD8" Returns: - Tuple of (bank_name, display_address) + Tuple of (memory_type, mdb_address) Raises: ValueError: If the address string is invalid @@ -186,28 +149,25 @@ def parse_address(address_str: str) -> tuple[str, int]: if not match: raise ValueError(f"Invalid address format: {address_str!r}") - bank_name = match.group(1) - addr_num = int(match.group(2)) + memory_type = match.group(1) + display_addr = int(match.group(2)) is_upper = match.group(3) == "U" - if bank_name not in BANKS: - raise ValueError(f"Unknown bank: {bank_name!r}") + if memory_type not in MEMORY_TYPE_BASES: + raise ValueError(f"Unknown bank: {memory_type!r}") - if bank_name in ("XD", "YD"): - if is_upper and addr_num != 0: + if memory_type in ("XD", "YD"): + if is_upper and display_addr != 0: raise ValueError(f"Invalid upper byte address: {address_str!r}") - # Return display address directly (not MDB encoding) - # For XD/YD, valid display addresses are 0-8 (plus 0u) - if is_upper: - return bank_name, 0 # XD0u -> display addr 0 - if addr_num < 0 or addr_num > 8: + mdb = xd_yd_display_to_mdb(display_addr, is_upper) + if mdb < BANKS[memory_type].min_addr or mdb > BANKS[memory_type].max_addr: raise ValueError(f"Address out of range: {address_str!r}") - return bank_name, addr_num + return memory_type, mdb - if not is_valid_address(bank_name, addr_num): + if not is_valid_address(memory_type, display_addr): raise ValueError(f"Address out of range: {address_str!r}") - return bank_name, addr_num + return memory_type, display_addr def normalize_address(address: str) -> str | None: @@ -218,10 +178,10 @@ def normalize_address(address: str) -> str | None: Returns: The normalized display address, or None if address is invalid. """ - parsed = parse_address_display(address) - if not parsed: + try: + memory_type, mdb_address = parse_address(address) + except ValueError: return None - memory_type, mdb_address = parsed return format_address_display(memory_type, mdb_address) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 1e13fc0..5f25d05 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -9,7 +9,7 @@ from pymodbus.client import AsyncModbusTcpClient -from .addresses import parse_address +from .addresses import format_address_display, parse_address from .banks import BANKS from .modbus import ( MODBUS_MAPPINGS, @@ -81,9 +81,7 @@ def _load_tags(filepath: str) -> dict[str, dict[str, str]]: def _format_bank_address(bank: str, index: int) -> str: """Format address string for return dicts.""" - if bank in ("X", "Y"): - return f"{bank.lower()}{index:03d}" - return f"{bank.lower()}{index}" + return format_address_display(bank, index).lower() class AddressAccessor: diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 1fe502f..8bb5858 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, field from pathlib import Path -from .addresses import format_address_display, parse_address_display +from .addresses import format_address_display, parse_address # Type codes used in CDV files to identify address types @@ -105,10 +105,10 @@ def get_type_code_for_address(address: str) -> int | None: Returns: Type code or None if address is invalid. """ - parsed = parse_address_display(address) - if not parsed: + try: + memory_type, _ = parse_address(address) + except ValueError: return None - memory_type, _ = parsed return MEMORY_TYPE_TO_CODE.get(memory_type) @@ -124,12 +124,11 @@ def is_address_writable(address: str) -> bool: Returns: True if the address can have a New Value written to it. """ - parsed = parse_address_display(address) - if not parsed: + try: + memory_type, mdb_address = parse_address(address) + except ValueError: return False - memory_type, mdb_address = parsed - # XD and YD are read-only if memory_type in ("XD", "YD"): return False @@ -177,16 +176,19 @@ def is_writable(self) -> bool: @property def memory_type(self) -> str | None: """Get the memory type prefix (X, Y, DS, etc.) or None if invalid.""" - parsed = parse_address_display(self.address) - return parsed[0] if parsed else None + try: + mem_type, _ = parse_address(self.address) + return mem_type + except ValueError: + return None @property def address_number(self) -> str | None: """Get the address number as a display string, or None if invalid.""" - parsed = parse_address_display(self.address) - if not parsed: + try: + memory_type, mdb_address = parse_address(self.address) + except ValueError: return None - memory_type, mdb_address = parsed # Return the display address portion (strip the memory type prefix) display = format_address_display(memory_type, mdb_address) return display[len(memory_type) :] diff --git a/src/pyclickplc/modbus.py b/src/pyclickplc/modbus.py index 5c667df..5d06c3c 100644 --- a/src/pyclickplc/modbus.py +++ b/src/pyclickplc/modbus.py @@ -115,7 +115,7 @@ def is_writable(self) -> bool: "X": ModbusMapping("X", 0, frozenset({2}), is_coil=True), "Y": ModbusMapping("Y", 8192, frozenset({1, 5, 15}), is_coil=True), "C": ModbusMapping("C", 16384, frozenset({1, 5, 15}), is_coil=True), - "T": ModbusMapping("T", 45057, frozenset({2}), is_coil=True), + "T": ModbusMapping("T", 45056, frozenset({2}), is_coil=True), "CT": ModbusMapping("CT", 49152, frozenset({2}), is_coil=True), "SC": ModbusMapping("SC", 61440, frozenset({2}), is_coil=True, writable=_MODBUS_WRITABLE_SC), # --- Register banks --- @@ -225,7 +225,7 @@ def plc_to_modbus(bank: str, index: int) -> tuple[int, int]: Args: bank: Bank name (e.g. "X", "DS", "XD") - index: PLC display address (e.g. 1 for X001, 0 for XD0) + index: MDB index (e.g. 1 for X001, 0 for XD0, 2 for XD1) Returns: Tuple of (modbus_address, register_count) @@ -248,12 +248,11 @@ def plc_to_modbus(bank: str, index: int) -> tuple[int, int]: # Standard coils return mapping.base + (index - 1), 1 - # Registers - if bank in ("XD", "YD"): - # XD/YD: stride 2, each value is 1 register - return mapping.base + index * 2, 1 - - # Standard registers + # Registers: 0-based banks (XD/YD) use base + index, + # 1-based banks use base + width * (index - 1) + bank_cfg = BANKS[bank] + if bank_cfg.min_addr == 0: + return mapping.base + index, 1 return mapping.base + mapping.width * (index - 1), mapping.width @@ -301,21 +300,19 @@ def _reverse_coil(address: int) -> tuple[str, int] | None: def _reverse_register(address: int) -> tuple[str, int] | None: - """Reverse map a Modbus register address to (bank, index).""" + """Reverse map a Modbus register address to (bank, mdb_index).""" for bank, mapping in _REGISTER_MAPPINGS: - if bank in ("XD", "YD"): - max_display = 8 # XD0..XD8 / YD0..YD8 - end = mapping.base + max_display * 2 + 1 + bank_cfg = BANKS[bank] + max_mdb = bank_cfg.max_addr + if bank_cfg.min_addr == 0: + # 0-based banks (XD/YD): contiguous, base + mdb_index + end = mapping.base + max_mdb + 1 if mapping.base <= address < end: - offset = address - mapping.base - if offset % 2 != 0: - return None # Upper byte slot (XD0u etc.) - return bank, offset // 2 + return bank, address - mapping.base continue - # Standard register banks - max_addr = BANKS[bank].max_addr - end = mapping.base + mapping.width * max_addr + # Standard 1-based register banks + end = mapping.base + mapping.width * max_mdb if mapping.base <= address < end: offset = address - mapping.base if offset % mapping.width != 0: @@ -334,21 +331,20 @@ def modbus_to_plc_register(address: int) -> tuple[str, int, int] | None: Needed by the server for FC 06 on width-2 types (read-modify-write). Returns: - (bank, index, reg_position) or None if unmapped + (bank, mdb_index, reg_position) or None if unmapped """ for bank, mapping in _REGISTER_MAPPINGS: - if bank in ("XD", "YD"): - max_display = 8 - end = mapping.base + max_display * 2 + 1 + bank_cfg = BANKS[bank] + max_mdb = bank_cfg.max_addr + if bank_cfg.min_addr == 0: + # 0-based banks (XD/YD): contiguous, width=1 + end = mapping.base + max_mdb + 1 if mapping.base <= address < end: - offset = address - mapping.base - if offset % 2 != 0: - return None # Upper byte slot - return bank, offset // 2, 0 + return bank, address - mapping.base, 0 continue - max_addr = BANKS[bank].max_addr - end = mapping.base + mapping.width * max_addr + # Standard 1-based register banks + end = mapping.base + mapping.width * max_mdb if mapping.base <= address < end: offset = address - mapping.base index = offset // mapping.width + 1 diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py index 28ab212..e1fc7be 100644 --- a/src/pyclickplc/nicknames.py +++ b/src/pyclickplc/nicknames.py @@ -9,7 +9,7 @@ import csv from pathlib import Path -from .addresses import AddressRecord, get_addr_key, parse_address_display +from .addresses import AddressRecord, get_addr_key, parse_address from .banks import BANKS, DEFAULT_RETENTIVE, MEMORY_TYPE_BASES, MEMORY_TYPE_TO_DATA_TYPE, DataType # CSV column names (matching CLICK software export format) @@ -59,12 +59,11 @@ def read_csv(path: str | Path) -> dict[int, AddressRecord]: if not addr_str: continue - parsed = parse_address_display(addr_str) - if not parsed: + try: + mem_type, mdb_address = parse_address(addr_str) + except ValueError: continue - mem_type, mdb_address = parsed - if mem_type not in BANKS: continue diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py index b9d0c09..f495ed7 100644 --- a/src/pyclickplc/server.py +++ b/src/pyclickplc/server.py @@ -60,16 +60,8 @@ def __init__(self) -> None: def _normalize(self, address: str) -> tuple[str, str]: """Normalize address and return (normalized, bank).""" - bank, index = parse_address(address) - normalized: str = format_address_display(bank, index) - # For X/Y, format_address_display expects MDB address; parse_address returns display - # But for X/Y display==MDB, and for XD/YD we need to handle differently - if bank in ("X", "Y"): - normalized = f"{bank}{index:03d}" - elif bank in ("XD", "YD"): - normalized = f"{bank}{index}" - else: - normalized = f"{bank}{index}" + bank, mdb = parse_address(address) + normalized = format_address_display(bank, mdb) return normalized, bank def _default(self, bank: str) -> PlcValue: @@ -114,9 +106,7 @@ def _is_address_writable(bank: str, index: int) -> bool: def _format_plc_address(bank: str, index: int) -> str: """Format a PLC address string for DataProvider calls.""" - if bank in ("X", "Y"): - return f"{bank}{index:03d}" - return f"{bank}{index}" + return format_address_display(bank, index) class _ClickDeviceContext(ModbusBaseDeviceContext): diff --git a/tests/test_addresses.py b/tests/test_addresses.py index cbbf3ea..8513759 100644 --- a/tests/test_addresses.py +++ b/tests/test_addresses.py @@ -13,7 +13,6 @@ normalize_address, parse_addr_key, parse_address, - parse_address_display, xd_yd_display_to_mdb, xd_yd_mdb_to_display, ) @@ -108,54 +107,28 @@ def test_ds_no_padding(self): assert format_address_display("DS", 4500) == "DS4500" -# ============================================================================== -# parse_address_display -# ============================================================================== - - -class TestParseAddressDisplay: - def test_basic_types(self): - assert parse_address_display("X001") == ("X", 1) - assert parse_address_display("DS100") == ("DS", 100) - assert parse_address_display("C1") == ("C", 1) - - def test_case_insensitive(self): - assert parse_address_display("x001") == ("X", 1) - assert parse_address_display("ds100") == ("DS", 100) - - def test_xd_yd(self): - assert parse_address_display("XD0") == ("XD", 0) - assert parse_address_display("XD0U") == ("XD", 1) - assert parse_address_display("XD0u") == ("XD", 1) - assert parse_address_display("XD1") == ("XD", 2) - assert parse_address_display("XD8") == ("XD", 16) - - def test_invalid_returns_none(self): - assert parse_address_display("") is None - assert parse_address_display("FAKE1") is None - assert parse_address_display("XD1U") is None # Only XD0 can have U - assert parse_address_display("!!!") is None - - # ============================================================================== # parse_address # ============================================================================== class TestParseAddress: - def test_basic(self): + def test_basic_types(self): + assert parse_address("X001") == ("X", 1) assert parse_address("DS100") == ("DS", 100) assert parse_address("C1") == ("C", 1) def test_case_insensitive(self): - assert parse_address("ds100") == ("DS", 100) assert parse_address("x001") == ("X", 1) + assert parse_address("ds100") == ("DS", 100) - def test_xd_returns_display_addr(self): - # parse_address returns display address, NOT MDB - assert parse_address("XD1") == ("XD", 1) - assert parse_address("XD8") == ("XD", 8) + def test_xd_yd_returns_mdb(self): + # parse_address returns MDB address assert parse_address("XD0") == ("XD", 0) + assert parse_address("XD0U") == ("XD", 1) + assert parse_address("XD0u") == ("XD", 1) + assert parse_address("XD1") == ("XD", 2) + assert parse_address("XD8") == ("XD", 16) def test_raises_on_invalid(self): with pytest.raises(ValueError): @@ -168,6 +141,8 @@ def test_raises_on_invalid(self): parse_address("DS4501") # Out of range with pytest.raises(ValueError): parse_address("!!!") + with pytest.raises(ValueError): + parse_address("XD1U") # Only XD0 can have U def test_raises_on_sparse_gap(self): with pytest.raises(ValueError): diff --git a/tests/test_modbus.py b/tests/test_modbus.py index 3a6b2f7..c15852e 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -245,16 +245,19 @@ def test_xd0(self): assert plc_to_modbus("XD", 0) == (57344, 1) def test_xd1(self): - assert plc_to_modbus("XD", 1) == (57346, 1) + # MDB index 2 = XD1 display + assert plc_to_modbus("XD", 2) == (57346, 1) def test_xd8(self): - assert plc_to_modbus("XD", 8) == (57360, 1) + # MDB index 16 = XD8 display + assert plc_to_modbus("XD", 16) == (57360, 1) def test_yd0(self): assert plc_to_modbus("YD", 0) == (57856, 1) def test_yd1(self): - assert plc_to_modbus("YD", 1) == (57858, 1) + # MDB index 2 = YD1 display + assert plc_to_modbus("YD", 2) == (57858, 1) # --- Errors --- @@ -395,17 +398,19 @@ def test_reg_57344_xd0(self): assert modbus_to_plc(57344, is_coil=False) == ("XD", 0) def test_reg_57346_xd1(self): - assert modbus_to_plc(57346, is_coil=False) == ("XD", 1) + # MDB index 2 = XD1 display + assert modbus_to_plc(57346, is_coil=False) == ("XD", 2) - def test_reg_57345_xd0u_gap(self): - """Register 57345 is XD0u (odd offset) -> None.""" - assert modbus_to_plc(57345, is_coil=False) is None + def test_reg_57345_xd0u(self): + """Register 57345 is XD0u (MDB index 1) — now addressable.""" + assert modbus_to_plc(57345, is_coil=False) == ("XD", 1) def test_reg_57856_yd0(self): assert modbus_to_plc(57856, is_coil=False) == ("YD", 0) def test_reg_57858_yd1(self): - assert modbus_to_plc(57858, is_coil=False) == ("YD", 1) + # MDB index 2 = YD1 display + assert modbus_to_plc(57858, is_coil=False) == ("YD", 2) # --- Unmapped registers --- @@ -472,11 +477,11 @@ def test_coil_round_trip(self, bank: str, index: int): ("SD", 1), ("SD", 1000), ("XD", 0), - ("XD", 1), - ("XD", 8), + ("XD", 2), + ("XD", 16), ("YD", 0), - ("YD", 1), - ("YD", 8), + ("YD", 2), + ("YD", 16), ], ) def test_register_round_trip(self, bank: str, index: int): @@ -682,34 +687,35 @@ def test_sparse_slot_last_addresses(self): result = modbus_to_plc(addr, is_coil=True) assert result == ("X", hi) - def test_xd_yd_odd_offsets_return_none(self): - """Odd offsets within XD/YD range return None (upper byte slots).""" - # XD base=57344, odd offsets - assert modbus_to_plc(57345, is_coil=False) is None # XD0u - assert modbus_to_plc(57347, is_coil=False) is None # between XD1 and XD2 - assert modbus_to_plc(57349, is_coil=False) is None - - # YD base=57856, odd offsets - assert modbus_to_plc(57857, is_coil=False) is None # YD0u - assert modbus_to_plc(57859, is_coil=False) is None - - def test_xd_all_valid_addresses(self): - """XD0 through XD8 all map correctly.""" - for i in range(9): - addr, count = plc_to_modbus("XD", i) + def test_xd_yd_all_mdb_indices_addressable(self): + """All MDB indices 0-16 within XD/YD range are addressable.""" + # XD base=57344 + for mdb in range(17): + result = modbus_to_plc(57344 + mdb, is_coil=False) + assert result == ("XD", mdb) + + # YD base=57856 + for mdb in range(17): + result = modbus_to_plc(57856 + mdb, is_coil=False) + assert result == ("YD", mdb) + + def test_xd_all_valid_mdb_addresses(self): + """XD MDB 0 through 16 all map correctly (contiguous).""" + for mdb in range(17): + addr, count = plc_to_modbus("XD", mdb) assert count == 1 - assert addr == 57344 + i * 2 + assert addr == 57344 + mdb result = modbus_to_plc(addr, is_coil=False) - assert result == ("XD", i) + assert result == ("XD", mdb) - def test_yd_all_valid_addresses(self): - """YD0 through YD8 all map correctly.""" - for i in range(9): - addr, count = plc_to_modbus("YD", i) + def test_yd_all_valid_mdb_addresses(self): + """YD MDB 0 through 16 all map correctly (contiguous).""" + for mdb in range(17): + addr, count = plc_to_modbus("YD", mdb) assert count == 1 - assert addr == 57856 + i * 2 + assert addr == 57856 + mdb result = modbus_to_plc(addr, is_coil=False) - assert result == ("YD", i) + assert result == ("YD", mdb) def test_df_mid_register_returns_none(self): """Odd offsets in DF (width-2) return None.""" diff --git a/tests/test_server.py b/tests/test_server.py index bdbe4ce..6ee0bde 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -160,9 +160,9 @@ def test_sd1(self): def test_xd0(self): assert modbus_to_plc_register(57344) == ("XD", 0, 0) - def test_xd_odd_none(self): - """XD odd offset (upper byte) still returns None.""" - assert modbus_to_plc_register(57345) is None + def test_xd_mdb1(self): + """XD MDB index 1 (XD0u) is now addressable.""" + assert modbus_to_plc_register(57345) == ("XD", 1, 0) def test_yd0(self): assert modbus_to_plc_register(57856) == ("YD", 0, 0) From 0018b59a7232601c46dcef5299a74f4e4ffb7201 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:06:58 -0500 Subject: [PATCH 10/55] Separate dataview value conversion into data and display layers Replace storage_to_display/display_to_storage with four functions: storage_to_datatype, datatype_to_storage (CDV strings <-> native Python types) and datatype_to_display, display_to_datatype (native types <-> UI strings). This moves formatting concerns (hex padding, float precision, char display) out of the storage layer. Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 12 +- src/pyclickplc/dataview.py | 202 +++++++++++++--------- tests/test_dataview.py | 340 +++++++++++++++++++++++++------------ 3 files changed, 369 insertions(+), 185 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 13c4436..25bc437 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -50,7 +50,9 @@ DataviewRow, TypeCode, create_empty_dataview, - display_to_storage, + datatype_to_display, + datatype_to_storage, + display_to_datatype, export_cdv, get_dataview_folder, get_type_code_for_address, @@ -58,7 +60,7 @@ list_cdv_files, load_cdv, save_cdv, - storage_to_display, + storage_to_datatype, ) from .modbus import ( MODBUS_MAPPINGS, @@ -162,8 +164,10 @@ "get_type_code_for_address", "is_address_writable", "create_empty_dataview", - "storage_to_display", - "display_to_storage", + "storage_to_datatype", + "datatype_to_storage", + "datatype_to_display", + "display_to_datatype", "export_cdv", "load_cdv", "save_cdv", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 8bb5858..acd07be 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -1,11 +1,13 @@ """DataView model and CDV file I/O for CLICK PLC DataView files. Provides the DataviewRow dataclass, type code mappings, CDV file read/write, -and new-value storage/display conversion functions. +and value conversion functions between CDV storage, native Python types, +and UI display strings. """ from __future__ import annotations +import struct from dataclasses import dataclass, field from pathlib import Path @@ -226,154 +228,200 @@ def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataviewRow]: return [DataviewRow() for _ in range(count)] -# --- New Value Conversion Functions --- -# CDV files store values in specific formats that need conversion for display +# --- Value Conversion Functions --- +# +# Two layers of conversion: +# 1. storage <-> datatype: CDV file strings <-> native Python types +# 2. datatype <-> display: native Python types <-> UI-friendly strings +# +# The storage layer handles CDV encoding (sign extension, IEEE 754, etc.). +# The display layer handles presentation (hex formatting, float precision, etc.). -def storage_to_display(value: str, type_code: int) -> str: - """Convert a stored CDV value to display format. +def storage_to_datatype(value: str, type_code: int) -> int | float | bool | None: + """Convert a CDV storage string to its native Python type. Args: - value: The raw value from the CDV file + value: The raw value string from the CDV file. type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) Returns: - Human-readable display value + Native Python value (bool for BIT, int for INT/INT2/HEX/TXT, + float for FLOAT), or None if empty/invalid. """ if not value: - return "" + return None try: if type_code == TypeCode.BIT: - # BIT: 0 or 1 - return "1" if value == "1" else "0" + return value == "1" elif type_code == TypeCode.INT: - # INT (16-bit signed): Stored as unsigned 32-bit with sign extension - # Convert back to signed 16-bit + # Stored as unsigned 32-bit with sign extension → signed 16-bit unsigned_val = int(value) - # Mask to 16 bits and convert to signed val_16bit = unsigned_val & 0xFFFF if val_16bit >= 0x8000: val_16bit -= 0x10000 - return str(val_16bit) + return val_16bit elif type_code == TypeCode.INT2: - # INT2 (32-bit signed): Stored as unsigned 32-bit - # Convert back to signed 32-bit + # Stored as unsigned 32-bit → signed 32-bit unsigned_val = int(value) if unsigned_val >= 0x80000000: unsigned_val -= 0x100000000 - return str(unsigned_val) + return unsigned_val elif type_code == TypeCode.HEX: - # HEX: Display as 4-digit hex, uppercase, NO suffix. - decimal_val = int(value) - return format(decimal_val, "04X") + return int(value) elif type_code == TypeCode.FLOAT: - # FLOAT: Stored as IEEE 754 32-bit integer representation - import struct - + # Stored as IEEE 754 32-bit integer representation → float int_val = int(value) - # Convert integer to bytes (unsigned 32-bit) bytes_val = struct.pack(">I", int_val & 0xFFFFFFFF) - # Interpret as big-endian float - float_val = struct.unpack(">f", bytes_val)[0] - - # Use 'G' for general format: - # 1. Automatically uses Scientific notation for large numbers - # 2. Automatically trims trailing zeros for small numbers - # 3. Uppercase 'E' - return f"{float_val:.7G}" + return struct.unpack(">f", bytes_val)[0] elif type_code == TypeCode.TXT: - # TXT: Stored as ASCII code, display as character - ascii_code = int(value) - if 32 <= ascii_code <= 126: # Printable ASCII - return chr(ascii_code) - return str(ascii_code) # Non-printable: show as number + return int(value) else: - return value + return None except (ValueError, struct.error): - return value + return None -def display_to_storage(value: str, type_code: int) -> str: - """Convert a display value to CDV storage format. +def datatype_to_storage(value: int | float | bool | None, type_code: int) -> str: + """Convert a native Python value to CDV storage format. Args: - value: The human-readable display value + value: The native Python value (bool, int, or float). type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) Returns: - Value formatted for CDV file storage + Value formatted for CDV file storage, or "" if None. """ - if not value: + if value is None: return "" try: if type_code == TypeCode.BIT: - # BIT: 0 or 1 - return "1" if value in ("1", "True", "true", "ON", "on") else "0" + return "1" if value else "0" elif type_code == TypeCode.INT: - # INT (16-bit signed): Convert to unsigned 32-bit with sign extension + # Signed 16-bit → unsigned 32-bit with sign extension signed_val = int(value) - # Clamp to 16-bit signed range signed_val = max(-32768, min(32767, signed_val)) - # Convert to unsigned 32-bit representation if signed_val < 0: - unsigned_val = signed_val + 0x100000000 - else: - unsigned_val = signed_val - return str(unsigned_val) + return str(signed_val + 0x100000000) + return str(signed_val) elif type_code == TypeCode.INT2: - # INT2 (32-bit signed): Convert to unsigned 32-bit + # Signed 32-bit → unsigned 32-bit signed_val = int(value) - # Clamp to 32-bit signed range signed_val = max(-2147483648, min(2147483647, signed_val)) if signed_val < 0: - unsigned_val = signed_val + 0x100000000 - else: - unsigned_val = signed_val - return str(unsigned_val) + return str(signed_val + 0x100000000) + return str(signed_val) elif type_code == TypeCode.HEX: - # HEX: Convert hex string to decimal - # Support with or without 0x prefix - hex_val = value.strip() - if hex_val.lower().startswith("0x"): - hex_val = hex_val[2:] - decimal_val = int(hex_val, 16) - return str(decimal_val) + return str(int(value)) elif type_code == TypeCode.FLOAT: - # Display (String) -> Float -> IEEE 754 Bytes -> Int -> Storage (String) - import struct - + # Float → IEEE 754 bytes → unsigned 32-bit integer string float_val = float(value) - # Convert float to bytes bytes_val = struct.pack(">f", float_val) - # Interpret as unsigned 32-bit integer int_val = struct.unpack(">I", bytes_val)[0] return str(int_val) elif type_code == TypeCode.TXT: - # TXT: Convert character to ASCII code - if len(value) == 1: - return str(ord(value)) - # If it's already a number, keep it return str(int(value)) else: - return value + return "" except (ValueError, struct.error): - return value + return "" + + +def datatype_to_display(value: int | float | bool | None, type_code: int) -> str: + """Convert a native Python value to a UI-friendly display string. + + Args: + value: The native Python value (bool, int, or float). + type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + + Returns: + Human-readable display string, or "" if None. + """ + if value is None: + return "" + + try: + if type_code == TypeCode.BIT: + return "1" if value else "0" + + elif type_code in (TypeCode.INT, TypeCode.INT2): + return str(int(value)) + + elif type_code == TypeCode.HEX: + return format(int(value), "04X") + + elif type_code == TypeCode.FLOAT: + return f"{float(value):.7G}" + + elif type_code == TypeCode.TXT: + code = int(value) + if 32 <= code <= 126: + return chr(code) + return str(code) + + else: + return str(value) + + except (ValueError, TypeError): + return "" + + +def display_to_datatype(value: str, type_code: int) -> int | float | bool | None: + """Convert a UI display string to its native Python type. + + Args: + value: The human-readable display string. + type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + + Returns: + Native Python value (bool for BIT, int for INT/INT2/HEX/TXT, + float for FLOAT), or None if empty/invalid. + """ + if not value: + return None + + try: + if type_code == TypeCode.BIT: + return value in ("1", "True", "true", "ON", "on") + + elif type_code in (TypeCode.INT, TypeCode.INT2): + return int(value) + + elif type_code == TypeCode.HEX: + hex_val = value.strip() + if hex_val.lower().startswith("0x"): + hex_val = hex_val[2:] + return int(hex_val, 16) + + elif type_code == TypeCode.FLOAT: + return float(value) + + elif type_code == TypeCode.TXT: + if len(value) == 1: + return ord(value) + return int(value) + + else: + return None + + except (ValueError, TypeError): + return None # ============================================================================= diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 63a10fa..9cc180c 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -1,5 +1,7 @@ """Tests for pyclickplc.dataview — DataView model and CDV file I/O.""" +import pytest + from pyclickplc.dataview import ( MAX_DATAVIEW_ROWS, WRITABLE_SC, @@ -7,12 +9,14 @@ DataviewRow, TypeCode, create_empty_dataview, - display_to_storage, + datatype_to_display, + datatype_to_storage, + display_to_datatype, get_type_code_for_address, is_address_writable, load_cdv, save_cdv, - storage_to_display, + storage_to_datatype, ) @@ -173,149 +177,279 @@ def test_rows_are_independent(self): assert rows[1].address == "" -class TestStorageToDisplay: - """Tests for storage_to_display conversion.""" +class TestStorageToDatatype: + """Tests for storage_to_datatype: CDV string -> native Python type.""" def test_bit_values(self): - assert storage_to_display("1", TypeCode.BIT) == "1" - assert storage_to_display("0", TypeCode.BIT) == "0" + assert storage_to_datatype("1", TypeCode.BIT) is True + assert storage_to_datatype("0", TypeCode.BIT) is False def test_int_positive(self): - assert storage_to_display("0", TypeCode.INT) == "0" - assert storage_to_display("100", TypeCode.INT) == "100" - assert storage_to_display("32767", TypeCode.INT) == "32767" + assert storage_to_datatype("0", TypeCode.INT) == 0 + assert storage_to_datatype("100", TypeCode.INT) == 100 + assert storage_to_datatype("32767", TypeCode.INT) == 32767 def test_int_negative(self): - assert storage_to_display("4294934528", TypeCode.INT) == "-32768" - assert storage_to_display("4294967295", TypeCode.INT) == "-1" - assert storage_to_display("65535", TypeCode.INT) == "-1" + assert storage_to_datatype("4294934528", TypeCode.INT) == -32768 + assert storage_to_datatype("4294967295", TypeCode.INT) == -1 + assert storage_to_datatype("65535", TypeCode.INT) == -1 def test_int2_positive(self): - assert storage_to_display("0", TypeCode.INT2) == "0" - assert storage_to_display("100", TypeCode.INT2) == "100" - assert storage_to_display("2147483647", TypeCode.INT2) == "2147483647" + assert storage_to_datatype("0", TypeCode.INT2) == 0 + assert storage_to_datatype("100", TypeCode.INT2) == 100 + assert storage_to_datatype("2147483647", TypeCode.INT2) == 2147483647 def test_int2_negative(self): - assert storage_to_display("2147483648", TypeCode.INT2) == "-2147483648" - assert storage_to_display("4294967294", TypeCode.INT2) == "-2" - assert storage_to_display("4294967295", TypeCode.INT2) == "-1" - - def test_float_values(self): - assert storage_to_display("0", TypeCode.FLOAT) == "0" - assert storage_to_display("1065353216", TypeCode.FLOAT) == "1" - val = storage_to_display("1078523331", TypeCode.FLOAT) - assert val.startswith("3.14") - assert "-" in storage_to_display("4286578685", TypeCode.FLOAT) + assert storage_to_datatype("2147483648", TypeCode.INT2) == -2147483648 + assert storage_to_datatype("4294967294", TypeCode.INT2) == -2 + assert storage_to_datatype("4294967295", TypeCode.INT2) == -1 def test_hex_values(self): - assert storage_to_display("65535", TypeCode.HEX) == "FFFF" - assert storage_to_display("255", TypeCode.HEX) == "00FF" - assert storage_to_display("0", TypeCode.HEX) == "0000" + assert storage_to_datatype("65535", TypeCode.HEX) == 65535 + assert storage_to_datatype("255", TypeCode.HEX) == 255 + assert storage_to_datatype("0", TypeCode.HEX) == 0 + + def test_float_values(self): + assert storage_to_datatype("0", TypeCode.FLOAT) == 0.0 + assert storage_to_datatype("1065353216", TypeCode.FLOAT) == 1.0 + val = storage_to_datatype("1078523331", TypeCode.FLOAT) + assert val == pytest.approx(3.14, abs=1e-5) + val = storage_to_datatype("4286578685", TypeCode.FLOAT) + assert val < 0 def test_txt_values(self): - assert storage_to_display("48", TypeCode.TXT) == "0" - assert storage_to_display("65", TypeCode.TXT) == "A" - assert storage_to_display("90", TypeCode.TXT) == "Z" - assert storage_to_display("49", TypeCode.TXT) == "1" + assert storage_to_datatype("48", TypeCode.TXT) == 48 + assert storage_to_datatype("65", TypeCode.TXT) == 65 + assert storage_to_datatype("90", TypeCode.TXT) == 90 + assert storage_to_datatype("32", TypeCode.TXT) == 32 def test_empty_value(self): - assert storage_to_display("", TypeCode.INT) == "" - assert storage_to_display("", TypeCode.HEX) == "" + assert storage_to_datatype("", TypeCode.INT) is None + assert storage_to_datatype("", TypeCode.HEX) is None + assert storage_to_datatype("", TypeCode.BIT) is None - def test_txt_space(self): - assert storage_to_display("32", TypeCode.TXT) == " " + def test_invalid_value(self): + assert storage_to_datatype("abc", TypeCode.INT) is None + def test_unknown_type_code(self): + assert storage_to_datatype("42", 9999) is None -class TestDisplayToStorage: - """Tests for display_to_storage conversion.""" + +class TestDatatypeToStorage: + """Tests for datatype_to_storage: native Python type -> CDV string.""" def test_bit_values(self): - assert display_to_storage("1", TypeCode.BIT) == "1" - assert display_to_storage("0", TypeCode.BIT) == "0" + assert datatype_to_storage(True, TypeCode.BIT) == "1" + assert datatype_to_storage(False, TypeCode.BIT) == "0" + assert datatype_to_storage(1, TypeCode.BIT) == "1" + assert datatype_to_storage(0, TypeCode.BIT) == "0" def test_int_positive(self): - assert display_to_storage("0", TypeCode.INT) == "0" - assert display_to_storage("100", TypeCode.INT) == "100" - assert display_to_storage("32767", TypeCode.INT) == "32767" + assert datatype_to_storage(0, TypeCode.INT) == "0" + assert datatype_to_storage(100, TypeCode.INT) == "100" + assert datatype_to_storage(32767, TypeCode.INT) == "32767" def test_int_negative(self): - assert display_to_storage("-32768", TypeCode.INT) == "4294934528" - assert display_to_storage("-1", TypeCode.INT) == "4294967295" + assert datatype_to_storage(-32768, TypeCode.INT) == "4294934528" + assert datatype_to_storage(-1, TypeCode.INT) == "4294967295" def test_int2_positive(self): - assert display_to_storage("0", TypeCode.INT2) == "0" - assert display_to_storage("100", TypeCode.INT2) == "100" + assert datatype_to_storage(0, TypeCode.INT2) == "0" + assert datatype_to_storage(100, TypeCode.INT2) == "100" def test_int2_negative(self): - assert display_to_storage("-2147483648", TypeCode.INT2) == "2147483648" - assert display_to_storage("-2", TypeCode.INT2) == "4294967294" + assert datatype_to_storage(-2147483648, TypeCode.INT2) == "2147483648" + assert datatype_to_storage(-2, TypeCode.INT2) == "4294967294" + + def test_hex_values(self): + assert datatype_to_storage(65535, TypeCode.HEX) == "65535" + assert datatype_to_storage(255, TypeCode.HEX) == "255" + assert datatype_to_storage(0, TypeCode.HEX) == "0" + + def test_float_values(self): + assert datatype_to_storage(0.0, TypeCode.FLOAT) == "0" + assert datatype_to_storage(1.0, TypeCode.FLOAT) == "1065353216" + assert datatype_to_storage(-1.0, TypeCode.FLOAT) == "3212836864" + + def test_txt_values(self): + assert datatype_to_storage(48, TypeCode.TXT) == "48" + assert datatype_to_storage(65, TypeCode.TXT) == "65" + assert datatype_to_storage(90, TypeCode.TXT) == "90" + + def test_none_value(self): + assert datatype_to_storage(None, TypeCode.INT) == "" + assert datatype_to_storage(None, TypeCode.HEX) == "" + + def test_unknown_type_code(self): + assert datatype_to_storage(42, 9999) == "" + + +class TestDatatypeToDisplay: + """Tests for datatype_to_display: native Python type -> UI string.""" + + def test_bit_values(self): + assert datatype_to_display(True, TypeCode.BIT) == "1" + assert datatype_to_display(False, TypeCode.BIT) == "0" + + def test_int_values(self): + assert datatype_to_display(0, TypeCode.INT) == "0" + assert datatype_to_display(100, TypeCode.INT) == "100" + assert datatype_to_display(-32768, TypeCode.INT) == "-32768" + assert datatype_to_display(32767, TypeCode.INT) == "32767" + + def test_int2_values(self): + assert datatype_to_display(0, TypeCode.INT2) == "0" + assert datatype_to_display(-2147483648, TypeCode.INT2) == "-2147483648" + assert datatype_to_display(2147483647, TypeCode.INT2) == "2147483647" + + def test_hex_values(self): + assert datatype_to_display(65535, TypeCode.HEX) == "FFFF" + assert datatype_to_display(255, TypeCode.HEX) == "00FF" + assert datatype_to_display(0, TypeCode.HEX) == "0000" + assert datatype_to_display(1, TypeCode.HEX) == "0001" def test_float_values(self): - assert display_to_storage("0.0", TypeCode.FLOAT) == "0" - assert display_to_storage("1.0", TypeCode.FLOAT) == "1065353216" - assert display_to_storage("-1.0", TypeCode.FLOAT) == "3212836864" + assert datatype_to_display(0.0, TypeCode.FLOAT) == "0" + assert datatype_to_display(1.0, TypeCode.FLOAT) == "1" + assert datatype_to_display(3.1400001049041748, TypeCode.FLOAT) == "3.14" + assert datatype_to_display(3.4028234663852886e38, TypeCode.FLOAT) == "3.402823E+38" + assert datatype_to_display(-3.4028234663852886e38, TypeCode.FLOAT) == "-3.402823E+38" + + def test_txt_printable(self): + assert datatype_to_display(48, TypeCode.TXT) == "0" + assert datatype_to_display(65, TypeCode.TXT) == "A" + assert datatype_to_display(90, TypeCode.TXT) == "Z" + assert datatype_to_display(32, TypeCode.TXT) == " " + + def test_txt_nonprintable(self): + assert datatype_to_display(5, TypeCode.TXT) == "5" + assert datatype_to_display(127, TypeCode.TXT) == "127" + + def test_none_value(self): + assert datatype_to_display(None, TypeCode.INT) == "" + assert datatype_to_display(None, TypeCode.HEX) == "" + + +class TestDisplayToDatatype: + """Tests for display_to_datatype: UI string -> native Python type.""" + + def test_bit_values(self): + assert display_to_datatype("1", TypeCode.BIT) is True + assert display_to_datatype("0", TypeCode.BIT) is False + assert display_to_datatype("True", TypeCode.BIT) is True + assert display_to_datatype("ON", TypeCode.BIT) is True + + def test_int_values(self): + assert display_to_datatype("0", TypeCode.INT) == 0 + assert display_to_datatype("100", TypeCode.INT) == 100 + assert display_to_datatype("-32768", TypeCode.INT) == -32768 + assert display_to_datatype("32767", TypeCode.INT) == 32767 + + def test_int2_values(self): + assert display_to_datatype("0", TypeCode.INT2) == 0 + assert display_to_datatype("-2147483648", TypeCode.INT2) == -2147483648 + assert display_to_datatype("2147483647", TypeCode.INT2) == 2147483647 def test_hex_values(self): - assert display_to_storage("FFFF", TypeCode.HEX) == "65535" - assert display_to_storage("FF", TypeCode.HEX) == "255" - assert display_to_storage("0xFF", TypeCode.HEX) == "255" - assert display_to_storage("0", TypeCode.HEX) == "0" + assert display_to_datatype("FFFF", TypeCode.HEX) == 65535 + assert display_to_datatype("FF", TypeCode.HEX) == 255 + assert display_to_datatype("0xFF", TypeCode.HEX) == 255 + assert display_to_datatype("0", TypeCode.HEX) == 0 - def test_txt_values(self): - assert display_to_storage("0", TypeCode.TXT) == "48" - assert display_to_storage("A", TypeCode.TXT) == "65" - assert display_to_storage("Z", TypeCode.TXT) == "90" - assert display_to_storage("1", TypeCode.TXT) == "49" + def test_float_values(self): + assert display_to_datatype("3.14", TypeCode.FLOAT) == pytest.approx(3.14) + assert display_to_datatype("0.0", TypeCode.FLOAT) == 0.0 + assert display_to_datatype("-1.0", TypeCode.FLOAT) == -1.0 + + def test_txt_char(self): + assert display_to_datatype("A", TypeCode.TXT) == 65 + assert display_to_datatype("Z", TypeCode.TXT) == 90 + assert display_to_datatype("0", TypeCode.TXT) == 48 + assert display_to_datatype(" ", TypeCode.TXT) == 32 + + def test_txt_numeric(self): + assert display_to_datatype("65", TypeCode.TXT) == 65 def test_empty_value(self): - assert display_to_storage("", TypeCode.INT) == "" - assert display_to_storage("", TypeCode.HEX) == "" + assert display_to_datatype("", TypeCode.INT) is None + assert display_to_datatype("", TypeCode.HEX) is None - def test_txt_space(self): - assert display_to_storage(" ", TypeCode.TXT) == "32" + def test_invalid_value(self): + assert display_to_datatype("abc", TypeCode.INT) is None - def test_snapshot_data_consistency(self): - assert storage_to_display("4286578685", TypeCode.FLOAT) == "-3.402823E+38" - assert storage_to_display("2139095037", TypeCode.FLOAT) == "3.402823E+38" - assert storage_to_display("1078523331", TypeCode.FLOAT).startswith("3.14") - assert storage_to_display("0", TypeCode.HEX) == "0000" - assert storage_to_display("65535", TypeCode.HEX) == "FFFF" - assert storage_to_display("1", TypeCode.HEX) == "0001" - assert storage_to_display("4294967295", TypeCode.INT) == "-1" - assert storage_to_display("4294967295", TypeCode.INT2) == "-1" + def test_unknown_type_code(self): + assert display_to_datatype("42", 9999) is None class TestRoundTripConversion: - """Tests for round-trip storage <-> display conversion.""" - - def test_int_roundtrip(self): - for val in ["-32768", "-1", "0", "100", "32767"]: - storage = display_to_storage(val, TypeCode.INT) - display = storage_to_display(storage, TypeCode.INT) - assert display == val, f"Round-trip failed for {val}" - - def test_int2_roundtrip(self): - for val in ["-2147483648", "-2", "-1", "0", "100", "2147483647"]: - storage = display_to_storage(val, TypeCode.INT2) - display = storage_to_display(storage, TypeCode.INT2) - assert display == val, f"Round-trip failed for {val}" - - def test_hex_roundtrip(self): - test_cases = [ - ("0", "0000"), - ("FF", "00FF"), - ("FFFF", "FFFF"), + """Tests for round-trip conversions across layers.""" + + def test_storage_datatype_roundtrip_int(self): + for storage_val in ["0", "100", "32767", "4294934528", "4294967295"]: + native = storage_to_datatype(storage_val, TypeCode.INT) + storage = datatype_to_storage(native, TypeCode.INT) + assert storage_to_datatype(storage, TypeCode.INT) == native + + def test_storage_datatype_roundtrip_int2(self): + for storage_val in ["0", "100", "2147483647", "2147483648", "4294967294"]: + native = storage_to_datatype(storage_val, TypeCode.INT2) + storage = datatype_to_storage(native, TypeCode.INT2) + assert storage_to_datatype(storage, TypeCode.INT2) == native + + def test_storage_datatype_roundtrip_hex(self): + for storage_val in ["0", "255", "65535"]: + native = storage_to_datatype(storage_val, TypeCode.HEX) + storage = datatype_to_storage(native, TypeCode.HEX) + assert storage == storage_val + + def test_storage_datatype_roundtrip_float(self): + for storage_val in ["0", "1065353216", "3212836864"]: + native = storage_to_datatype(storage_val, TypeCode.FLOAT) + storage = datatype_to_storage(native, TypeCode.FLOAT) + assert storage == storage_val + + def test_storage_datatype_roundtrip_txt(self): + for storage_val in ["32", "48", "65", "90"]: + native = storage_to_datatype(storage_val, TypeCode.TXT) + storage = datatype_to_storage(native, TypeCode.TXT) + assert storage == storage_val + + def test_display_datatype_roundtrip_hex(self): + for display_val, expected in [("0", "0000"), ("FF", "00FF"), ("FFFF", "FFFF")]: + native = display_to_datatype(display_val, TypeCode.HEX) + display = datatype_to_display(native, TypeCode.HEX) + assert display == expected + + def test_display_datatype_roundtrip_txt(self): + for char in ["A", "Z", "0", " "]: + native = display_to_datatype(char, TypeCode.TXT) + display = datatype_to_display(native, TypeCode.TXT) + assert display == char + + def test_full_pipeline_snapshot(self): + """Full pipeline: CDV storage -> datatype -> display string.""" + cases = [ + ("4286578685", TypeCode.FLOAT, "-3.402823E+38"), + ("2139095037", TypeCode.FLOAT, "3.402823E+38"), + ("0", TypeCode.HEX, "0000"), + ("65535", TypeCode.HEX, "FFFF"), + ("1", TypeCode.HEX, "0001"), + ("4294967295", TypeCode.INT, "-1"), + ("4294967295", TypeCode.INT2, "-1"), ] - for input_val, expected_display in test_cases: - storage = display_to_storage(input_val, TypeCode.HEX) - display = storage_to_display(storage, TypeCode.HEX) - assert display == expected_display, f"Round-trip failed for {input_val}" + for storage_val, type_code, expected_display in cases: + native = storage_to_datatype(storage_val, type_code) + display = datatype_to_display(native, type_code) + assert display == expected_display, ( + f"Pipeline failed for {storage_val} (type {type_code}): " + f"got {display!r}, expected {expected_display!r}" + ) - def test_txt_roundtrip(self): - for val in ["0", "A", "Z", "1"]: - storage = display_to_storage(val, TypeCode.TXT) - display = storage_to_display(storage, TypeCode.TXT) - assert display == val, f"Round-trip failed for {val}" + def test_full_pipeline_float_pi(self): + """Full pipeline for pi-ish float value.""" + native = storage_to_datatype("1078523331", TypeCode.FLOAT) + display = datatype_to_display(native, TypeCode.FLOAT) + assert display.startswith("3.14") class TestLoadCdv: @@ -352,8 +486,6 @@ def test_load_with_new_values(self, tmp_path): assert rows[0].new_value == "1" def test_load_nonexistent(self, tmp_path): - import pytest - with pytest.raises(FileNotFoundError): load_cdv(tmp_path / "missing.cdv") From d94ea7af5788d3087c99c9c1d1fead545fef8820 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:17:01 -0500 Subject: [PATCH 11/55] spec: light cleanup. Possible future ClickProject object? --- scratchpad/TestingDisplayVersusStorage.cdv | Bin 686 -> 0 bytes scratchpad/TestingDisplayVersusStorage.csv | 24 ------- spec/ARCHITECTURE.md | 10 +-- spec/HANDOFF.md | 75 --------------------- 4 files changed, 5 insertions(+), 104 deletions(-) delete mode 100644 scratchpad/TestingDisplayVersusStorage.cdv delete mode 100644 scratchpad/TestingDisplayVersusStorage.csv delete mode 100644 spec/HANDOFF.md diff --git a/scratchpad/TestingDisplayVersusStorage.cdv b/scratchpad/TestingDisplayVersusStorage.cdv deleted file mode 100644 index 5feb12c5519082d2282e366618d6a34efafacc88..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 686 zcmZ`%%MQXY49gjbKLM%hV;g6xX?J#j|NjrbPH2O8Xxi2$w&SFI+>bKIL4v+}*~t~_ zps`6tnGqkv;}QE74;nxJz*f0*)htm?lJO>d3wE>eh@l!QC#@pA;6%!)-n^r}R`G2_Pqk`u+_gB?R<9mmnewOZMf(~&rd=z-% zf9}4Q?uR(~PUtFVX94TxXX$>38-Sn^XPWe}ld7_6U7MkGylMqTSib<>X11y(GseDQ t*1QqUW`y(lW^cGHo1*C@>Qt!DnwBHabL`1;R Date: Sat, 7 Feb 2026 15:24:18 -0500 Subject: [PATCH 12/55] Add CLAUDE.md and rewrite README with public API docs and usage examples Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 69 ++++++++++++++++++++++ README.md | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b96a582 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`pyclickplc` is a shared utility library for AutomationDirect CLICK PLCs. It provides PLC bank definitions, address parsing, nickname CSV and DataView CDV file I/O, BlockTag parsing, Modbus protocol mapping, and async Modbus TCP client/server. Consumed by ClickNick (GUI editor), pyrung (simulation), and standalone tooling. + +## Commands + +- **Install**: `uv sync --all-extras --dev` +- **Run all (install + lint + test)**: `make` or `make default` +- **Test**: `make test` (runs `uv run pytest`) +- **Single test**: `uv run pytest tests/test_addresses.py::TestParseAddress::test_xd_basic -v` +- **Lint**: `make lint` (runs `uv run devtools/lint.py` — codespell, ruff check --fix, ruff format, ty check) +- **Build**: `make build` (runs `uv build`) + +## Architecture + +### Two-Layer Design + +PLC knowledge and Modbus protocol are separated into two layers linked by bank name: + +1. **`banks.py`** (foundation, zero deps) — `BankConfig`, `BANKS` dict, `DataType` enum, sparse `valid_ranges` for X/Y hardware slots, address validation +2. **`modbus.py`** (depends on banks) — `ModbusMapping`, `MODBUS_MAPPINGS` dict, forward/reverse address mapping, register pack/unpack + +### Address System + +All address parsing flows through `parse_address()` in `addresses.py`, which returns `(memory_type, mdb_address)` — MDB-style indices for all banks. XD/YD use contiguous MDB indices 0-16; display addresses 0-8 map via `xd_yd_display_to_mdb()`. The `format_address_display()` function handles the reverse. These two functions are the canonical formatting/parsing entry points used by client, server, nicknames, and dataview modules. + +### Dependency Graph + +``` +banks.py ← no deps (foundation) + ↑ + ├── validation.py + ├── addresses.py + │ ↑ + │ ├── blocks.py + │ ├── dataview.py + │ ├── modbus.py + │ │ ↑ + │ │ ├── client.py ──→ nicknames.py + │ │ └── server.py + │ └── nicknames.py ──→ blocks.py, validation.py +``` + +### Key Modules + +- **`client.py`** — `ClickClient` (async Modbus TCP client using pymodbus) with `AddressAccessor`, `AddressInterface`, `TagInterface` +- **`server.py`** — `ClickServer` (async Modbus TCP server) with `DataProvider` protocol and `MemoryDataProvider` +- **`nicknames.py`** — Read/write CLICK nickname CSV files +- **`dataview.py`** — Read/write DataView CDV files (UTF-16 LE CSV format) +- **`blocks.py`** — BlockTag parsing and computation + +### Modbus Mapping + +`plc_to_modbus(bank, index)` uses `base + index` for 0-based banks (XD/YD) and `base + width * (index - 1)` for 1-based banks. X/Y are sparse coil banks with slot-based offset formulas. `modbus_to_plc()` reverses the mapping. + +## Conventions + +- `DataType` enum is the single source of truth for PLC types; Modbus properties derive from it +- All dataclasses use `frozen=True` +- Tests in `tests/` mirror source modules (e.g., `test_addresses.py` tests `addresses.py`) +- pytest discovers tests from both `src/` and `tests/` directories (`python_files = ["*.py"]`) +- `asyncio_mode = "auto"` — async tests need no decorator +- Package uses src-layout: source is in `src/pyclickplc/` +- Python 3.11+ required; CI tests 3.11-3.14 +- Ruff line-length is 100 diff --git a/README.md b/README.md index 4f6d5ba..51fbf38 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,171 @@ -# Pyrung +# pyclickplc -Utilities to work with AutomationDirect CLICK Plcs +Utilities for AutomationDirect CLICK PLCs — address parsing, Modbus TCP client/server, nickname CSV and DataView CDV file I/O, and BlockTag comment parsing. -## Status +## Installation -PLANNING ONLY * INITIAL COMMIT +```bash +pip install pyclickplc +``` -## Goals +Requires Python 3.11+. The Modbus client and server depend on [pymodbus](https://github.com/pymodbus-dev/pymodbus). -- Provide shared utility library for reading/writing Nickname csv files, -Dataview .cdv file, communicating via Modbus, and parsing the BlockTag comment specification +## Modbus Client + +`ClickClient` is an async Modbus TCP driver with three access patterns: bank accessors, address strings, and tag nicknames. + +```python +import asyncio +from pyclickplc import ClickClient + +async def main(): + async with ClickClient("192.168.1.10") as plc: + # Bank accessors — read/write by bank and index + value = await plc.ds.read(1) # Read DS1 + await plc.ds.write(1, 100) # Write 100 to DS1 + values = await plc.ds.read(1, 10) # Read DS1-DS10 (returns dict) + await plc.y.write(1, [True, False]) # Write Y001=True, Y002=False + + # Address interface — read/write by address string + value = await plc.addr.read("df1") # Read DF1 + await plc.addr.write("df1", 3.14) # Write 3.14 to DF1 + values = await plc.addr.read("c1-c10") # Read C1-C10 range + + # Tag interface — read/write by nickname (requires CSV file) + plc_with_tags = ClickClient("192.168.1.10", tag_filepath="nicknames.csv") + # ... use as context manager, then: + # value = await plc_with_tags.tag.read("MyTag") + # await plc_with_tags.tag.write("MyTag", 42) + # all_tags = await plc_with_tags.tag.read() # Read all tags + +asyncio.run(main()) +``` + +Supported banks: `X`, `Y`, `C`, `T`, `CT`, `SC`, `DS`, `DD`, `DH`, `DF`, `XD`, `YD`, `TD`, `CTD`, `SD`, `TXT`. + +## Modbus Server + +`ClickServer` simulates a CLICK PLC over Modbus TCP. Supply a `DataProvider` to back the address space. + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider + +async def main(): + provider = MemoryDataProvider() + provider.set("DS1", 42) + provider.set("Y001", True) + + async with ClickServer(provider, host="localhost", port=5020) as server: + # Server is now accepting Modbus TCP connections + await asyncio.sleep(60) + +asyncio.run(main()) +``` + +Implement the `DataProvider` protocol for custom backends: + +```python +from pyclickplc.server import DataProvider, PlcValue + +class MyProvider: + def read(self, address: str) -> PlcValue: ... + def write(self, address: str, value: PlcValue) -> None: ... +``` + +## Nickname CSV Files + +Read and write CLICK software nickname CSV files. + +```python +from pyclickplc import read_csv, write_csv + +# Read — returns dict[addr_key, AddressRecord] +records = read_csv("nicknames.csv") +for key, record in records.items(): + print(record.display_address, record.nickname, record.comment) + +# Write — only records with content are written +count = write_csv("output.csv", records) +``` + +MDB-format CSV files (exported by CLICK software) are also supported via `read_mdb_csv()`. + +## DataView CDV Files + +Read and write CLICK DataView `.cdv` files (UTF-16 LE format). + +```python +from pyclickplc import load_cdv, save_cdv + +# Load — returns (rows, has_new_values, header) +rows, has_new_values, header = load_cdv("dataview.cdv") +for row in rows: + if not row.is_empty: + print(row.address, row.type_code, row.new_value) + +# Save +save_cdv("output.cdv", rows, has_new_values, header) +``` + +## Address Parsing + +Parse and format PLC address strings. + +```python +from pyclickplc import parse_address, format_address_display, normalize_address + +bank, index = parse_address("DS100") # ("DS", 100) +bank, index = parse_address("X001") # ("X", 1) +bank, index = parse_address("XD0u") # ("XD", 1) — MDB index + +display = format_address_display("X", 1) # "X001" +display = format_address_display("XD", 1) # "XD0u" + +normalized = normalize_address("x1") # "X001" +``` + +## Modbus Mapping + +Map between PLC addresses and raw Modbus coil/register addresses. + +```python +from pyclickplc import plc_to_modbus, modbus_to_plc, pack_value, unpack_value +from pyclickplc import DataType + +# PLC address → Modbus address +modbus_addr, reg_count = plc_to_modbus("DS", 1) # (0, 1) +modbus_addr, reg_count = plc_to_modbus("DF", 1) # (28672, 2) + +# Modbus address → PLC address +result = modbus_to_plc(0, is_coil=False) # ("DS", 1) +result = modbus_to_plc(0, is_coil=True) # ("X", 1) + +# Pack/unpack values for Modbus registers +regs = pack_value(3.14, DataType.FLOAT) # [low_word, high_word] +value = unpack_value(regs, DataType.FLOAT) # 3.14 +``` + +## Bank Definitions + +All 16 CLICK PLC memory banks are defined in `BANKS`: + +```python +from pyclickplc import BANKS, DataType + +ds = BANKS["DS"] +print(ds.min_addr, ds.max_addr, ds.data_type) # 1, 4500, DataType.INT + +# Sparse banks (X/Y) have valid_ranges for hardware slot validation +x = BANKS["X"] +print(x.valid_ranges) # ((1, 16), (21, 36), (101, 116), ...) +``` + +## Development + +```bash +uv sync --all-extras --dev # Install dependencies +make test # Run tests (uv run pytest) +make lint # Lint (codespell, ruff, ty) +make # All of the above +``` From b4619036efbbd394d91863115c1e9e7f5f0c6812 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:35:38 -0500 Subject: [PATCH 13/55] copy: clarify return values of ClickClient --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 51fbf38..a2531de 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Utilities for AutomationDirect CLICK PLCs — address parsing, Modbus TCP client ## Installation ```bash +uv install pyclickplc pip install pyclickplc ``` @@ -21,26 +22,28 @@ from pyclickplc import ClickClient async def main(): async with ClickClient("192.168.1.10") as plc: # Bank accessors — read/write by bank and index - value = await plc.ds.read(1) # Read DS1 + value = await plc.ds.read(1) # Read DS1 → single value (int) await plc.ds.write(1, 100) # Write 100 to DS1 - values = await plc.ds.read(1, 10) # Read DS1-DS10 (returns dict) + values = await plc.ds.read(1, 10) # Read DS1-DS10 → {"ds1": ..., "ds10": ...} await plc.y.write(1, [True, False]) # Write Y001=True, Y002=False # Address interface — read/write by address string - value = await plc.addr.read("df1") # Read DF1 + value = await plc.addr.read("df1") # Read DF1 → single value (float) await plc.addr.write("df1", 3.14) # Write 3.14 to DF1 - values = await plc.addr.read("c1-c10") # Read C1-C10 range + values = await plc.addr.read("c1-c10") # Read C1-C10 → {"c001": ..., "c010": ...} # Tag interface — read/write by nickname (requires CSV file) plc_with_tags = ClickClient("192.168.1.10", tag_filepath="nicknames.csv") # ... use as context manager, then: - # value = await plc_with_tags.tag.read("MyTag") + # value = await plc_with_tags.tag.read("MyTag") # → single value # await plc_with_tags.tag.write("MyTag", 42) - # all_tags = await plc_with_tags.tag.read() # Read all tags + # all_tags = await plc_with_tags.tag.read() # → {"MyTag": ..., ...} asyncio.run(main()) ``` +**Return types:** Single reads return a bare value (`bool`, `int`, `float`, or `str` depending on bank type). Range reads and tag read-all return a `dict` keyed by lowercase address string (e.g. `"ds1"`, `"x001"`) or tag name. Dicts from multiple reads can be combined into a single PLC state snapshot. + Supported banks: `X`, `Y`, `C`, `T`, `CT`, `SC`, `DS`, `DD`, `DH`, `DF`, `XD`, `YD`, `TD`, `CTD`, `SD`, `TXT`. ## Modbus Server From 185a9cc73fc63c96bc794dfae08c99234938abd7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:46:39 -0500 Subject: [PATCH 14/55] fix: correct starting T address --- tests/test_modbus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_modbus.py b/tests/test_modbus.py index c15852e..084cc1e 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -159,7 +159,7 @@ def test_c2000(self): assert plc_to_modbus("C", 2000) == (18383, 1) def test_t1(self): - assert plc_to_modbus("T", 1) == (45057, 1) + assert plc_to_modbus("T", 1) == (45056, 1) def test_ct1(self): assert plc_to_modbus("CT", 1) == (49152, 1) @@ -327,7 +327,7 @@ def test_coil_18383_c2000(self): assert modbus_to_plc(18383, is_coil=True) == ("C", 2000) def test_coil_45057_t1(self): - assert modbus_to_plc(45057, is_coil=True) == ("T", 1) + assert modbus_to_plc(45056, is_coil=True) == ("T", 1) def test_coil_49152_ct1(self): assert modbus_to_plc(49152, is_coil=True) == ("CT", 1) From 9ae712376f9281d04c5700302600aacbe24019f9 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:03:08 -0500 Subject: [PATCH 15/55] feat: display_to_datatype for Txt returns a string --- src/pyclickplc/dataview.py | 28 +++++++++++++++++----------- tests/test_dataview.py | 18 +++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index acd07be..4c670eb 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -238,7 +238,7 @@ def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataviewRow]: # The display layer handles presentation (hex formatting, float precision, etc.). -def storage_to_datatype(value: str, type_code: int) -> int | float | bool | None: +def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str | None: """Convert a CDV storage string to its native Python type. Args: @@ -246,8 +246,8 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | None type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) Returns: - Native Python value (bool for BIT, int for INT/INT2/HEX/TXT, - float for FLOAT), or None if empty/invalid. + Native Python value (bool for BIT, int for INT/INT2/HEX, + float for FLOAT, str for TXT), or None if empty/invalid. """ if not value: return None @@ -281,7 +281,8 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | None return struct.unpack(">f", bytes_val)[0] elif type_code == TypeCode.TXT: - return int(value) + code = int(value) + return chr(code) if 0 < code < 128 else "" else: return None @@ -290,7 +291,7 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | None return None -def datatype_to_storage(value: int | float | bool | None, type_code: int) -> str: +def datatype_to_storage(value: int | float | bool | str | None, type_code: int) -> str: """Convert a native Python value to CDV storage format. Args: @@ -334,6 +335,8 @@ def datatype_to_storage(value: int | float | bool | None, type_code: int) -> str return str(int_val) elif type_code == TypeCode.TXT: + if isinstance(value, str): + return str(ord(value)) if value else "0" return str(int(value)) else: @@ -343,7 +346,7 @@ def datatype_to_storage(value: int | float | bool | None, type_code: int) -> str return "" -def datatype_to_display(value: int | float | bool | None, type_code: int) -> str: +def datatype_to_display(value: int | float | bool | str | None, type_code: int) -> str: """Convert a native Python value to a UI-friendly display string. Args: @@ -370,6 +373,8 @@ def datatype_to_display(value: int | float | bool | None, type_code: int) -> str return f"{float(value):.7G}" elif type_code == TypeCode.TXT: + if isinstance(value, str): + return value if value else "" code = int(value) if 32 <= code <= 126: return chr(code) @@ -382,7 +387,7 @@ def datatype_to_display(value: int | float | bool | None, type_code: int) -> str return "" -def display_to_datatype(value: str, type_code: int) -> int | float | bool | None: +def display_to_datatype(value: str, type_code: int) -> int | float | bool | str | None: """Convert a UI display string to its native Python type. Args: @@ -390,8 +395,8 @@ def display_to_datatype(value: str, type_code: int) -> int | float | bool | None type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) Returns: - Native Python value (bool for BIT, int for INT/INT2/HEX/TXT, - float for FLOAT), or None if empty/invalid. + Native Python value (bool for BIT, int for INT/INT2/HEX, + float for FLOAT, str for TXT), or None if empty/invalid. """ if not value: return None @@ -414,8 +419,9 @@ def display_to_datatype(value: str, type_code: int) -> int | float | bool | None elif type_code == TypeCode.TXT: if len(value) == 1: - return ord(value) - return int(value) + return value + code = int(value) + return chr(code) if 0 < code < 128 else "" else: return None diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 9cc180c..5a96eab 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -218,10 +218,10 @@ def test_float_values(self): assert val < 0 def test_txt_values(self): - assert storage_to_datatype("48", TypeCode.TXT) == 48 - assert storage_to_datatype("65", TypeCode.TXT) == 65 - assert storage_to_datatype("90", TypeCode.TXT) == 90 - assert storage_to_datatype("32", TypeCode.TXT) == 32 + assert storage_to_datatype("48", TypeCode.TXT) == "0" + assert storage_to_datatype("65", TypeCode.TXT) == "A" + assert storage_to_datatype("90", TypeCode.TXT) == "Z" + assert storage_to_datatype("32", TypeCode.TXT) == " " def test_empty_value(self): assert storage_to_datatype("", TypeCode.INT) is None @@ -362,13 +362,13 @@ def test_float_values(self): assert display_to_datatype("-1.0", TypeCode.FLOAT) == -1.0 def test_txt_char(self): - assert display_to_datatype("A", TypeCode.TXT) == 65 - assert display_to_datatype("Z", TypeCode.TXT) == 90 - assert display_to_datatype("0", TypeCode.TXT) == 48 - assert display_to_datatype(" ", TypeCode.TXT) == 32 + assert display_to_datatype("A", TypeCode.TXT) == "A" + assert display_to_datatype("Z", TypeCode.TXT) == "Z" + assert display_to_datatype("0", TypeCode.TXT) == "0" + assert display_to_datatype(" ", TypeCode.TXT) == " " def test_txt_numeric(self): - assert display_to_datatype("65", TypeCode.TXT) == 65 + assert display_to_datatype("65", TypeCode.TXT) == "A" def test_empty_value(self): assert display_to_datatype("", TypeCode.INT) is None From 500439db79c47c48755e03adf32d532994048b5f Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:06:00 -0500 Subject: [PATCH 16/55] Create HANDOFF.md --- spec/HANDOFF.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 spec/HANDOFF.md diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md new file mode 100644 index 0000000..7c1805a --- /dev/null +++ b/spec/HANDOFF.md @@ -0,0 +1,76 @@ +# Rich PLC Value Types — Design Handoff + +## Problem + +Client reads return bare Python types (`int`, `float`, `bool`, `str`). For HEX and FLOAT especially, the raw value isn't what users want to see: + +- `plc.dh.read(1)` → `255` (user expects to see `"00FF"`) +- `plc.df.read(1)` → `3.140000104904175` (user expects `"3.14"`) + +The dataview layer has formatting functions (`datatype_to_display`, etc.) but they require manual type code lookups. There's no unified way to get display-friendly values. + +## Proposal + +Introduce rich value types that subclass Python builtins. They behave exactly like their base types for math/comparisons but override `__str__` to show PLC display format. + +```python +val = await plc.dh.read(1) +val + 1 # 256 — math works, it IS an int +print(val) # 00FF — display-friendly by default +val.raw() # 255 — explicit access to underlying value +f"{val:plc}" # 00FF — optional __format__ protocol support +``` + +### Types needed + +| Type | Base | `str()` example | `.raw()` | +|------|------|----------------|----------| +| PlcBit | bool-like* | `"1"` / `"0"` | `True` / `False` | +| PlcInt | int | `"42"` | `42` | +| PlcInt2 | int | `"42"` | `42` | +| PlcHex | int | `"00FF"` | `255` | +| PlcFloat | float | `"3.14"` | `3.140000104904175` | +| PlcStr | str | `"A"` | `"A"` (same) | + +*PlcBit can't subclass `bool` (it's final in Python). Could subclass `int` with truthy semantics, or just be a small wrapper. + +### Where they get created + +1. **Client** — `AddressAccessor._read_single` and range reads wrap return values +2. **Dataview** — `storage_to_datatype` and `display_to_datatype` return rich types +3. **Server** — `MemoryDataProvider` could accept/return them (or just unwrap on write) + +### Display formatting rules (from existing `datatype_to_display`) + +- BIT: `"1"` / `"0"` +- INT/INT2: `str(int(value))` +- HEX: `format(int(value), "04X")` +- FLOAT: `f"{float(value):.7G}"` +- TXT: the character itself (already `str`) + +### What moves where + +The formatting logic currently in `dataview.py` (`datatype_to_display`, `display_to_datatype`) could either: +- Stay in `dataview.py` and be called by the rich types +- Move into a new `values.py` module alongside the type definitions + +The conversion functions (`storage_to_datatype`, `datatype_to_storage`) stay in `dataview.py` since they're CDV-specific, but return rich types instead of bare values. + +### Dict results + +Range reads return `dict[str, PlcValue]`. Could use a `PlcResult(dict)` subclass with a `.display()` that formats all values, or just let users call `str()` on individual values. + +## Open questions + +- Module placement: new `values.py` or add to existing `banks.py`/`dataview.py`? +- Should `PlcBit` subclass `int` (like Python's `bool` does) or be a custom class? +- Should `.raw()` return the base type (`int(val)`) or just be an alias for clarity? +- How much `__format__` protocol support? Just `:plc` or more (`:hex`, `:dec`)? +- Should `MemoryDataProvider.read()` return rich types or bare values? + +## Status + +- TXT `str` unification is done (all four conversion functions handle `str` for TXT) +- README updated with API docs +- CLAUDE.md created +- 2 pre-existing test failures in `test_modbus.py` (T bank base address mismatch) From bfe7cc15d64eda861b2cb9c462881f57c24629c2 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:59:25 -0500 Subject: [PATCH 17/55] feat: add ModbusResponse mapping with normalized address keys All read() calls now return ModbusResponse (a Mapping) with canonical uppercase keys (DS1, X001) and case-insensitive look-ups. Adds AddressAccessor.__getitem__ for `await plc.ds[1]` bare-value shorthand. Co-Authored-By: Claude Opus 4.6 --- README.md | 11 +- src/pyclickplc/__init__.py | 3 +- src/pyclickplc/client.py | 131 +++++++++++++++----- tests/test_client.py | 239 ++++++++++++++++++++++++++++++++----- tests/test_integration.py | 66 +++++----- 5 files changed, 348 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index a2531de..d94ef14 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,16 @@ from pyclickplc import ClickClient async def main(): async with ClickClient("192.168.1.10") as plc: # Bank accessors — read/write by bank and index - value = await plc.ds.read(1) # Read DS1 → single value (int) + result = await plc.ds.read(1) # Read DS1 → ModbusResponse({"DS1": 42}) + value = await plc.ds[1] # Shorthand → bare value (int) await plc.ds.write(1, 100) # Write 100 to DS1 - values = await plc.ds.read(1, 10) # Read DS1-DS10 → {"ds1": ..., "ds10": ...} + result = await plc.ds.read(1, 10) # Read DS1-DS10 → ModbusResponse await plc.y.write(1, [True, False]) # Write Y001=True, Y002=False # Address interface — read/write by address string - value = await plc.addr.read("df1") # Read DF1 → single value (float) + result = await plc.addr.read("df1") # Read DF1 → ModbusResponse({"DF1": 3.14}) await plc.addr.write("df1", 3.14) # Write 3.14 to DF1 - values = await plc.addr.read("c1-c10") # Read C1-C10 → {"c001": ..., "c010": ...} + result = await plc.addr.read("c1-c10") # Read C1-C10 → ModbusResponse # Tag interface — read/write by nickname (requires CSV file) plc_with_tags = ClickClient("192.168.1.10", tag_filepath="nicknames.csv") @@ -42,7 +43,7 @@ async def main(): asyncio.run(main()) ``` -**Return types:** Single reads return a bare value (`bool`, `int`, `float`, or `str` depending on bank type). Range reads and tag read-all return a `dict` keyed by lowercase address string (e.g. `"ds1"`, `"x001"`) or tag name. Dicts from multiple reads can be combined into a single PLC state snapshot. +**Return types:** All `read()` calls return a `ModbusResponse` — a `Mapping` keyed by canonical uppercase address (`"DS1"`, `"X001"`) with normalized look-ups (`response["ds1"]` finds `"DS1"`). Use `await plc.ds[1]` for a bare value (`bool`, `int`, `float`, or `str`). Tag interface single reads return a bare value; tag read-all returns a plain `dict` keyed by tag name. Supported banks: `X`, `Y`, `C`, `T`, `CT`, `SC`, `DS`, `DD`, `DH`, `DF`, `XD`, `YD`, `TD`, `CTD`, `SD`, `TXT`. diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 25bc437..c6a6c6e 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -41,7 +41,7 @@ strip_block_tag, validate_block_span, ) -from .client import ClickClient +from .client import ClickClient, ModbusResponse from .dataview import ( MAX_DATAVIEW_ROWS, MEMORY_TYPE_TO_CODE, @@ -140,6 +140,7 @@ "validate_block_span", # client "ClickClient", + "ModbusResponse", # modbus "ModbusMapping", "MODBUS_MAPPINGS", diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 5f25d05..c5ce7df 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -5,11 +5,12 @@ from __future__ import annotations -from typing import ClassVar +from collections.abc import Coroutine, Iterator, Mapping +from typing import Any, ClassVar from pymodbus.client import AsyncModbusTcpClient -from .addresses import format_address_display, parse_address +from .addresses import format_address_display, normalize_address, parse_address from .banks import BANKS from .modbus import ( MODBUS_MAPPINGS, @@ -19,6 +20,67 @@ ) from .nicknames import DATA_TYPE_CODE_TO_STR, read_csv +PlcValue = bool | int | float | str + +# ============================================================================== +# ModbusResponse +# ============================================================================== + + +class ModbusResponse(Mapping[str, PlcValue]): + """Immutable mapping with normalized PLC address keys. + + Keys are stored in canonical uppercase form (``DS1``, ``X001``). + Look-ups normalise the key automatically, so ``response["ds1"]`` + and ``response["DS1"]`` both work. + """ + + __slots__ = ("_data",) + + def __init__(self, data: dict[str, PlcValue]) -> None: + self._data = data + + # -- Mapping interface -------------------------------------------------- + + def __getitem__(self, key: str) -> PlcValue: + normalized = normalize_address(key) + if normalized is not None and normalized in self._data: + return self._data[normalized] + raise KeyError(key) + + def __contains__(self, key: object) -> bool: + if not isinstance(key, str): + return False + normalized = normalize_address(key) + return normalized is not None and normalized in self._data + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + # -- Comparison --------------------------------------------------------- + + def __eq__(self, other: object) -> bool: + if isinstance(other, ModbusResponse): + return self._data == other._data + if isinstance(other, dict): + if len(other) != len(self._data): + return False + normalized: dict[str, Any] = {} + for k, v in other.items(): + nk = normalize_address(k) if isinstance(k, str) else None + if nk is None: + return False + normalized[nk] = v + return self._data == normalized + return NotImplemented + + def __repr__(self) -> str: + return f"ModbusResponse({self._data!r})" + + # ============================================================================== # Constants # ============================================================================== @@ -81,7 +143,7 @@ def _load_tags(filepath: str) -> dict[str, dict[str, str]]: def _format_bank_address(bank: str, index: int) -> str: """Format address string for return dicts.""" - return format_address_display(bank, index).lower() + return format_address_display(bank, index) class AddressAccessor: @@ -93,15 +155,19 @@ def __init__(self, plc: ClickClient, bank: str) -> None: self._mapping = MODBUS_MAPPINGS[bank] self._bank_cfg = BANKS[bank] - async def read( - self, start: int, end: int | None = None - ) -> dict[str, bool | int | float | str] | bool | int | float | str: - """Read single value or range (inclusive).""" + async def read(self, start: int, end: int | None = None) -> ModbusResponse: + """Read single value or range (inclusive). + + Always returns a ModbusResponse, even for a single address. + Use ``await plc.ds[1]`` for a bare value. + """ bank = self._bank if end is None: - # Single value - return await self._read_single(start) + # Single value → wrap in ModbusResponse + value = await self._read_single(start) + key = _format_bank_address(bank, start) + return ModbusResponse({key: value}) if end <= start: raise ValueError("End address must be greater than start address.") @@ -113,7 +179,7 @@ async def read( return await self._read_sparse_range(start, end) return await self._read_range(start, end) - async def _read_single(self, index: int) -> bool | int | float | str: + async def _read_single(self, index: int) -> PlcValue: """Read a single PLC address.""" bank = self._bank self._validate_index(index) @@ -130,12 +196,12 @@ async def _read_single(self, index: int) -> bool | int | float | str: regs = await self._plc._read_registers(addr, count, bank) return unpack_value(regs, self._bank_cfg.data_type) - async def _read_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + async def _read_range(self, start: int, end: int) -> ModbusResponse: """Read a contiguous range.""" bank = self._bank self._validate_index(start) self._validate_index(end) - result: dict[str, bool | int | float | str] = {} + result: dict[str, PlcValue] = {} if self._mapping.is_coil: addr_start, _ = plc_to_modbus(bank, start) @@ -158,9 +224,9 @@ async def _read_range(self, start: int, end: int) -> dict[str, bool | int | floa key = _format_bank_address(bank, idx) result[key] = val - return result + return ModbusResponse(result) - async def _read_sparse_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + async def _read_sparse_range(self, start: int, end: int) -> ModbusResponse: """Read a sparse (X/Y) range, skipping gaps.""" bank = self._bank # Enumerate valid addresses in [start, end] @@ -180,14 +246,14 @@ async def _read_sparse_range(self, start: int, end: int) -> dict[str, bool | int count = addr_last - addr_first + 1 bits = await self._plc._read_coils(addr_first, count, bank) - result: dict[str, bool | int | float | str] = {} + result: dict[str, PlcValue] = {} for a in valid_addrs: addr, _ = plc_to_modbus(bank, a) bit_idx = addr - addr_first key = _format_bank_address(bank, a) result[key] = bits[bit_idx] - return result + return ModbusResponse(result) async def _read_txt(self, index: int) -> str: """Read a single TXT address.""" @@ -203,7 +269,7 @@ async def _read_txt(self, index: int) -> str: # Even: high byte return chr((reg_val >> 8) & 0xFF) - async def _read_txt_range(self, start: int, end: int) -> dict[str, bool | int | float | str]: + async def _read_txt_range(self, start: int, end: int) -> ModbusResponse: """Read a range of TXT addresses.""" self._validate_index(start) self._validate_index(end) @@ -215,7 +281,7 @@ async def _read_txt_range(self, start: int, end: int) -> dict[str, bool | int | reg_base + first_reg, last_reg - first_reg + 1, "TXT" ) - result: dict[str, bool | int | float | str] = {} + result: dict[str, PlcValue] = {} for idx in range(start, end + 1): reg_offset = (idx - 1) // 2 - first_reg reg_val = regs[reg_offset] @@ -225,7 +291,7 @@ async def _read_txt_range(self, start: int, end: int) -> dict[str, bool | int | ch = chr((reg_val >> 8) & 0xFF) result[_format_bank_address("TXT", idx)] = ch - return result + return ModbusResponse(result) async def write( self, @@ -382,6 +448,12 @@ def _validate_type(self, value: bool | int | float | str) -> None: def __repr__(self) -> str: return f"" + def __getitem__(self, key: int) -> Coroutine[Any, Any, PlcValue]: + """Enable ``await plc.ds[1]`` syntax for single-value reads.""" + if isinstance(key, slice): + raise TypeError("Slicing is not supported. Use read(start, end) for range reads.") + return self._read_single(key) + # ============================================================================== # AddressInterface @@ -394,9 +466,7 @@ class AddressInterface: def __init__(self, plc: ClickClient) -> None: self._plc = plc - async def read( - self, address: str - ) -> dict[str, bool | int | float | str] | bool | int | float | str: + async def read(self, address: str) -> ModbusResponse: """Read by address string. Supports 'df1' or 'df1-df10'.""" if "-" in address: parts = address.split("-", 1) @@ -435,9 +505,7 @@ class TagInterface: def __init__(self, plc: ClickClient) -> None: self._plc = plc - async def read( - self, tag_name: str | None = None - ) -> dict[str, bool | int | float | str] | bool | int | float | str: + async def read(self, tag_name: str | None = None) -> dict[str, PlcValue] | PlcValue: """Read single tag or all tags.""" tags = self._plc.tags if tag_name is not None: @@ -445,17 +513,18 @@ async def read( available = list(tags.keys())[:5] raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") tag_info = tags[tag_name] - return await self._plc.addr.read(tag_info["address"]) + resp = await self._plc.addr.read(tag_info["address"]) + # Single-address read → extract the lone value + return next(iter(resp.values())) if not tags: raise ValueError("No tags loaded. Provide a tag file or specify a tag name.") - result: dict[str, bool | int | float | str] = {} + all_tags: dict[str, PlcValue] = {} for name, info in tags.items(): - val = await self._plc.addr.read(info["address"]) - assert not isinstance(val, dict) - result[name] = val - return result + resp = await self._plc.addr.read(info["address"]) + all_tags[name] = next(iter(resp.values())) + return all_tags async def write( self, diff --git a/tests/test_client.py b/tests/test_client.py index c415ea1..e2f28b9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -14,6 +14,7 @@ AddressAccessor, AddressInterface, ClickClient, + ModbusResponse, TagInterface, ) from pyclickplc.modbus import MODBUS_MAPPINGS, pack_value @@ -77,13 +78,13 @@ async def test_getattr_cached(self): async def test_getattr_underscore_raises(self): plc = _make_plc() with pytest.raises(AttributeError): - plc._private + _ = plc._private @pytest.mark.asyncio async def test_getattr_unknown_raises(self): plc = _make_plc() with pytest.raises(AttributeError, match="not a supported"): - plc.invalid_bank + _ = plc.invalid_bank @pytest.mark.asyncio async def test_addr_is_address_interface(self): @@ -129,61 +130,62 @@ async def test_read_float(self): plc = _make_plc() regs = pack_value(3.14, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=regs) - value = await plc.df.read(1) + result = await plc.df.read(1) + assert isinstance(result, ModbusResponse) import math - assert math.isclose(value, 3.14, rel_tol=1e-6) + assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_int16(self): plc = _make_plc() plc._read_registers = AsyncMock(return_value=[42]) - value = await plc.ds.read(1) - assert value == 42 + result = await plc.ds.read(1) + assert result == {"DS1": 42} @pytest.mark.asyncio async def test_read_int32(self): plc = _make_plc() regs = pack_value(100000, DataType.INT2) plc._read_registers = AsyncMock(return_value=regs) - value = await plc.dd.read(1) - assert value == 100000 + result = await plc.dd.read(1) + assert result == {"DD1": 100000} @pytest.mark.asyncio async def test_read_unsigned(self): plc = _make_plc() plc._read_registers = AsyncMock(return_value=[0xABCD]) - value = await plc.dh.read(1) - assert value == 0xABCD + result = await plc.dh.read(1) + assert result == {"DH1": 0xABCD} @pytest.mark.asyncio async def test_read_bool(self): plc = _make_plc() plc._read_coils = AsyncMock(return_value=[True]) - value = await plc.c.read(1) - assert value is True + result = await plc.c.read(1) + assert result == {"C1": True} @pytest.mark.asyncio async def test_read_sparse_bool(self): plc = _make_plc() plc._read_coils = AsyncMock(return_value=[True]) - value = await plc.x.read(101) - assert value is True + result = await plc.x.read(101) + assert result == {"X101": True} @pytest.mark.asyncio async def test_read_txt(self): plc = _make_plc() # TXT1 is low byte of register plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) - value = await plc.txt.read(1) - assert value == "A" + result = await plc.txt.read(1) + assert result == {"TXT1": "A"} @pytest.mark.asyncio async def test_read_txt_even(self): plc = _make_plc() plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) - value = await plc.txt.read(2) - assert value == "B" + result = await plc.txt.read(2) + assert result == {"TXT2": "B"} # ============================================================================== @@ -199,19 +201,19 @@ async def test_read_df_range(self): r2 = pack_value(2.0, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=r1 + r2) result = await plc.df.read(1, 2) - assert isinstance(result, dict) + assert isinstance(result, ModbusResponse) assert len(result) == 2 import math - assert math.isclose(result["df1"], 1.0, rel_tol=1e-6) - assert math.isclose(result["df2"], 2.0, rel_tol=1e-6) + assert math.isclose(result["DF1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["DF2"], 2.0, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_c_range(self): plc = _make_plc() plc._read_coils = AsyncMock(return_value=[True, False, True]) result = await plc.c.read(1, 3) - assert result == {"c1": True, "c2": False, "c3": True} + assert result == {"C1": True, "C2": False, "C3": True} @pytest.mark.asyncio async def test_read_end_le_start_raises(self): @@ -329,8 +331,8 @@ async def test_read_max_df(self): plc = _make_plc() regs = pack_value(0.0, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=regs) - value = await plc.df.read(500) - assert value == 0.0 + result = await plc.df.read(500) + assert result == {"DF500": 0.0} # ============================================================================== @@ -344,10 +346,11 @@ async def test_read_single(self): plc = _make_plc() regs = pack_value(3.14, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=regs) - value = await plc.addr.read("df1") + result = await plc.addr.read("df1") + assert isinstance(result, ModbusResponse) import math - assert math.isclose(value, 3.14, rel_tol=1e-6) + assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_range(self): @@ -356,7 +359,7 @@ async def test_read_range(self): r2 = pack_value(2.0, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=r1 + r2) result = await plc.addr.read("df1-df2") - assert isinstance(result, dict) + assert isinstance(result, ModbusResponse) assert len(result) == 2 @pytest.mark.asyncio @@ -364,8 +367,8 @@ async def test_read_case_insensitive(self): plc = _make_plc() regs = pack_value(0.0, DataType.FLOAT) plc._read_registers = AsyncMock(return_value=regs) - value = await plc.addr.read("DF1") - assert value == 0.0 + result = await plc.addr.read("DF1") + assert result == {"DF1": 0.0} @pytest.mark.asyncio async def test_inter_bank_range_raises(self): @@ -488,9 +491,7 @@ async def test_write_empty_string_clears_txt(self): plc._read_registers = AsyncMock(return_value=[0x4142]) # "AB" await plc.txt.write(1, "") # Empty string → null byte in low position, high byte preserved - plc._write_registers.assert_called_once_with( - MODBUS_MAPPINGS["TXT"].base, [0x4100] - ) + plc._write_registers.assert_called_once_with(MODBUS_MAPPINGS["TXT"].base, [0x4100]) @pytest.mark.asyncio async def test_write_txt_list(self): @@ -498,3 +499,177 @@ async def test_write_txt_list(self): plc._read_registers = AsyncMock(return_value=[0]) await plc.txt.write(1, ["H", "i"]) assert plc._write_registers.call_count == 2 + + +# ============================================================================== +# ModbusResponse +# ============================================================================== + + +class TestModbusResponse: + def _sample(self) -> ModbusResponse: + return ModbusResponse({"DS1": 10, "DS2": 20, "DS3": 30}) + + # -- __getitem__ -------------------------------------------------------- + + def test_getitem_exact_key(self): + r = self._sample() + assert r["DS1"] == 10 + + def test_getitem_normalized_key(self): + r = self._sample() + assert r["ds1"] == 10 + + def test_getitem_missing_raises(self): + r = self._sample() + with pytest.raises(KeyError): + r["DS999"] + + def test_getitem_invalid_raises(self): + r = self._sample() + with pytest.raises(KeyError): + r["invalid"] + + # -- __contains__ ------------------------------------------------------- + + def test_contains_exact(self): + r = self._sample() + assert "DS1" in r + + def test_contains_normalized(self): + r = self._sample() + assert "ds2" in r + + def test_not_contains(self): + r = self._sample() + assert "DS999" not in r + + def test_contains_non_string(self): + r = self._sample() + assert 42 not in r + + # -- __len__ / __iter__ ------------------------------------------------- + + def test_len(self): + r = self._sample() + assert len(r) == 3 + + def test_iter(self): + r = self._sample() + assert list(r) == ["DS1", "DS2", "DS3"] + + # -- Mapping methods (inherited) ---------------------------------------- + + def test_keys(self): + r = self._sample() + assert list(r.keys()) == ["DS1", "DS2", "DS3"] + + def test_values(self): + r = self._sample() + assert list(r.values()) == [10, 20, 30] + + def test_items(self): + r = self._sample() + assert list(r.items()) == [("DS1", 10), ("DS2", 20), ("DS3", 30)] + + def test_get_existing(self): + r = self._sample() + assert r.get("DS1") == 10 + + def test_get_normalized(self): + r = self._sample() + assert r.get("ds3") == 30 + + def test_get_missing_default(self): + r = self._sample() + assert r.get("DS999", -1) == -1 + + def test_get_missing_none(self): + r = self._sample() + assert r.get("DS999") is None + + # -- __eq__ ------------------------------------------------------------- + + def test_eq_modbus_response(self): + a = ModbusResponse({"DS1": 10, "DS2": 20}) + b = ModbusResponse({"DS1": 10, "DS2": 20}) + assert a == b + + def test_eq_modbus_response_mismatch(self): + a = ModbusResponse({"DS1": 10}) + b = ModbusResponse({"DS1": 99}) + assert a != b + + def test_eq_dict_uppercase(self): + r = ModbusResponse({"DS1": 10, "DS2": 20}) + assert r == {"DS1": 10, "DS2": 20} + + def test_eq_dict_normalized(self): + r = ModbusResponse({"DS1": 10, "DS2": 20}) + assert r == {"ds1": 10, "ds2": 20} + + def test_eq_dict_length_mismatch(self): + r = ModbusResponse({"DS1": 10}) + assert r != {"DS1": 10, "DS2": 20} + + def test_eq_other_type(self): + r = self._sample() + assert r != 42 + + # -- isinstance checks -------------------------------------------------- + + def test_not_dict_instance(self): + from collections.abc import Mapping + + r = self._sample() + assert not isinstance(r, dict) + assert isinstance(r, Mapping) + + # -- __repr__ ----------------------------------------------------------- + + def test_repr(self): + r = ModbusResponse({"DS1": 10}) + assert repr(r) == "ModbusResponse({'DS1': 10})" + + +# ============================================================================== +# AddressAccessor.__getitem__ +# ============================================================================== + + +class TestAddressAccessorGetitem: + @pytest.mark.asyncio + async def test_getitem_int(self): + plc = _make_plc() + plc._read_registers = AsyncMock(return_value=[42]) + value = await plc.ds[1] + assert value == 42 + + @pytest.mark.asyncio + async def test_getitem_float(self): + plc = _make_plc() + regs = pack_value(3.14, DataType.FLOAT) + plc._read_registers = AsyncMock(return_value=regs) + value = await plc.df[1] + import math + + assert math.isclose(value, 3.14, rel_tol=1e-6) + + @pytest.mark.asyncio + async def test_getitem_bool(self): + plc = _make_plc() + plc._read_coils = AsyncMock(return_value=[True]) + value = await plc.c[1] + assert value is True + + @pytest.mark.asyncio + async def test_getitem_slice_raises(self): + plc = _make_plc() + with pytest.raises(TypeError, match="Slicing is not supported"): + plc.ds[1:5] + + @pytest.mark.asyncio + async def test_getitem_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.df[0] diff --git a/tests/test_integration.py b/tests/test_integration.py index bf9c989..f147307 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -39,45 +39,45 @@ class TestRoundTrips: async def test_float_round_trip(self, plc_fixture): plc, provider = plc_fixture await plc.df.write(1, 3.14) - value = await plc.df.read(1) - assert math.isclose(value, 3.14, rel_tol=1e-6) + result = await plc.df.read(1) + assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) assert math.isclose(provider.get("DF1"), 3.14, rel_tol=1e-6) @pytest.mark.asyncio async def test_int32_round_trip(self, plc_fixture): plc, provider = plc_fixture await plc.dd.write(1, 100000) - value = await plc.dd.read(1) - assert value == 100000 + result = await plc.dd.read(1) + assert result == {"DD1": 100000} assert provider.get("DD1") == 100000 @pytest.mark.asyncio async def test_int16_signed_positive(self, plc_fixture): plc, provider = plc_fixture await plc.ds.write(1, 1234) - value = await plc.ds.read(1) - assert value == 1234 + result = await plc.ds.read(1) + assert result == {"DS1": 1234} @pytest.mark.asyncio async def test_int16_signed_negative(self, plc_fixture): plc, provider = plc_fixture await plc.ds.write(1, -1234) - value = await plc.ds.read(1) - assert value == -1234 + result = await plc.ds.read(1) + assert result == {"DS1": -1234} @pytest.mark.asyncio async def test_int16_unsigned_dh(self, plc_fixture): plc, provider = plc_fixture await plc.dh.write(1, 0xABCD) - value = await plc.dh.read(1) - assert value == 0xABCD + result = await plc.dh.read(1) + assert result == {"DH1": 0xABCD} @pytest.mark.asyncio async def test_bool_round_trip(self, plc_fixture): plc, provider = plc_fixture await plc.c.write(1, True) - value = await plc.c.read(1) - assert value is True + result = await plc.c.read(1) + assert result == {"C1": True} assert provider.get("C1") is True @pytest.mark.asyncio @@ -85,15 +85,15 @@ async def test_bool_false(self, plc_fixture): plc, provider = plc_fixture await plc.c.write(1, True) await plc.c.write(1, False) - value = await plc.c.read(1) - assert value is False + result = await plc.c.read(1) + assert result == {"C1": False} @pytest.mark.asyncio async def test_text_round_trip(self, plc_fixture): plc, provider = plc_fixture await plc.txt.write(1, "A") - value = await plc.txt.read(1) - assert value == "A" + result = await plc.txt.read(1) + assert result == {"TXT1": "A"} # ============================================================================== @@ -106,22 +106,22 @@ class TestProviderToDriver: async def test_provider_set_driver_reads(self, plc_fixture): plc, provider = plc_fixture provider.set("DF1", 99.5) - value = await plc.df.read(1) - assert math.isclose(value, 99.5, rel_tol=1e-6) + result = await plc.df.read(1) + assert math.isclose(result["DF1"], 99.5, rel_tol=1e-6) @pytest.mark.asyncio async def test_provider_set_bool(self, plc_fixture): plc, provider = plc_fixture provider.set("C1", True) - value = await plc.c.read(1) - assert value is True + result = await plc.c.read(1) + assert result == {"C1": True} @pytest.mark.asyncio async def test_provider_set_ds(self, plc_fixture): plc, provider = plc_fixture provider.set("DS1", -42) - value = await plc.ds.read(1) - assert value == -42 + result = await plc.ds.read(1) + assert result == {"DS1": -42} # ============================================================================== @@ -138,9 +138,9 @@ async def test_read_df_range(self, plc_fixture): provider.set("DF3", 3.0) result = await plc.df.read(1, 3) assert len(result) == 3 - assert math.isclose(result["df1"], 1.0, rel_tol=1e-6) - assert math.isclose(result["df2"], 2.0, rel_tol=1e-6) - assert math.isclose(result["df3"], 3.0, rel_tol=1e-6) + assert math.isclose(result["DF1"], 1.0, rel_tol=1e-6) + assert math.isclose(result["DF2"], 2.0, rel_tol=1e-6) + assert math.isclose(result["DF3"], 3.0, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_ds_range(self, plc_fixture): @@ -149,8 +149,8 @@ async def test_read_ds_range(self, plc_fixture): provider.set(f"DS{i}", i * 10) result = await plc.ds.read(1, 5) assert len(result) == 5 - assert result["ds1"] == 10 - assert result["ds5"] == 50 + assert result["DS1"] == 10 + assert result["DS5"] == 50 # ============================================================================== @@ -211,19 +211,19 @@ class TestSparseCoils: async def test_y_write_read(self, plc_fixture): plc, provider = plc_fixture await plc.y.write(1, True) - value = await plc.y.read(1) - assert value is True + result = await plc.y.read(1) + assert result == {"Y001": True} @pytest.mark.asyncio async def test_x_read_from_provider(self, plc_fixture): plc, provider = plc_fixture provider.set("X001", True) - value = await plc.x.read(1) - assert value is True + result = await plc.x.read(1) + assert result == {"X001": True} @pytest.mark.asyncio async def test_x_expansion_slot(self, plc_fixture): plc, provider = plc_fixture provider.set("X101", True) - value = await plc.x.read(101) - assert value is True + result = await plc.x.read(101) + assert result == {"X101": True} From c5ba2a7a4936150576d26d94a7d74959da44f843 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:30:33 -0500 Subject: [PATCH 18/55] refactor(dataview)!: make CDV type codes private via _CdvStorageCode `TypeCode` was only used for CDV storage encoding/decoding and did not need to remain public. - Renamed `TypeCode` to `_CdvStorageCode` in `src/pyclickplc/dataview.py` - Updated all internal CDV conversion/mapping references and docstrings - Removed `TypeCode` from `src/pyclickplc/__init__.py` imports and `__all__` - Updated dataview/value tests to use `_CdvStorageCode` (with local alias for readability) - Verified with `make test` (`668 passed`) BREAKING CHANGE: `pyclickplc.TypeCode` and `pyclickplc.dataview.TypeCode` are no longer available; CDV storage codes are now internal. --- src/pyclickplc/__init__.py | 2 - src/pyclickplc/dataview.py | 98 +++++++++++++++++++------------------- tests/test_dataview.py | 4 +- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index c6a6c6e..a89f33a 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -48,7 +48,6 @@ WRITABLE_SC, WRITABLE_SD, DataviewRow, - TypeCode, create_empty_dataview, datatype_to_display, datatype_to_storage, @@ -156,7 +155,6 @@ "ClickServer", "MemoryDataProvider", # dataview - "TypeCode", "DataviewRow", "MEMORY_TYPE_TO_CODE", "WRITABLE_SC", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 4c670eb..83ac702 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -15,7 +15,7 @@ # Type codes used in CDV files to identify address types -class TypeCode: +class _CdvStorageCode: """Type codes for CDV file format.""" BIT = 768 @@ -28,32 +28,32 @@ class TypeCode: # Map memory type prefixes to their type codes MEMORY_TYPE_TO_CODE: dict[str, int] = { - "X": TypeCode.BIT, - "Y": TypeCode.BIT, - "C": TypeCode.BIT, - "T": TypeCode.BIT, - "CT": TypeCode.BIT, - "SC": TypeCode.BIT, - "DS": TypeCode.INT, - "TD": TypeCode.INT, - "SD": TypeCode.INT, - "DD": TypeCode.INT2, - "CTD": TypeCode.INT2, - "DH": TypeCode.HEX, - "XD": TypeCode.HEX, - "YD": TypeCode.HEX, - "DF": TypeCode.FLOAT, - "TXT": TypeCode.TXT, + "X": _CdvStorageCode.BIT, + "Y": _CdvStorageCode.BIT, + "C": _CdvStorageCode.BIT, + "T": _CdvStorageCode.BIT, + "CT": _CdvStorageCode.BIT, + "SC": _CdvStorageCode.BIT, + "DS": _CdvStorageCode.INT, + "TD": _CdvStorageCode.INT, + "SD": _CdvStorageCode.INT, + "DD": _CdvStorageCode.INT2, + "CTD": _CdvStorageCode.INT2, + "DH": _CdvStorageCode.HEX, + "XD": _CdvStorageCode.HEX, + "YD": _CdvStorageCode.HEX, + "DF": _CdvStorageCode.FLOAT, + "TXT": _CdvStorageCode.TXT, } # Reverse mapping: type code to list of memory types CODE_TO_MEMORY_TYPES: dict[int, list[str]] = { - TypeCode.BIT: ["X", "Y", "C", "T", "CT", "SC"], - TypeCode.INT: ["DS", "TD", "SD"], - TypeCode.INT2: ["DD", "CTD"], - TypeCode.HEX: ["DH", "XD", "YD"], - TypeCode.FLOAT: ["DF"], - TypeCode.TXT: ["TXT"], + _CdvStorageCode.BIT: ["X", "Y", "C", "T", "CT", "SC"], + _CdvStorageCode.INT: ["DS", "TD", "SD"], + _CdvStorageCode.INT2: ["DD", "CTD"], + _CdvStorageCode.HEX: ["DH", "XD", "YD"], + _CdvStorageCode.FLOAT: ["DF"], + _CdvStorageCode.TXT: ["TXT"], } # SC addresses that are writable (most SC are read-only system controls) @@ -243,7 +243,7 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str Args: value: The raw value string from the CDV file. - type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) Returns: Native Python value (bool for BIT, int for INT/INT2/HEX, @@ -253,10 +253,10 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str return None try: - if type_code == TypeCode.BIT: + if type_code == _CdvStorageCode.BIT: return value == "1" - elif type_code == TypeCode.INT: + elif type_code == _CdvStorageCode.INT: # Stored as unsigned 32-bit with sign extension → signed 16-bit unsigned_val = int(value) val_16bit = unsigned_val & 0xFFFF @@ -264,23 +264,23 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str val_16bit -= 0x10000 return val_16bit - elif type_code == TypeCode.INT2: + elif type_code == _CdvStorageCode.INT2: # Stored as unsigned 32-bit → signed 32-bit unsigned_val = int(value) if unsigned_val >= 0x80000000: unsigned_val -= 0x100000000 return unsigned_val - elif type_code == TypeCode.HEX: + elif type_code == _CdvStorageCode.HEX: return int(value) - elif type_code == TypeCode.FLOAT: + elif type_code == _CdvStorageCode.FLOAT: # Stored as IEEE 754 32-bit integer representation → float int_val = int(value) bytes_val = struct.pack(">I", int_val & 0xFFFFFFFF) return struct.unpack(">f", bytes_val)[0] - elif type_code == TypeCode.TXT: + elif type_code == _CdvStorageCode.TXT: code = int(value) return chr(code) if 0 < code < 128 else "" @@ -296,7 +296,7 @@ def datatype_to_storage(value: int | float | bool | str | None, type_code: int) Args: value: The native Python value (bool, int, or float). - type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) Returns: Value formatted for CDV file storage, or "" if None. @@ -305,10 +305,10 @@ def datatype_to_storage(value: int | float | bool | str | None, type_code: int) return "" try: - if type_code == TypeCode.BIT: + if type_code == _CdvStorageCode.BIT: return "1" if value else "0" - elif type_code == TypeCode.INT: + elif type_code == _CdvStorageCode.INT: # Signed 16-bit → unsigned 32-bit with sign extension signed_val = int(value) signed_val = max(-32768, min(32767, signed_val)) @@ -316,7 +316,7 @@ def datatype_to_storage(value: int | float | bool | str | None, type_code: int) return str(signed_val + 0x100000000) return str(signed_val) - elif type_code == TypeCode.INT2: + elif type_code == _CdvStorageCode.INT2: # Signed 32-bit → unsigned 32-bit signed_val = int(value) signed_val = max(-2147483648, min(2147483647, signed_val)) @@ -324,17 +324,17 @@ def datatype_to_storage(value: int | float | bool | str | None, type_code: int) return str(signed_val + 0x100000000) return str(signed_val) - elif type_code == TypeCode.HEX: + elif type_code == _CdvStorageCode.HEX: return str(int(value)) - elif type_code == TypeCode.FLOAT: + elif type_code == _CdvStorageCode.FLOAT: # Float → IEEE 754 bytes → unsigned 32-bit integer string float_val = float(value) bytes_val = struct.pack(">f", float_val) int_val = struct.unpack(">I", bytes_val)[0] return str(int_val) - elif type_code == TypeCode.TXT: + elif type_code == _CdvStorageCode.TXT: if isinstance(value, str): return str(ord(value)) if value else "0" return str(int(value)) @@ -351,7 +351,7 @@ def datatype_to_display(value: int | float | bool | str | None, type_code: int) Args: value: The native Python value (bool, int, or float). - type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) Returns: Human-readable display string, or "" if None. @@ -360,19 +360,19 @@ def datatype_to_display(value: int | float | bool | str | None, type_code: int) return "" try: - if type_code == TypeCode.BIT: + if type_code == _CdvStorageCode.BIT: return "1" if value else "0" - elif type_code in (TypeCode.INT, TypeCode.INT2): + elif type_code in (_CdvStorageCode.INT, _CdvStorageCode.INT2): return str(int(value)) - elif type_code == TypeCode.HEX: + elif type_code == _CdvStorageCode.HEX: return format(int(value), "04X") - elif type_code == TypeCode.FLOAT: + elif type_code == _CdvStorageCode.FLOAT: return f"{float(value):.7G}" - elif type_code == TypeCode.TXT: + elif type_code == _CdvStorageCode.TXT: if isinstance(value, str): return value if value else "" code = int(value) @@ -392,7 +392,7 @@ def display_to_datatype(value: str, type_code: int) -> int | float | bool | str Args: value: The human-readable display string. - type_code: The type code (TypeCode.BIT, TypeCode.INT, etc.) + type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) Returns: Native Python value (bool for BIT, int for INT/INT2/HEX, @@ -402,22 +402,22 @@ def display_to_datatype(value: str, type_code: int) -> int | float | bool | str return None try: - if type_code == TypeCode.BIT: + if type_code == _CdvStorageCode.BIT: return value in ("1", "True", "true", "ON", "on") - elif type_code in (TypeCode.INT, TypeCode.INT2): + elif type_code in (_CdvStorageCode.INT, _CdvStorageCode.INT2): return int(value) - elif type_code == TypeCode.HEX: + elif type_code == _CdvStorageCode.HEX: hex_val = value.strip() if hex_val.lower().startswith("0x"): hex_val = hex_val[2:] return int(hex_val, 16) - elif type_code == TypeCode.FLOAT: + elif type_code == _CdvStorageCode.FLOAT: return float(value) - elif type_code == TypeCode.TXT: + elif type_code == _CdvStorageCode.TXT: if len(value) == 1: return value code = int(value) diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 5a96eab..ddcad57 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -7,7 +7,7 @@ WRITABLE_SC, WRITABLE_SD, DataviewRow, - TypeCode, + _CdvStorageCode, create_empty_dataview, datatype_to_display, datatype_to_storage, @@ -19,6 +19,8 @@ storage_to_datatype, ) +TypeCode = _CdvStorageCode + class TestGetTypeCodeForAddress: """Tests for get_type_code_for_address function.""" From 4cf9c93443cfca12430804d45441778dcb3d06c4 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:06:06 -0500 Subject: [PATCH 19/55] Delete HANDOFF.md --- spec/HANDOFF.md | 76 ------------------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 spec/HANDOFF.md diff --git a/spec/HANDOFF.md b/spec/HANDOFF.md deleted file mode 100644 index 7c1805a..0000000 --- a/spec/HANDOFF.md +++ /dev/null @@ -1,76 +0,0 @@ -# Rich PLC Value Types — Design Handoff - -## Problem - -Client reads return bare Python types (`int`, `float`, `bool`, `str`). For HEX and FLOAT especially, the raw value isn't what users want to see: - -- `plc.dh.read(1)` → `255` (user expects to see `"00FF"`) -- `plc.df.read(1)` → `3.140000104904175` (user expects `"3.14"`) - -The dataview layer has formatting functions (`datatype_to_display`, etc.) but they require manual type code lookups. There's no unified way to get display-friendly values. - -## Proposal - -Introduce rich value types that subclass Python builtins. They behave exactly like their base types for math/comparisons but override `__str__` to show PLC display format. - -```python -val = await plc.dh.read(1) -val + 1 # 256 — math works, it IS an int -print(val) # 00FF — display-friendly by default -val.raw() # 255 — explicit access to underlying value -f"{val:plc}" # 00FF — optional __format__ protocol support -``` - -### Types needed - -| Type | Base | `str()` example | `.raw()` | -|------|------|----------------|----------| -| PlcBit | bool-like* | `"1"` / `"0"` | `True` / `False` | -| PlcInt | int | `"42"` | `42` | -| PlcInt2 | int | `"42"` | `42` | -| PlcHex | int | `"00FF"` | `255` | -| PlcFloat | float | `"3.14"` | `3.140000104904175` | -| PlcStr | str | `"A"` | `"A"` (same) | - -*PlcBit can't subclass `bool` (it's final in Python). Could subclass `int` with truthy semantics, or just be a small wrapper. - -### Where they get created - -1. **Client** — `AddressAccessor._read_single` and range reads wrap return values -2. **Dataview** — `storage_to_datatype` and `display_to_datatype` return rich types -3. **Server** — `MemoryDataProvider` could accept/return them (or just unwrap on write) - -### Display formatting rules (from existing `datatype_to_display`) - -- BIT: `"1"` / `"0"` -- INT/INT2: `str(int(value))` -- HEX: `format(int(value), "04X")` -- FLOAT: `f"{float(value):.7G}"` -- TXT: the character itself (already `str`) - -### What moves where - -The formatting logic currently in `dataview.py` (`datatype_to_display`, `display_to_datatype`) could either: -- Stay in `dataview.py` and be called by the rich types -- Move into a new `values.py` module alongside the type definitions - -The conversion functions (`storage_to_datatype`, `datatype_to_storage`) stay in `dataview.py` since they're CDV-specific, but return rich types instead of bare values. - -### Dict results - -Range reads return `dict[str, PlcValue]`. Could use a `PlcResult(dict)` subclass with a `.display()` that formats all values, or just let users call `str()` on individual values. - -## Open questions - -- Module placement: new `values.py` or add to existing `banks.py`/`dataview.py`? -- Should `PlcBit` subclass `int` (like Python's `bool` does) or be a custom class? -- Should `.raw()` return the base type (`int(val)`) or just be an alias for clarity? -- How much `__format__` protocol support? Just `:plc` or more (`:hex`, `:dec`)? -- Should `MemoryDataProvider.read()` return rich types or bare values? - -## Status - -- TXT `str` unification is done (all four conversion functions handle `str` for TXT) -- README updated with API docs -- CLAUDE.md created -- 2 pre-existing test failures in `test_modbus.py` (T bank base address mismatch) From 5a971c310cfd0721b7193aece8daf6e9f44dd02a Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:38:56 -0500 Subject: [PATCH 20/55] refactor(types): add generic bank typing and make tests ty-compatible Parameterize ModbusResponse and AddressAccessor with value type generics in client.py. Add bank Literal type aliases plus overloads for _get_accessor and __getattr__ to improve typed accessor inference (bool/int/float/str by bank). Keep runtime behavior unchanged while adding explicit casts in read paths where decoding is type-dependent. Refactor test_client.py with typed async-mock setup/access helpers and float narrowing helpers. Update frozen-dataclass tests in test_addresses.py, test_banks.py, and test_modbus.py to use cast(Any, obj).field = ..., preserving runtime immutability checks while avoiding ty invalid-assignment errors. Add minor typing-safe assertions in test_dataview.py and test_server.py. Verification run: make lint passed; targeted pytest modules for these test changes passed. --- src/pyclickplc/client.py | 111 ++++++++++++++++++++++++--------- tests/test_addresses.py | 3 +- tests/test_banks.py | 3 +- tests/test_client.py | 128 +++++++++++++++++++++++++-------------- tests/test_dataview.py | 1 + tests/test_modbus.py | 3 +- tests/test_server.py | 10 ++- 7 files changed, 183 insertions(+), 76 deletions(-) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index c5ce7df..302cde2 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -6,7 +6,7 @@ from __future__ import annotations from collections.abc import Coroutine, Iterator, Mapping -from typing import Any, ClassVar +from typing import Any, ClassVar, Generic, Literal, TypeAlias, TypeVar, cast, overload from pymodbus.client import AsyncModbusTcpClient @@ -21,13 +21,41 @@ from .nicknames import DATA_TYPE_CODE_TO_STR, read_csv PlcValue = bool | int | float | str +TValue_co = TypeVar("TValue_co", bound=PlcValue, covariant=True) + +BoolBankName: TypeAlias = Literal["X", "Y", "C", "T", "CT", "SC"] +IntBankName: TypeAlias = Literal["DS", "DD", "DH", "TD", "CTD", "SD", "XD", "YD"] +FloatBankName: TypeAlias = Literal["DF"] +StrBankName: TypeAlias = Literal["TXT"] + +BoolBankAttr: TypeAlias = Literal["x", "X", "y", "Y", "c", "C", "t", "T", "ct", "CT", "sc", "SC"] +IntBankAttr: TypeAlias = Literal[ + "ds", + "DS", + "dd", + "DD", + "dh", + "DH", + "td", + "TD", + "ctd", + "CTD", + "sd", + "SD", + "xd", + "XD", + "yd", + "YD", +] +FloatBankAttr: TypeAlias = Literal["df", "DF"] +StrBankAttr: TypeAlias = Literal["txt", "TXT"] # ============================================================================== # ModbusResponse # ============================================================================== -class ModbusResponse(Mapping[str, PlcValue]): +class ModbusResponse(Mapping[str, TValue_co], Generic[TValue_co]): """Immutable mapping with normalized PLC address keys. Keys are stored in canonical uppercase form (``DS1``, ``X001``). @@ -37,12 +65,12 @@ class ModbusResponse(Mapping[str, PlcValue]): __slots__ = ("_data",) - def __init__(self, data: dict[str, PlcValue]) -> None: + def __init__(self, data: dict[str, TValue_co]) -> None: self._data = data # -- Mapping interface -------------------------------------------------- - def __getitem__(self, key: str) -> PlcValue: + def __getitem__(self, key: str) -> TValue_co: normalized = normalize_address(key) if normalized is not None and normalized in self._data: return self._data[normalized] @@ -68,7 +96,7 @@ def __eq__(self, other: object) -> bool: if isinstance(other, dict): if len(other) != len(self._data): return False - normalized: dict[str, Any] = {} + normalized: dict[str, object] = {} for k, v in other.items(): nk = normalize_address(k) if isinstance(k, str) else None if nk is None: @@ -146,7 +174,7 @@ def _format_bank_address(bank: str, index: int) -> str: return format_address_display(bank, index) -class AddressAccessor: +class AddressAccessor(Generic[TValue_co]): """Provides read/write access to a specific PLC memory bank.""" def __init__(self, plc: ClickClient, bank: str) -> None: @@ -155,7 +183,7 @@ def __init__(self, plc: ClickClient, bank: str) -> None: self._mapping = MODBUS_MAPPINGS[bank] self._bank_cfg = BANKS[bank] - async def read(self, start: int, end: int | None = None) -> ModbusResponse: + async def read(self, start: int, end: int | None = None) -> ModbusResponse[TValue_co]: """Read single value or range (inclusive). Always returns a ModbusResponse, even for a single address. @@ -179,29 +207,29 @@ async def read(self, start: int, end: int | None = None) -> ModbusResponse: return await self._read_sparse_range(start, end) return await self._read_range(start, end) - async def _read_single(self, index: int) -> PlcValue: + async def _read_single(self, index: int) -> TValue_co: """Read a single PLC address.""" bank = self._bank self._validate_index(index) if bank == "TXT": - return await self._read_txt(index) + return cast(TValue_co, await self._read_txt(index)) if self._mapping.is_coil: addr, _ = plc_to_modbus(bank, index) result = await self._plc._read_coils(addr, 1, bank) - return result[0] + return cast(TValue_co, result[0]) addr, count = plc_to_modbus(bank, index) regs = await self._plc._read_registers(addr, count, bank) - return unpack_value(regs, self._bank_cfg.data_type) + return cast(TValue_co, unpack_value(regs, self._bank_cfg.data_type)) - async def _read_range(self, start: int, end: int) -> ModbusResponse: + async def _read_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: """Read a contiguous range.""" bank = self._bank self._validate_index(start) self._validate_index(end) - result: dict[str, PlcValue] = {} + result: dict[str, TValue_co] = {} if self._mapping.is_coil: addr_start, _ = plc_to_modbus(bank, start) @@ -210,7 +238,7 @@ async def _read_range(self, start: int, end: int) -> ModbusResponse: bits = await self._plc._read_coils(addr_start, count, bank) for i, idx in enumerate(range(start, end + 1)): key = _format_bank_address(bank, idx) - result[key] = bits[i] + result[key] = cast(TValue_co, bits[i]) else: addr_start, _ = plc_to_modbus(bank, start) addr_end, count_last = plc_to_modbus(bank, end) @@ -222,11 +250,11 @@ async def _read_range(self, start: int, end: int) -> ModbusResponse: offset = i * width val = unpack_value(regs[offset : offset + width], data_type) key = _format_bank_address(bank, idx) - result[key] = val + result[key] = cast(TValue_co, val) return ModbusResponse(result) - async def _read_sparse_range(self, start: int, end: int) -> ModbusResponse: + async def _read_sparse_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: """Read a sparse (X/Y) range, skipping gaps.""" bank = self._bank # Enumerate valid addresses in [start, end] @@ -246,12 +274,12 @@ async def _read_sparse_range(self, start: int, end: int) -> ModbusResponse: count = addr_last - addr_first + 1 bits = await self._plc._read_coils(addr_first, count, bank) - result: dict[str, PlcValue] = {} + result: dict[str, TValue_co] = {} for a in valid_addrs: addr, _ = plc_to_modbus(bank, a) bit_idx = addr - addr_first key = _format_bank_address(bank, a) - result[key] = bits[bit_idx] + result[key] = cast(TValue_co, bits[bit_idx]) return ModbusResponse(result) @@ -269,7 +297,7 @@ async def _read_txt(self, index: int) -> str: # Even: high byte return chr((reg_val >> 8) & 0xFF) - async def _read_txt_range(self, start: int, end: int) -> ModbusResponse: + async def _read_txt_range(self, start: int, end: int) -> ModbusResponse[TValue_co]: """Read a range of TXT addresses.""" self._validate_index(start) self._validate_index(end) @@ -281,7 +309,7 @@ async def _read_txt_range(self, start: int, end: int) -> ModbusResponse: reg_base + first_reg, last_reg - first_reg + 1, "TXT" ) - result: dict[str, PlcValue] = {} + result: dict[str, TValue_co] = {} for idx in range(start, end + 1): reg_offset = (idx - 1) // 2 - first_reg reg_val = regs[reg_offset] @@ -289,7 +317,7 @@ async def _read_txt_range(self, start: int, end: int) -> ModbusResponse: ch = chr(reg_val & 0xFF) else: ch = chr((reg_val >> 8) & 0xFF) - result[_format_bank_address("TXT", idx)] = ch + result[_format_bank_address("TXT", idx)] = cast(TValue_co, ch) return ModbusResponse(result) @@ -448,7 +476,7 @@ def _validate_type(self, value: bool | int | float | str) -> None: def __repr__(self) -> str: return f"" - def __getitem__(self, key: int) -> Coroutine[Any, Any, PlcValue]: + def __getitem__(self, key: int) -> Coroutine[Any, Any, TValue_co]: """Enable ``await plc.ds[1]`` syntax for single-value reads.""" if isinstance(key, slice): raise TypeError("Slicing is not supported. Use read(start, end) for range reads.") @@ -466,7 +494,7 @@ class AddressInterface: def __init__(self, plc: ClickClient) -> None: self._plc = plc - async def read(self, address: str) -> ModbusResponse: + async def read(self, address: str) -> ModbusResponse[PlcValue]: """Read by address string. Supports 'df1' or 'df1-df10'.""" if "-" in address: parts = address.split("-", 1) @@ -569,7 +597,7 @@ def __init__( port = 502 self._client = AsyncModbusTcpClient(host, port=port, timeout=timeout) - self._accessors: dict[str, AddressAccessor] = {} + self._accessors: dict[str, AddressAccessor[PlcValue]] = {} self.tags: dict[str, dict[str, str]] = {} self.addr = AddressInterface(self) self.tag = TagInterface(self) @@ -577,16 +605,45 @@ def __init__( if tag_filepath: self.tags = _load_tags(tag_filepath) - def _get_accessor(self, bank: str) -> AddressAccessor: + @overload + def _get_accessor(self, bank: BoolBankName) -> AddressAccessor[bool]: ... + + @overload + def _get_accessor(self, bank: IntBankName) -> AddressAccessor[int]: ... + + @overload + def _get_accessor(self, bank: FloatBankName) -> AddressAccessor[float]: ... + + @overload + def _get_accessor(self, bank: StrBankName) -> AddressAccessor[str]: ... + + @overload + def _get_accessor(self, bank: str) -> AddressAccessor[PlcValue]: ... + + def _get_accessor(self, bank: str) -> AddressAccessor[PlcValue]: """Get or create an AddressAccessor for a bank.""" bank_upper = bank.upper() if bank_upper not in MODBUS_MAPPINGS: raise AttributeError(f"'{bank}' is not a supported address type.") if bank_upper not in self._accessors: - self._accessors[bank_upper] = AddressAccessor(self, bank_upper) + self._accessors[bank_upper] = cast( + AddressAccessor[PlcValue], AddressAccessor(self, bank_upper) + ) return self._accessors[bank_upper] - def __getattr__(self, name: str) -> AddressAccessor: + @overload + def __getattr__(self, name: BoolBankAttr) -> AddressAccessor[bool]: ... + + @overload + def __getattr__(self, name: IntBankAttr) -> AddressAccessor[int]: ... + + @overload + def __getattr__(self, name: FloatBankAttr) -> AddressAccessor[float]: ... + + @overload + def __getattr__(self, name: StrBankAttr) -> AddressAccessor[str]: ... + + def __getattr__(self, name: str) -> AddressAccessor[PlcValue]: if name.startswith("_"): raise AttributeError(name) upper = name.upper() diff --git a/tests/test_addresses.py b/tests/test_addresses.py index 8513759..d0892d2 100644 --- a/tests/test_addresses.py +++ b/tests/test_addresses.py @@ -1,6 +1,7 @@ """Tests for pyclickplc.addresses module.""" from dataclasses import FrozenInstanceError, replace +from typing import Any, cast import pytest @@ -176,7 +177,7 @@ class TestAddressRecord: def test_frozen(self): rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) with pytest.raises(FrozenInstanceError): - rec.nickname = "test" + cast(Any, rec).nickname = "test" def test_replace(self): rec = AddressRecord(memory_type="DS", address=1, data_type=DataType.INT) diff --git a/tests/test_banks.py b/tests/test_banks.py index afd0546..cb073d9 100644 --- a/tests/test_banks.py +++ b/tests/test_banks.py @@ -1,6 +1,7 @@ """Tests for pyclickplc.banks module.""" from dataclasses import FrozenInstanceError +from typing import Any, cast import pytest @@ -50,7 +51,7 @@ class TestBankConfig: def test_frozen(self): bank = BANKS["DS"] with pytest.raises(FrozenInstanceError): - bank.name = "other" + cast(Any, bank).name = "other" def test_all_16_banks_defined(self): assert len(BANKS) == 16 diff --git a/tests/test_client.py b/tests/test_client.py index e2f28b9..211b30c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,6 +5,7 @@ from __future__ import annotations +from typing import Any, cast from unittest.mock import AsyncMock import pytest @@ -28,13 +29,50 @@ def _make_plc(tag_filepath: str = "") -> ClickClient: """Create a ClickClient without connecting.""" plc = ClickClient("localhost:5020", tag_filepath=tag_filepath) # Mock internal transport methods - plc._read_coils = AsyncMock(return_value=[False]) - plc._write_coils = AsyncMock() - plc._read_registers = AsyncMock(return_value=[0]) - plc._write_registers = AsyncMock() + _set_read_coils(plc, [False]) + _set_write_coils(plc) + _set_read_registers(plc, [0]) + _set_write_registers(plc) return plc +def _set_read_coils(plc: ClickClient, return_value: list[bool]) -> AsyncMock: + mock = AsyncMock(return_value=return_value) + object.__setattr__(plc, "_read_coils", mock) + return mock + + +def _set_write_coils(plc: ClickClient) -> AsyncMock: + mock = AsyncMock() + object.__setattr__(plc, "_write_coils", mock) + return mock + + +def _set_read_registers(plc: ClickClient, return_value: list[int]) -> AsyncMock: + mock = AsyncMock(return_value=return_value) + object.__setattr__(plc, "_read_registers", mock) + return mock + + +def _set_write_registers(plc: ClickClient) -> AsyncMock: + mock = AsyncMock() + object.__setattr__(plc, "_write_registers", mock) + return mock + + +def _get_write_coils_mock(plc: ClickClient) -> AsyncMock: + return cast(AsyncMock, plc._write_coils) + + +def _get_write_registers_mock(plc: ClickClient) -> AsyncMock: + return cast(AsyncMock, plc._write_registers) + + +def _as_float(value: object) -> float: + assert isinstance(value, (int, float)) + return float(value) + + # ============================================================================== # ClickClient construction and __getattr__ # ============================================================================== @@ -78,13 +116,13 @@ async def test_getattr_cached(self): async def test_getattr_underscore_raises(self): plc = _make_plc() with pytest.raises(AttributeError): - _ = plc._private + _ = cast(Any, plc)._private @pytest.mark.asyncio async def test_getattr_unknown_raises(self): plc = _make_plc() with pytest.raises(AttributeError, match="not a supported"): - _ = plc.invalid_bank + _ = cast(Any, plc).invalid_bank @pytest.mark.asyncio async def test_addr_is_address_interface(self): @@ -129,7 +167,7 @@ class TestAddressAccessorReadSingle: async def test_read_float(self): plc = _make_plc() regs = pack_value(3.14, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) result = await plc.df.read(1) assert isinstance(result, ModbusResponse) import math @@ -139,7 +177,7 @@ async def test_read_float(self): @pytest.mark.asyncio async def test_read_int16(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[42]) + _set_read_registers(plc, [42]) result = await plc.ds.read(1) assert result == {"DS1": 42} @@ -147,28 +185,28 @@ async def test_read_int16(self): async def test_read_int32(self): plc = _make_plc() regs = pack_value(100000, DataType.INT2) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) result = await plc.dd.read(1) assert result == {"DD1": 100000} @pytest.mark.asyncio async def test_read_unsigned(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[0xABCD]) + _set_read_registers(plc, [0xABCD]) result = await plc.dh.read(1) assert result == {"DH1": 0xABCD} @pytest.mark.asyncio async def test_read_bool(self): plc = _make_plc() - plc._read_coils = AsyncMock(return_value=[True]) + _set_read_coils(plc, [True]) result = await plc.c.read(1) assert result == {"C1": True} @pytest.mark.asyncio async def test_read_sparse_bool(self): plc = _make_plc() - plc._read_coils = AsyncMock(return_value=[True]) + _set_read_coils(plc, [True]) result = await plc.x.read(101) assert result == {"X101": True} @@ -176,14 +214,14 @@ async def test_read_sparse_bool(self): async def test_read_txt(self): plc = _make_plc() # TXT1 is low byte of register - plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) + _set_read_registers(plc, [ord("A") | (ord("B") << 8)]) result = await plc.txt.read(1) assert result == {"TXT1": "A"} @pytest.mark.asyncio async def test_read_txt_even(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[ord("A") | (ord("B") << 8)]) + _set_read_registers(plc, [ord("A") | (ord("B") << 8)]) result = await plc.txt.read(2) assert result == {"TXT2": "B"} @@ -199,7 +237,7 @@ async def test_read_df_range(self): plc = _make_plc() r1 = pack_value(1.0, DataType.FLOAT) r2 = pack_value(2.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=r1 + r2) + _set_read_registers(plc, r1 + r2) result = await plc.df.read(1, 2) assert isinstance(result, ModbusResponse) assert len(result) == 2 @@ -211,7 +249,7 @@ async def test_read_df_range(self): @pytest.mark.asyncio async def test_read_c_range(self): plc = _make_plc() - plc._read_coils = AsyncMock(return_value=[True, False, True]) + _set_read_coils(plc, [True, False, True]) result = await plc.c.read(1, 3) assert result == {"C1": True, "C2": False, "C3": True} @@ -232,25 +270,25 @@ class TestAddressAccessorWrite: async def test_write_float(self): plc = _make_plc() await plc.df.write(1, 3.14) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_int16(self): plc = _make_plc() await plc.ds.write(1, 42) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_bool(self): plc = _make_plc() await plc.c.write(1, True) - plc._write_coils.assert_called_once() + _get_write_coils_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_list(self): plc = _make_plc() await plc.df.write(1, [1.0, 2.0, 3.0]) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_wrong_type_raises(self): @@ -280,7 +318,7 @@ async def test_write_not_writable_sc(self): async def test_write_writable_sc53(self): plc = _make_plc() await plc.sc.write(53, True) - plc._write_coils.assert_called_once() + _get_write_coils_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_not_writable_sd(self): @@ -292,7 +330,7 @@ async def test_write_not_writable_sd(self): async def test_write_writable_sd29(self): plc = _make_plc() await plc.sd.write(29, 100) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() # ============================================================================== @@ -330,7 +368,7 @@ async def test_read_max_df(self): """Reading at max address should work.""" plc = _make_plc() regs = pack_value(0.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) result = await plc.df.read(500) assert result == {"DF500": 0.0} @@ -345,19 +383,19 @@ class TestAddressInterface: async def test_read_single(self): plc = _make_plc() regs = pack_value(3.14, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) result = await plc.addr.read("df1") assert isinstance(result, ModbusResponse) import math - assert math.isclose(result["DF1"], 3.14, rel_tol=1e-6) + assert math.isclose(_as_float(result["DF1"]), 3.14, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_range(self): plc = _make_plc() r1 = pack_value(1.0, DataType.FLOAT) r2 = pack_value(2.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=r1 + r2) + _set_read_registers(plc, r1 + r2) result = await plc.addr.read("df1-df2") assert isinstance(result, ModbusResponse) assert len(result) == 2 @@ -366,7 +404,7 @@ async def test_read_range(self): async def test_read_case_insensitive(self): plc = _make_plc() regs = pack_value(0.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) result = await plc.addr.read("DF1") assert result == {"DF1": 0.0} @@ -392,13 +430,13 @@ async def test_invalid_address_raises(self): async def test_write_single(self): plc = _make_plc() await plc.addr.write("df1", 3.14) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_list(self): plc = _make_plc() await plc.addr.write("df1", [1.0, 2.0]) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() # ============================================================================== @@ -419,11 +457,11 @@ def _plc_with_tags(self) -> ClickClient: async def test_read_single_tag(self): plc = self._plc_with_tags() regs = pack_value(25.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) value = await plc.tag.read("Temp") import math - assert math.isclose(value, 25.0, rel_tol=1e-6) + assert math.isclose(_as_float(value), 25.0, rel_tol=1e-6) @pytest.mark.asyncio async def test_read_missing_tag_raises(self): @@ -435,8 +473,8 @@ async def test_read_missing_tag_raises(self): async def test_read_all_tags(self): plc = self._plc_with_tags() regs = pack_value(25.0, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) - plc._read_coils = AsyncMock(return_value=[True]) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) result = await plc.tag.read() assert isinstance(result, dict) assert "Temp" in result @@ -452,7 +490,7 @@ async def test_read_all_no_tags_raises(self): async def test_write_tag(self): plc = self._plc_with_tags() await plc.tag.write("Temp", 30.0) - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_missing_tag_raises(self): @@ -481,24 +519,26 @@ class TestAddressAccessorTxtWrite: async def test_write_single_txt(self): plc = _make_plc() # Mock read of current register value (for twin byte preservation) - plc._read_registers = AsyncMock(return_value=[0]) + _set_read_registers(plc, [0]) await plc.txt.write(1, "A") - plc._write_registers.assert_called_once() + _get_write_registers_mock(plc).assert_called_once() @pytest.mark.asyncio async def test_write_empty_string_clears_txt(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[0x4142]) # "AB" + _set_read_registers(plc, [0x4142]) # "AB" await plc.txt.write(1, "") # Empty string → null byte in low position, high byte preserved - plc._write_registers.assert_called_once_with(MODBUS_MAPPINGS["TXT"].base, [0x4100]) + _get_write_registers_mock(plc).assert_called_once_with( + MODBUS_MAPPINGS["TXT"].base, [0x4100] + ) @pytest.mark.asyncio async def test_write_txt_list(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[0]) + _set_read_registers(plc, [0]) await plc.txt.write(1, ["H", "i"]) - assert plc._write_registers.call_count == 2 + assert _get_write_registers_mock(plc).call_count == 2 # ============================================================================== @@ -641,7 +681,7 @@ class TestAddressAccessorGetitem: @pytest.mark.asyncio async def test_getitem_int(self): plc = _make_plc() - plc._read_registers = AsyncMock(return_value=[42]) + _set_read_registers(plc, [42]) value = await plc.ds[1] assert value == 42 @@ -649,7 +689,7 @@ async def test_getitem_int(self): async def test_getitem_float(self): plc = _make_plc() regs = pack_value(3.14, DataType.FLOAT) - plc._read_registers = AsyncMock(return_value=regs) + _set_read_registers(plc, regs) value = await plc.df[1] import math @@ -658,7 +698,7 @@ async def test_getitem_float(self): @pytest.mark.asyncio async def test_getitem_bool(self): plc = _make_plc() - plc._read_coils = AsyncMock(return_value=[True]) + _set_read_coils(plc, [True]) value = await plc.c[1] assert value is True @@ -666,7 +706,7 @@ async def test_getitem_bool(self): async def test_getitem_slice_raises(self): plc = _make_plc() with pytest.raises(TypeError, match="Slicing is not supported"): - plc.ds[1:5] + cast(Any, plc.ds)[1:5] @pytest.mark.asyncio async def test_getitem_out_of_range_raises(self): diff --git a/tests/test_dataview.py b/tests/test_dataview.py index ddcad57..9f10e29 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -217,6 +217,7 @@ def test_float_values(self): val = storage_to_datatype("1078523331", TypeCode.FLOAT) assert val == pytest.approx(3.14, abs=1e-5) val = storage_to_datatype("4286578685", TypeCode.FLOAT) + assert isinstance(val, (int, float)) assert val < 0 def test_txt_values(self): diff --git a/tests/test_modbus.py b/tests/test_modbus.py index 084cc1e..5e5bd26 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -4,6 +4,7 @@ import math import struct +from typing import Any, cast import pytest @@ -52,7 +53,7 @@ def test_all_16_banks_present(self): def test_frozen_dataclass(self): m = MODBUS_MAPPINGS["DS"] with pytest.raises(AttributeError): - m.base = 999 + cast(Any, m).base = 999 def test_coil_banks(self): coil_banks = {k for k, v in MODBUS_MAPPINGS.items() if v.is_coil} diff --git a/tests/test_server.py b/tests/test_server.py index 6ee0bde..c3c6073 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,6 +10,12 @@ _ClickDeviceContext, ) + +def _as_float(value: object) -> float: + assert isinstance(value, (int, float)) + return float(value) + + # ============================================================================== # MemoryDataProvider # ============================================================================== @@ -373,7 +379,7 @@ def test_fc16_write_df1_float(self): assert result is None import math - assert math.isclose(p.get("DF1"), 3.14, rel_tol=1e-6) + assert math.isclose(_as_float(p.get("DF1")), 3.14, rel_tol=1e-6) def test_fc06_write_df1_read_modify_write(self): """FC 06 on width-2: writes one register, read-modify-write.""" @@ -389,7 +395,7 @@ def test_fc06_write_df1_read_modify_write(self): assert result is None import math - assert math.isclose(p.get("DF1"), 3.14, rel_tol=1e-6) + assert math.isclose(_as_float(p.get("DF1")), 3.14, rel_tol=1e-6) def test_fc06_write_unmapped(self): p = MemoryDataProvider() From eecf8e348eab1fcfa3218ca6d1ebc936697d81c7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:54:11 -0500 Subject: [PATCH 21/55] spec: Client/Server min max plan --- scratchpad/client-server-min-max-plan.md | 94 ++++++++++++++++++++++++ spec/CLICKDEVICE_SPEC.md | 44 +++++++++-- spec/CLICKSERVER_SPEC.md | 40 +++++++++- 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 scratchpad/client-server-min-max-plan.md diff --git a/scratchpad/client-server-min-max-plan.md b/scratchpad/client-server-min-max-plan.md new file mode 100644 index 0000000..9ddc3c7 --- /dev/null +++ b/scratchpad/client-server-min-max-plan.md @@ -0,0 +1,94 @@ +# Client + Server Min/Max Validation Plan + +## Goal + +Enforce one runtime write contract across pyclickplc: + +- Reject invalid values on client writes. +- Reject invalid values in `MemoryDataProvider.write()` / `set()`. +- Do not add a permissive or compatibility mode. + +This project is unreleased, so we optimize for a clean contract instead of migration paths. + +## Decision Summary + +- `HEX` uses IEC WORD semantics: unsigned 16-bit `0..65535` (`0x0000..0xFFFF`). +- Numeric bank validation is explicit (not only implicit `struct.pack` failures). +- Bool-as-int is rejected for numeric banks (`DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD`, `DF`). +- `DF` rejects non-finite values (`NaN`, `+Inf`, `-Inf`) and values not representable in float32. +- `TXT` allows blank (`""`) or a single ASCII character (including space `" "`). + +## Implementation Plan + +1. Add shared runtime value validation helper +- File: `src/pyclickplc/validation.py` +- Add an assertion-style API for runtime write validation keyed by `DataType`. +- Keep existing nickname/comment/initial-value validation intact. + +2. Use shared validation in client write path +- File: `src/pyclickplc/client.py` +- Replace `_validate_type()` with `_validate_value()` (type + range + format). +- Keep existing index and writability checks unchanged. +- Raise deterministic `ValueError` messages for invalid values. + +3. Validate in `MemoryDataProvider` +- File: `src/pyclickplc/server.py` +- In `write()`, normalize address, resolve bank/type, validate value, then store. +- `set()` and `bulk_set()` inherit strict validation through `write()`. +- No constructor flags for permissive behavior. + +4. Keep Modbus server writability behavior unchanged +- `SC` and `SD` writability remains enforced by server request handling. +- Provider validation is value-centric, not Modbus function-code authorization. + +## Runtime Validation Matrix + +- `BIT`: `type(value) is bool` +- `INT` (`DS`, `TD`, `SD`): `type(value) is int`, range `-32768..32767` +- `INT2` (`DD`, `CTD`): `type(value) is int`, range `-2147483648..2147483647` +- `HEX` (`DH`, `XD`, `YD`): `type(value) is int`, range `0..65535` +- `FLOAT` (`DF`): numeric (`int`/`float`, not bool), finite, packable as float32 +- `TXT`: `type(value) is str`, and either: + - blank string `""`, or + - length `1` ASCII char (`ord <= 127`) + +## Tests To Add/Update + +1. Client write rejections +- `plc.ds.write(1, 32768)` -> `ValueError` +- `plc.dd.write(1, 2147483648)` -> `ValueError` +- `plc.dh.write(1, -1)` -> `ValueError` +- `plc.ds.write(1, True)` -> `ValueError` +- `plc.df.write(1, float("nan"))` -> `ValueError` +- `plc.df.write(1, float("inf"))` -> `ValueError` +- `plc.txt.write(1, "AB")` -> `ValueError` +- `plc.txt.write(1, "\\u00E9")` -> `ValueError` +- `plc.txt.write(1, "")` -> succeeds (writes NUL) + +2. MemoryDataProvider rejections +- `set("DS1", 999999999999)` -> `ValueError` +- `set("DF1", "abc")` -> `ValueError` +- `set("TXT1", "AB")` -> `ValueError` +- `set("DH1", -1)` -> `ValueError` + +3. Regression coverage +- Existing valid read/write tests remain green. +- Existing Modbus mapping/pack/unpack tests remain green. + +## Spec/Doc Updates + +- `spec/CLICKSERVER_SPEC.md` + - Add strict runtime value validation semantics for `MemoryDataProvider`. + - Clarify that DataProvider exceptions include validation errors and map to `SlaveDeviceFailure`. + +- `spec/CLICKDEVICE_SPEC.md` + - Add explicit runtime value-range rules for write APIs. + - Document WORD range for `HEX` (`0..65535`). + - Add strict invalid-value scenarios. + +## Acceptance Criteria + +- Invalid runtime write values fail consistently in both client API and `MemoryDataProvider`. +- Errors are `ValueError` with actionable messages. +- No permissive mode exists in core API. +- Spec docs clearly describe runtime validation behavior and limits. diff --git a/spec/CLICKDEVICE_SPEC.md b/spec/CLICKDEVICE_SPEC.md index 265c411..1f77b4d 100644 --- a/spec/CLICKDEVICE_SPEC.md +++ b/spec/CLICKDEVICE_SPEC.md @@ -373,13 +373,25 @@ For sparse types (X, Y): ### Data Type Validation -| Type | Accepted Python Types | -|------|----------------------| -| `bool` | `bool` | -| `int16` | `int` | -| `int32` | `int` | -| `float` | `int` or `float` | -| `str` | `str` | +| Type | Accepted Python Types | Notes | +|------|-----------------------|-------| +| `bool` | `bool` | only bool | +| `int16` | `int` | bool rejected | +| `int32` | `int` | bool rejected | +| `WORD` (`hex`) | `int` | bool rejected | +| `float32` | `int` or `float` | bool rejected | +| `str` | `str` | blank (`""`) or single ASCII char for TXT | + +### Value Range Validation + +- `int16` banks (`DS`, `TD`, `SD`): `-32768..32767` +- `int32` banks (`DD`, `CTD`): `-2147483648..2147483647` +- `WORD` banks (`DH`, `XD`, `YD`): `0..65535` (`0x0000..0xFFFF`) +- `float32` bank (`DF`): finite value representable as IEEE-754 float32 +- `TXT`: blank (`""`) or exactly 1 ASCII character (`0..127`) +- `TXT` space (`" "`) is valid + +Validation runs before writing and raises `ValueError` on invalid runtime values. ### Writability Validation @@ -543,6 +555,20 @@ When writing X/Y ranges that span gaps: 53. `plc.df.write(1, 'string')` raises ValueError (wrong type) 54. `plc.ds.write(1, 3.14)` raises ValueError (float for int16) +Strict runtime value checks: + +- `plc.ds.write(1, 32768)` raises ValueError (int16 overflow) +- `plc.dd.write(1, 2147483648)` raises ValueError (int32 overflow) +- `plc.dh.write(1, -1)` raises ValueError (WORD underflow) +- `plc.dh.write(1, 65536)` raises ValueError (WORD overflow) +- `plc.ds.write(1, True)` raises ValueError (bool rejected for numeric) +- `plc.df.write(1, float('nan'))` raises ValueError (non-finite float) +- `plc.df.write(1, float('inf'))` raises ValueError (non-finite float) +- `plc.txt.write(1, 'AB')` raises ValueError (TXT must be single char) +- `plc.txt.write(1, '\\u00E9')` raises ValueError (TXT must be ASCII) +- `plc.txt.write(1, ' ')` succeeds (space TXT is allowed) +- `plc.txt.write(1, '')` succeeds (blank TXT is allowed) + ### Text Special Cases 55. Read single char at odd position (`txt1`) @@ -582,6 +608,10 @@ Provide clear, actionable error messages: - `"Inter-bank ranges are unsupported."` - `"End address must be greater than start address."` - `"Expected {address} as {expected_type}, got {actual_type}."` +- `"{BANK}{index} value must be in [{min}, {max}]."` +- `"{BANK}{index} value must be a finite float32."` +- `"{BANK}{index} must be WORD (0..65535)."` +- `"{BANK}{index} TXT value must be blank or a single ASCII character."` - `"{BANK}{index} is not writable."` - `"Tag '{name}' not found. Available: [...]"` - `"No tags loaded. Provide a tag file or specify a tag name."` diff --git a/spec/CLICKSERVER_SPEC.md b/spec/CLICKSERVER_SPEC.md index f49d01f..f1af120 100644 --- a/spec/CLICKSERVER_SPEC.md +++ b/spec/CLICKSERVER_SPEC.md @@ -116,7 +116,7 @@ class DataProvider(Protocol): - bool for X, Y, C, T, CT, SC - int for DS, DD, DH, TD, CTD, SD - float for DF - - str for TXT (single character) + - str for TXT (blank or single character) """ ... @@ -125,7 +125,7 @@ class DataProvider(Protocol): Args: address: Uppercase PLC address string - value: Value to write. Type matches the address bank. + value: Value to write. Type and range must match the address bank. """ ... ``` @@ -136,6 +136,7 @@ class DataProvider(Protocol): - Address strings are always uppercase with no spaces: `'DF1'`, `'X001'`, `'DS100'`. - The server validates writability (SC/SD restrictions) **before** calling `write()`. The DataProvider does not need to enforce writability. - The server handles all Modbus packing/unpacking. The DataProvider only deals in native Python types. +- `MemoryDataProvider` enforces strict runtime value validation for `write()` and `set()`. - If `read()` returns a value of the wrong type, behavior is undefined. --- @@ -171,7 +172,7 @@ class MemoryDataProvider: | `float` (DF) | `0.0` | | `str` (TXT) | `'\x00'` | -- `write()` stores the value +- `write()` validates value type/range for the target bank, then stores it - `set()` / `get()` are synchronous wrappers for test setup and inspection - `bulk_set()` calls `set()` for each entry - Address strings are normalized to uppercase internally @@ -418,6 +419,26 @@ All other banks are fully writable within their address range. The server accepts Modbus requests for any valid address in each bank's range. Requests beyond a bank's address space that don't fall in another bank return defaults (reads) or are rejected (writes). +### Runtime Value Validation (MemoryDataProvider) + +`MemoryDataProvider` rejects invalid runtime values with `ValueError`. + +| Data Type | Banks | Required Value | +|-----------|-------|----------------| +| `bool` | X, Y, C, T, CT, SC | `bool` | +| `int16` signed | DS, TD, SD | `int` in `[-32768, 32767]` | +| `int32` signed | DD, CTD | `int` in `[-2147483648, 2147483647]` | +| `WORD` unsigned | DH, XD, YD | `int` in `[0, 65535]` | +| `float32` | DF | finite `int`/`float` representable as float32 | +| `text` | TXT | blank (`""`) or single ASCII `str` character | + +Additional rules: + +- Bool values are rejected for numeric banks (`bool` is not accepted as `int`). +- `NaN`, `+Inf`, and `-Inf` are invalid for `DF`. +- TXT values may be blank (`""`) or exactly one character with ASCII code `0..127`. +- TXT space (`" "`) is valid. + --- ## Error Handling @@ -441,6 +462,8 @@ If the DataProvider raises an exception during `read()` or `write()`, the server The server never crashes due to a DataProvider error. +This includes `MemoryDataProvider` validation failures (`ValueError`). + --- ## Test Scenarios @@ -548,6 +571,17 @@ The server never crashes due to a DataProvider error. 72. `bulk_set()` sets multiple values 73. Address normalization: `set('df1', 1.0)` then `get('DF1')` returns `1.0` +MemoryDataProvider value validation: + +- Reject out-of-range int16 (`set('DS1', 32768)`) +- Reject out-of-range int32 (`set('DD1', 2147483648)`) +- Reject out-of-range WORD (`set('DH1', -1)` or `set('DH1', 65536)`) +- Reject non-finite float (`set('DF1', float('nan'))`, `float('inf')`) +- Reject invalid TXT (`set('TXT1', 'AB')`, non-ASCII) +- Allow space TXT (`set('TXT1', ' ')`) +- Allow blank TXT (`set('TXT1', '')`) +- Reject bool for numeric banks (`set('DS1', True)`) + ### Server Lifecycle 74. Context manager: server starts and stops cleanly From 0749337151d5d4efe915b3a9f91369a1357fab4c Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:32:05 -0500 Subject: [PATCH 22/55] feat: enforce strict runtime write validation in client and MemoryDataProvider - add shared `assert_runtime_value()` validation helper keyed by `DataType` - apply strict value checks in client write path (type + range/format + float32 finite/packable) - validate values in `MemoryDataProvider.write()` so `set()` and `bulk_set()` inherit same behavior - keep SC/SD writability enforcement in Modbus server flow unchanged - add coverage for client and provider rejection cases (overflow, bool-as-int, NaN/Inf, TXT length/ASCII) - update CLICK device/server specs to document strict runtime validation contract and WORD semantics --- spec/CLICKDEVICE_SPEC.md | 4 +-- spec/CLICKSERVER_SPEC.md | 6 ++-- src/pyclickplc/client.py | 29 +++++------------- src/pyclickplc/server.py | 22 ++++++++------ src/pyclickplc/validation.py | 58 ++++++++++++++++++++++++++++++++++++ tests/test_client.py | 56 ++++++++++++++++++++++++++++++++-- tests/test_server.py | 39 ++++++++++++++++++++++++ tests/test_validation.py | 31 +++++++++++++++++++ 8 files changed, 207 insertions(+), 38 deletions(-) diff --git a/spec/CLICKDEVICE_SPEC.md b/spec/CLICKDEVICE_SPEC.md index 1f77b4d..c12c35d 100644 --- a/spec/CLICKDEVICE_SPEC.md +++ b/spec/CLICKDEVICE_SPEC.md @@ -607,8 +607,8 @@ Provide clear, actionable error messages: - `"{BANK} end must be > start and <= {max}"` - `"Inter-bank ranges are unsupported."` - `"End address must be greater than start address."` -- `"Expected {address} as {expected_type}, got {actual_type}."` -- `"{BANK}{index} value must be in [{min}, {max}]."` +- `"{BANK}{index} value must be bool."` +- `"{BANK}{index} value must be int in [{min}, {max}]."` - `"{BANK}{index} value must be a finite float32."` - `"{BANK}{index} must be WORD (0..65535)."` - `"{BANK}{index} TXT value must be blank or a single ASCII character."` diff --git a/spec/CLICKSERVER_SPEC.md b/spec/CLICKSERVER_SPEC.md index f1af120..98771ea 100644 --- a/spec/CLICKSERVER_SPEC.md +++ b/spec/CLICKSERVER_SPEC.md @@ -114,7 +114,7 @@ class DataProvider(Protocol): Returns: Current value. Type must match the address bank: - bool for X, Y, C, T, CT, SC - - int for DS, DD, DH, TD, CTD, SD + - int for DS, DD, DH, TD, CTD, SD, XD, YD - float for DF - str for TXT (blank or single character) """ @@ -166,9 +166,9 @@ class MemoryDataProvider: | Bank Data Type | Default | |----------------|---------| | `bool` (X, Y, C, T, CT, SC) | `False` | -| `int16` (DS, TD) | `0` | +| `int16` (DS, TD, SD) | `0` | | `int32` (DD, CTD) | `0` | -| `int16` unsigned (DH, SD) | `0` | +| `WORD` unsigned (DH, XD, YD) | `0` | | `float` (DF) | `0.0` | | `str` (TXT) | `'\x00'` | diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 302cde2..f78a868 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -19,6 +19,7 @@ unpack_value, ) from .nicknames import DATA_TYPE_CODE_TO_STR, read_csv +from .validation import assert_runtime_value PlcValue = bool | int | float | str TValue_co = TypeVar("TValue_co", bound=PlcValue, covariant=True) @@ -133,17 +134,6 @@ def __repr__(self) -> str: "YD": "hex", } -# Map data type string -> accepted Python types for write validation -TYPE_MAP: dict[str, type | tuple[type, ...]] = { - "bool": bool, - "int16": int, - "int32": int, - "float": (int, float), - "hex": int, - "str": str, -} - - # ============================================================================== # Tag loading # ============================================================================== @@ -337,7 +327,7 @@ async def _write_single(self, index: int, value: bool | int | float | str) -> No bank = self._bank self._validate_index(index) self._validate_writable(index) - self._validate_type(value) + self._validate_value(index, value) if bank == "TXT": await self._write_txt(index, str(value)) @@ -362,7 +352,7 @@ async def _write_list(self, start: int, values: list) -> None: idx = start + i self._validate_index(idx) self._validate_writable(idx) - self._validate_type(v) + self._validate_value(idx, v) await self._write_txt(idx, str(v)) return @@ -371,7 +361,7 @@ async def _write_list(self, start: int, values: list) -> None: idx = start + i self._validate_index(idx) self._validate_writable(idx) - self._validate_type(v) + self._validate_value(idx, v) if self._mapping.is_coil: if self._bank_cfg.valid_ranges is not None: @@ -464,14 +454,9 @@ def _validate_writable(self, index: int) -> None: elif not mapping.is_writable: raise ValueError(f"{self._bank}{index} is not writable.") - def _validate_type(self, value: bool | int | float | str) -> None: - """Validate Python type matches expected bank type.""" - expected_type_str = _DATA_TYPE_STR[self._bank] - expected = TYPE_MAP[expected_type_str] - if not isinstance(value, expected): - raise ValueError( - f"Expected {self._bank} as {expected_type_str}, got {type(value).__name__}." - ) + def _validate_value(self, index: int, value: bool | int | float | str) -> None: + """Validate runtime write value for this bank and index.""" + assert_runtime_value(self._bank_cfg.data_type, value, bank=self._bank, index=index) def __repr__(self) -> str: return f"" diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py index f495ed7..a53a1ae 100644 --- a/src/pyclickplc/server.py +++ b/src/pyclickplc/server.py @@ -21,6 +21,7 @@ pack_value, unpack_value, ) +from .validation import assert_runtime_value # ============================================================================== # DataProvider Protocol @@ -58,11 +59,11 @@ class MemoryDataProvider: def __init__(self) -> None: self._data: dict[str, PlcValue] = {} - def _normalize(self, address: str) -> tuple[str, str]: - """Normalize address and return (normalized, bank).""" - bank, mdb = parse_address(address) - normalized = format_address_display(bank, mdb) - return normalized, bank + def _normalize(self, address: str) -> tuple[str, str, int]: + """Normalize address and return (normalized, bank, index).""" + bank, index = parse_address(address) + normalized = format_address_display(bank, index) + return normalized, bank, index def _default(self, bank: str) -> PlcValue: """Get default value for a bank.""" @@ -70,11 +71,12 @@ def _default(self, bank: str) -> PlcValue: return _DEFAULTS[data_type] def read(self, address: str) -> PlcValue: - normalized, bank = self._normalize(address) + normalized, bank, _index = self._normalize(address) return self._data.get(normalized, self._default(bank)) def write(self, address: str, value: PlcValue) -> None: - normalized, _bank = self._normalize(address) + normalized, bank, index = self._normalize(address) + assert_runtime_value(BANKS[bank].data_type, value, bank=bank, index=index) self._data[normalized] = value def get(self, address: str) -> PlcValue: @@ -202,8 +204,10 @@ def _get_registers(self, address: int, count: int) -> list[int]: high_addr = _format_plc_address("TXT", txt_base_index + 1) low_val = self.provider.read(low_addr) high_val = self.provider.read(high_addr) - low_byte = ord(str(low_val)) & 0xFF - high_byte = ord(str(high_val)) & 0xFF + low_text = str(low_val) + high_text = str(high_val) + low_byte = ord(low_text) & 0xFF if low_text else 0 + high_byte = ord(high_text) & 0xFF if high_text else 0 result.append(low_byte | (high_byte << 8)) i += 1 continue diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py index a5f6950..999b05c 100644 --- a/src/pyclickplc/validation.py +++ b/src/pyclickplc/validation.py @@ -5,6 +5,9 @@ from __future__ import annotations +import math +import struct + from .banks import ( DataType, ) @@ -179,3 +182,58 @@ def validate_initial_value( # Unknown data type return True, "" + + +def assert_runtime_value( + data_type: DataType, + value: object, + *, + bank: str, + index: int, +) -> None: + """Assert runtime write value validity for a specific PLC address. + + Raises ValueError with a deterministic, actionable message on invalid values. + """ + target = f"{bank}{index}" + + if data_type == DataType.BIT: + if type(value) is not bool: + raise ValueError(f"{target} value must be bool.") + return + + if data_type == DataType.INT: + if type(value) is not int or value < INT_MIN or value > INT_MAX: + raise ValueError(f"{target} value must be int in [{INT_MIN}, {INT_MAX}].") + return + + if data_type == DataType.INT2: + if type(value) is not int or value < INT2_MIN or value > INT2_MAX: + raise ValueError(f"{target} value must be int in [{INT2_MIN}, {INT2_MAX}].") + return + + if data_type == DataType.HEX: + if type(value) is not int or value < 0 or value > 0xFFFF: + raise ValueError(f"{target} must be WORD (0..65535).") + return + + if data_type == DataType.FLOAT: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"{target} value must be a finite float32.") + numeric_value = float(value) + if not math.isfinite(numeric_value): + raise ValueError(f"{target} value must be a finite float32.") + try: + struct.pack(" 127: + raise ValueError(f"{target} TXT value must be blank or a single ASCII character.") + return diff --git a/tests/test_client.py b/tests/test_client.py index 211b30c..fcc6d33 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -293,15 +293,67 @@ async def test_write_list(self): @pytest.mark.asyncio async def test_write_wrong_type_raises(self): plc = _make_plc() - with pytest.raises(ValueError, match="Expected"): + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): await plc.df.write(1, "string") @pytest.mark.asyncio async def test_write_float_to_int_raises(self): plc = _make_plc() - with pytest.raises(ValueError, match="Expected"): + with pytest.raises(ValueError, match="DS1 value must be int"): await plc.ds.write(1, 3.14) + @pytest.mark.asyncio + async def test_write_int16_overflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DS1 value must be int"): + await plc.ds.write(1, 32768) + + @pytest.mark.asyncio + async def test_write_int32_overflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DD1 value must be int"): + await plc.dd.write(1, 2147483648) + + @pytest.mark.asyncio + async def test_write_word_underflow_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DH1 must be WORD"): + await plc.dh.write(1, -1) + + @pytest.mark.asyncio + async def test_write_bool_for_numeric_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DS1 value must be int"): + await plc.ds.write(1, True) + + @pytest.mark.asyncio + async def test_write_nan_float_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + await plc.df.write(1, float("nan")) + + @pytest.mark.asyncio + async def test_write_inf_float_raises(self): + plc = _make_plc() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + await plc.df.write(1, float("inf")) + + @pytest.mark.asyncio + async def test_write_txt_too_long_raises(self): + plc = _make_plc() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + await plc.txt.write(1, "AB") + + @pytest.mark.asyncio + async def test_write_txt_non_ascii_raises(self): + plc = _make_plc() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + await plc.txt.write(1, "\u00e9") + @pytest.mark.asyncio async def test_write_not_writable_x(self): plc = _make_plc() diff --git a/tests/test_server.py b/tests/test_server.py index c3c6073..3699081 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from pyclickplc.banks import DataType from pyclickplc.modbus import modbus_to_plc_register, pack_value, plc_to_modbus from pyclickplc.server import ( @@ -111,6 +113,43 @@ def test_yd_default(self): p = MemoryDataProvider() assert p.get("YD0") == 0 + def test_set_rejects_out_of_range_int(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DS1 value must be int"): + p.set("DS1", 999999999999) + + def test_set_rejects_non_numeric_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", "abc") + + def test_set_rejects_nan_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", float("nan")) + + def test_set_rejects_inf_float(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + p.set("DF1", float("inf")) + + def test_set_rejects_invalid_txt(self): + p = MemoryDataProvider() + with pytest.raises( + ValueError, match="TXT1 TXT value must be blank or a single ASCII character" + ): + p.set("TXT1", "AB") + + def test_set_rejects_word_underflow(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DH1 must be WORD"): + p.set("DH1", -1) + + def test_set_rejects_bool_for_numeric(self): + p = MemoryDataProvider() + with pytest.raises(ValueError, match="DS1 value must be int"): + p.set("DS1", True) + # ============================================================================== # modbus_to_plc_register diff --git a/tests/test_validation.py b/tests/test_validation.py index 32ef55f..16c2b44 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,8 +1,13 @@ """Tests for pyclickplc.validation module.""" +import math + +import pytest + from pyclickplc.banks import DataType from pyclickplc.validation import ( FORBIDDEN_CHARS, + assert_runtime_value, validate_comment, validate_initial_value, validate_nickname, @@ -169,3 +174,29 @@ def test_txt_non_ascii(self): valid, error = validate_initial_value("\u00e9", DataType.TXT) assert valid is False assert "ASCII" in error + + +# ============================================================================== +# assert_runtime_value +# ============================================================================== + + +class TestAssertRuntimeValue: + def test_reject_bool_for_int(self): + with pytest.raises(ValueError, match="DS1 value must be int"): + assert_runtime_value(DataType.INT, True, bank="DS", index=1) + + def test_reject_non_finite_float(self): + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + assert_runtime_value(DataType.FLOAT, math.nan, bank="DF", index=1) + + def test_reject_overflow_float32(self): + with pytest.raises(ValueError, match="DF1 value must be a finite float32"): + assert_runtime_value(DataType.FLOAT, 1e100, bank="DF", index=1) + + def test_reject_invalid_txt(self): + with pytest.raises(ValueError, match="TXT1 TXT value must be blank or a single ASCII"): + assert_runtime_value(DataType.TXT, "AB", bank="TXT", index=1) + + def test_allow_blank_txt(self): + assert_runtime_value(DataType.TXT, "", bank="TXT", index=1) From f380de09ca41f330eabae054ae50bd33a6a0035b Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:52:13 -0500 Subject: [PATCH 23/55] feat(dataview): add CDV check helpers and tighten root API Add shared CDV verification helpers in pyclickplc.dataview: check_cdv_file(path) check_cdv_files(project_path) Reuse existing CDV parse/conversion logic for range, type-code, and writability checks. Add coverage for new CDV check helpers in test_dataview.py. Reduce pyclickplc.__all__ / top-level re-exports to a curated public surface. Intentionally do not expose TypeCode; keep CDV type constants internal (_CdvStorageCode). --- src/pyclickplc/__init__.py | 141 ++------------------------------- src/pyclickplc/dataview.py | 157 ++++++++++++++++++++++++++++++++++++- tests/test_dataview.py | 84 ++++++++++++++++++++ 3 files changed, 245 insertions(+), 137 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index a89f33a..c02bdea 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -3,193 +3,64 @@ from .addresses import ( AddressRecord, format_address_display, - get_addr_key, normalize_address, - parse_addr_key, parse_address, ) from .banks import ( BANKS, - BIT_ONLY_TYPES, - DATA_TYPE_DISPLAY, - DATA_TYPE_HINTS, - DEFAULT_RETENTIVE, - INTERLEAVED_PAIRS, - INTERLEAVED_TYPE_PAIRS, - MEMORY_TYPE_BASES, - MEMORY_TYPE_TO_DATA_TYPE, - NON_EDITABLE_TYPES, - PAIRED_RETENTIVE_TYPES, BankConfig, DataType, - is_valid_address, -) -from .blocks import ( - BlockRange, - BlockTag, - HasComment, - compute_all_block_ranges, - extract_block_name, - find_block_range_indices, - find_paired_tag_index, - format_block_tag, - get_all_block_names, - get_block_type, - is_block_name_available, - is_block_tag, - parse_block_tag, - strip_block_tag, - validate_block_span, ) from .client import ClickClient, ModbusResponse from .dataview import ( - MAX_DATAVIEW_ROWS, - MEMORY_TYPE_TO_CODE, - WRITABLE_SC, - WRITABLE_SD, DataviewRow, - create_empty_dataview, - datatype_to_display, - datatype_to_storage, - display_to_datatype, + check_cdv_file, + check_cdv_files, export_cdv, get_dataview_folder, - get_type_code_for_address, - is_address_writable, list_cdv_files, load_cdv, save_cdv, - storage_to_datatype, ) from .modbus import ( - MODBUS_MAPPINGS, - MODBUS_SIGNED, - MODBUS_WIDTH, - STRUCT_FORMATS, ModbusMapping, modbus_to_plc, - modbus_to_plc_register, pack_value, plc_to_modbus, unpack_value, ) -from .nicknames import ( - CSV_COLUMNS, - DATA_TYPE_CODE_TO_STR, - DATA_TYPE_STR_TO_CODE, - read_csv, - read_mdb_csv, - write_csv, -) +from .nicknames import read_csv, read_mdb_csv, write_csv from .server import ClickServer, MemoryDataProvider -from .validation import ( - COMMENT_MAX_LENGTH, - FLOAT_MAX, - FLOAT_MIN, - FORBIDDEN_CHARS, - INT2_MAX, - INT2_MIN, - INT_MAX, - INT_MIN, - NICKNAME_MAX_LENGTH, - RESERVED_NICKNAMES, - validate_comment, - validate_initial_value, - validate_nickname, -) +from .validation import validate_comment, validate_initial_value, validate_nickname __all__ = [ - # banks "BankConfig", "BANKS", "DataType", - "DATA_TYPE_DISPLAY", - "DATA_TYPE_HINTS", - "DEFAULT_RETENTIVE", - "INTERLEAVED_PAIRS", - "INTERLEAVED_TYPE_PAIRS", - "MEMORY_TYPE_BASES", - "MEMORY_TYPE_TO_DATA_TYPE", - "NON_EDITABLE_TYPES", - "PAIRED_RETENTIVE_TYPES", - "BIT_ONLY_TYPES", - "is_valid_address", - # addresses "AddressRecord", - "get_addr_key", - "parse_addr_key", "format_address_display", "parse_address", "normalize_address", - # blocks - "BlockTag", - "BlockRange", - "HasComment", - "parse_block_tag", - "get_block_type", - "is_block_tag", - "extract_block_name", - "strip_block_tag", - "format_block_tag", - "get_all_block_names", - "is_block_name_available", - "find_paired_tag_index", - "find_block_range_indices", - "compute_all_block_ranges", - "validate_block_span", - # client "ClickClient", "ModbusResponse", - # modbus "ModbusMapping", - "MODBUS_MAPPINGS", - "MODBUS_WIDTH", - "MODBUS_SIGNED", - "STRUCT_FORMATS", "plc_to_modbus", "modbus_to_plc", - "modbus_to_plc_register", "pack_value", "unpack_value", - # server "ClickServer", "MemoryDataProvider", - # dataview "DataviewRow", - "MEMORY_TYPE_TO_CODE", - "WRITABLE_SC", - "WRITABLE_SD", - "MAX_DATAVIEW_ROWS", - "get_type_code_for_address", - "is_address_writable", - "create_empty_dataview", - "storage_to_datatype", - "datatype_to_storage", - "datatype_to_display", - "display_to_datatype", + "check_cdv_file", + "check_cdv_files", "export_cdv", "load_cdv", "save_cdv", "get_dataview_folder", "list_cdv_files", - # nicknames - "CSV_COLUMNS", - "DATA_TYPE_STR_TO_CODE", - "DATA_TYPE_CODE_TO_STR", "read_csv", "write_csv", "read_mdb_csv", - # validation - "NICKNAME_MAX_LENGTH", - "COMMENT_MAX_LENGTH", - "FORBIDDEN_CHARS", - "RESERVED_NICKNAMES", - "INT_MIN", - "INT_MAX", - "INT2_MIN", - "INT2_MAX", - "FLOAT_MIN", - "FLOAT_MAX", "validate_nickname", "validate_comment", "validate_initial_value", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 83ac702..576f40d 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -1,8 +1,8 @@ """DataView model and CDV file I/O for CLICK PLC DataView files. Provides the DataviewRow dataclass, type code mappings, CDV file read/write, -and value conversion functions between CDV storage, native Python types, -and UI display strings. +value conversion functions between CDV storage, native Python types, +UI display strings, and CDV verification helpers. """ from __future__ import annotations @@ -12,6 +12,7 @@ from pathlib import Path from .addresses import format_address_display, parse_address +from .validation import FLOAT_MAX, FLOAT_MIN, INT2_MAX, INT2_MIN, INT_MAX, INT_MIN # Type codes used in CDV files to identify address types @@ -589,6 +590,158 @@ def export_cdv( save_cdv(path, rows, has_new_values, header) +def _validate_cdv_new_value( + new_value: str, + type_code: int, + address: str, + filename: str, + row_num: int, +) -> list[str]: + """Validate CDV new_value storage and logical ranges for a row.""" + issues: list[str] = [] + prefix = f"CDV {filename} row {row_num}: {address}" + + try: + if type_code == _CdvStorageCode.BIT: + if new_value not in ("0", "1"): + issues.append(f"{prefix} new_value '{new_value}' invalid for BIT (must be 0 or 1)") + return issues + + if type_code == _CdvStorageCode.INT: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for INT storage") + return issues + converted = storage_to_datatype(new_value, type_code) + if not isinstance(converted, int): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT") + return issues + if converted < INT_MIN or converted > INT_MAX: + issues.append( + f"{prefix} new_value converts to {converted}, " + f"outside INT range ({INT_MIN} to {INT_MAX})" + ) + return issues + + if type_code == _CdvStorageCode.INT2: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for INT2 storage") + return issues + converted = storage_to_datatype(new_value, type_code) + if not isinstance(converted, int): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT2") + return issues + if converted < INT2_MIN or converted > INT2_MAX: + issues.append( + f"{prefix} new_value converts to {converted}, " + f"outside INT2 range ({INT2_MIN} to {INT2_MAX})" + ) + return issues + + if type_code == _CdvStorageCode.HEX: + raw = int(new_value) + if raw < 0 or raw > 0xFFFF: + issues.append(f"{prefix} new_value '{new_value}' out of range for HEX (0-65535)") + return issues + + if type_code == _CdvStorageCode.FLOAT: + raw = int(new_value) + if raw < 0 or raw > 0xFFFFFFFF: + issues.append(f"{prefix} new_value '{new_value}' invalid for FLOAT storage") + return issues + converted = storage_to_datatype(new_value, type_code) + if not isinstance(converted, float): + issues.append(f"{prefix} new_value '{new_value}' failed to convert to FLOAT") + return issues + if converted < FLOAT_MIN or converted > FLOAT_MAX: + issues.append( + f"{prefix} new_value converts to {converted}, outside FLOAT range" + ) + return issues + + if type_code == _CdvStorageCode.TXT: + raw = int(new_value) + if raw < 0 or raw > 127: + issues.append( + f"{prefix} new_value '{new_value}' out of range for TXT (0-127 ASCII)" + ) + return issues + + except ValueError: + issues.append(f"{prefix} new_value '{new_value}' is not a valid number") + + return issues + + +def check_cdv_file(path: Path | str) -> list[str]: + """Validate a single CDV file and return issue strings.""" + issues: list[str] = [] + path = Path(path) + filename = path.name + + try: + rows, _has_new_values, _header = load_cdv(path) + except Exception as exc: # pragma: no cover - exercised by caller tests + return [f"CDV {filename}: Error loading file - {exc}"] + + for i, row in enumerate(rows): + if row.is_empty: + continue + + row_num = i + 1 + + try: + memory_type, _mdb_address = parse_address(row.address) + except ValueError: + issues.append(f"CDV {filename} row {row_num}: Invalid address format '{row.address}'") + continue + + if memory_type not in MEMORY_TYPE_TO_CODE: + issues.append(f"CDV {filename} row {row_num}: Unknown memory type '{memory_type}'") + continue + + expected_code = get_type_code_for_address(row.address) + if expected_code is not None and row.type_code != expected_code: + issues.append( + f"CDV {filename} row {row_num}: Type code mismatch for {row.address} " + f"(has {row.type_code}, expected {expected_code})" + ) + + if row.new_value: + issues.extend( + _validate_cdv_new_value( + row.new_value, row.type_code, row.address, filename, row_num + ) + ) + if not is_address_writable(row.address): + issues.append( + f"CDV {filename} row {row_num}: {row.address} has new_value " + f"but address is not writable" + ) + + return issues + + +def check_cdv_files(project_path: Path | str) -> tuple[list[str], int]: + """Validate all CDV files in a project DataView folder.""" + issues: list[str] = [] + files_checked = 0 + + try: + dataview_folder = get_dataview_folder(project_path) + if dataview_folder is None: + return issues, files_checked + + for cdv_path in list_cdv_files(dataview_folder): + files_checked += 1 + issues.extend(check_cdv_file(cdv_path)) + except Exception as exc: + issues.append(f"CDV: Error accessing dataview folder - {exc}") + + return issues, files_checked + + def get_dataview_folder(project_path: Path | str) -> Path | None: """Get the DataView folder for a CLICK project. diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 9f10e29..db49efe 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -8,6 +8,8 @@ WRITABLE_SD, DataviewRow, _CdvStorageCode, + check_cdv_file, + check_cdv_files, create_empty_dataview, datatype_to_display, datatype_to_storage, @@ -526,3 +528,85 @@ def test_save_with_new_values(self, tmp_path): loaded_rows, has_new_values, _header = load_cdv(cdv) assert has_new_values is True assert loaded_rows[0].new_value == "1" + + +class TestCheckCdv: + def test_check_cdv_file_valid(self, tmp_path): + cdv = tmp_path / "valid.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].type_code = TypeCode.BIT + save_cdv(cdv, rows, has_new_values=False) + + assert check_cdv_file(cdv) == [] + + def test_check_cdv_file_invalid_address(self, tmp_path): + cdv = tmp_path / "invalid-address.cdv" + rows = create_empty_dataview() + rows[0].address = "INVALID" + rows[0].type_code = TypeCode.BIT + save_cdv(cdv, rows, has_new_values=False) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "Invalid address format" in issues[0] + + def test_check_cdv_file_type_mismatch(self, tmp_path): + cdv = tmp_path / "mismatch.cdv" + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].type_code = TypeCode.BIT + save_cdv(cdv, rows, has_new_values=False) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "Type code mismatch" in issues[0] + + def test_check_cdv_file_invalid_new_value_bit(self, tmp_path): + cdv = tmp_path / "invalid-bit.cdv" + rows = create_empty_dataview() + rows[0].address = "X001" + rows[0].type_code = TypeCode.BIT + rows[0].new_value = "2" + save_cdv(cdv, rows, has_new_values=True) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "invalid for BIT" in issues[0] + + def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): + cdv = tmp_path / "non-writable.cdv" + rows = create_empty_dataview() + rows[0].address = "XD0" + rows[0].type_code = TypeCode.HEX + rows[0].new_value = "1" + save_cdv(cdv, rows, has_new_values=True) + + issues = check_cdv_file(cdv) + assert len(issues) == 1 + assert "not writable" in issues[0] + + def test_check_cdv_files_counts_and_aggregation(self, tmp_path): + project = tmp_path / "MyProject" + dataview_dir = project / "CLICK (00010A98)" / "DataView" + dataview_dir.mkdir(parents=True) + + rows_ok = create_empty_dataview() + rows_ok[0].address = "X001" + rows_ok[0].type_code = TypeCode.BIT + save_cdv(dataview_dir / "ok.cdv", rows_ok, has_new_values=False) + + rows_bad = create_empty_dataview() + rows_bad[0].address = "DS1" + rows_bad[0].type_code = TypeCode.BIT + save_cdv(dataview_dir / "bad.cdv", rows_bad, has_new_values=False) + + issues, checked = check_cdv_files(project) + assert checked == 2 + assert len(issues) == 1 + assert "Type code mismatch" in issues[0] + + def test_check_cdv_files_missing_folder(self, tmp_path): + issues, checked = check_cdv_files(tmp_path / "NoProject") + assert checked == 0 + assert issues == [] From a222f77146cd540c7b22a39b1bc710daa7745965 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:56:53 -0500 Subject: [PATCH 24/55] docs: refocus README on core workflows and add MkDocs API reference generation - Rewrite `README.md` to be core-user focused: - Keep install + quickstart - Cover `ClickClient`, `ClickServer`/`MemoryDataProvider`, CSV/CDV I/O, and address helpers - Remove validation-helper and advanced API sections from primary README narrative - Keep `read_mdb_csv` out of README - Add MkDocs docs stack with generated API reference: - Add `mkdocs.yml` with Material theme, `mkdocstrings`, and `gen-files` plugin - Add `docs/gen_reference.py` to auto-generate module reference pages from `src/pyclickplc` - Add starter docs pages under `docs/` (`index` + core usage guides) - Add docs tooling: - Add `docs` dependency group in `pyproject.toml` (`mkdocs`, `mkdocs-material`, `mkdocstrings[python]`, `mkdocs-gen-files`) - Add `make docs-serve`, `make docs-build`, and `make docs-check` - Verification: - `mkdocs build --strict` passes with generated API pages in nav. --- Makefile | 10 +- README.md | 106 +++----- docs/gen_reference.py | 37 +++ docs/guides/client.md | 26 ++ docs/guides/files.md | 30 +++ docs/guides/server.md | 29 ++ docs/index.md | 21 ++ mkdocs.yml | 47 ++++ pyproject.toml | 6 + uv.lock | 596 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 833 insertions(+), 75 deletions(-) create mode 100644 docs/gen_reference.py create mode 100644 docs/guides/client.md create mode 100644 docs/guides/files.md create mode 100644 docs/guides/server.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml diff --git a/Makefile b/Makefile index 0f355f7..01acba3 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ .DEFAULT_GOAL := default -.PHONY: default install lint test upgrade build clean docs docs-check +.PHONY: default install lint test upgrade build clean docs-serve docs-build docs-check default: install lint test @@ -23,6 +23,14 @@ upgrade: build: uv build +docs-serve: + uv run --group docs mkdocs serve + +docs-build: + uv run --group docs mkdocs build --strict + +docs-check: docs-build + # Improved Windows detection ifeq ($(OS),Windows_NT) WINDOWS := 1 diff --git a/README.md b/README.md index d94ef14..e53f464 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pyclickplc -Utilities for AutomationDirect CLICK PLCs — address parsing, Modbus TCP client/server, nickname CSV and DataView CDV file I/O, and BlockTag comment parsing. +Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O. ## Installation @@ -11,9 +11,9 @@ pip install pyclickplc Requires Python 3.11+. The Modbus client and server depend on [pymodbus](https://github.com/pymodbus-dev/pymodbus). -## Modbus Client +## Quickstart -`ClickClient` is an async Modbus TCP driver with three access patterns: bank accessors, address strings, and tag nicknames. +`ClickClient` is an async Modbus TCP client for CLICK PLCs. ```python import asyncio @@ -21,35 +21,30 @@ from pyclickplc import ClickClient async def main(): async with ClickClient("192.168.1.10") as plc: - # Bank accessors — read/write by bank and index - result = await plc.ds.read(1) # Read DS1 → ModbusResponse({"DS1": 42}) - value = await plc.ds[1] # Shorthand → bare value (int) - await plc.ds.write(1, 100) # Write 100 to DS1 - result = await plc.ds.read(1, 10) # Read DS1-DS10 → ModbusResponse - await plc.y.write(1, [True, False]) # Write Y001=True, Y002=False - - # Address interface — read/write by address string - result = await plc.addr.read("df1") # Read DF1 → ModbusResponse({"DF1": 3.14}) - await plc.addr.write("df1", 3.14) # Write 3.14 to DF1 - result = await plc.addr.read("c1-c10") # Read C1-C10 → ModbusResponse - - # Tag interface — read/write by nickname (requires CSV file) - plc_with_tags = ClickClient("192.168.1.10", tag_filepath="nicknames.csv") - # ... use as context manager, then: - # value = await plc_with_tags.tag.read("MyTag") # → single value - # await plc_with_tags.tag.write("MyTag", 42) - # all_tags = await plc_with_tags.tag.read() # → {"MyTag": ..., ...} + # Bank accessor + await plc.ds.write(1, 100) + value = await plc.ds[1] # bare value + result = await plc.ds.read(1, 3) # DS1..DS3 (inclusive range) + + # Address interface + await plc.addr.write("df1", 3.14) + by_addr = await plc.addr.read("df1") + + # Tag interface (requires tag_filepath on client construction) + async with ClickClient("192.168.1.10", tag_filepath="nicknames.csv") as tagged: + await tagged.tag.write("MyTag", 42) + tag_value = await tagged.tag.read("MyTag") + all_tag_values = await tagged.tag.read() + tag_defs = tagged.tag.read_all() # synchronous tag metadata asyncio.run(main()) ``` -**Return types:** All `read()` calls return a `ModbusResponse` — a `Mapping` keyed by canonical uppercase address (`"DS1"`, `"X001"`) with normalized look-ups (`response["ds1"]` finds `"DS1"`). Use `await plc.ds[1]` for a bare value (`bool`, `int`, `float`, or `str`). Tag interface single reads return a bare value; tag read-all returns a plain `dict` keyed by tag name. - -Supported banks: `X`, `Y`, `C`, `T`, `CT`, `SC`, `DS`, `DD`, `DH`, `DF`, `XD`, `YD`, `TD`, `CTD`, `SD`, `TXT`. +All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`). Lookups are normalized (`resp["ds1"]` resolves `"DS1"`). Use `await plc.ds[1]` for a bare value. ## Modbus Server -`ClickServer` simulates a CLICK PLC over Modbus TCP. Supply a `DataProvider` to back the address space. +`ClickServer` simulates a CLICK PLC over Modbus TCP. `MemoryDataProvider` is the built-in in-memory backend. ```python import asyncio @@ -57,25 +52,22 @@ from pyclickplc import ClickServer, MemoryDataProvider async def main(): provider = MemoryDataProvider() - provider.set("DS1", 42) - provider.set("Y001", True) + provider.bulk_set({ + "DS1": 42, + "Y001": True, + }) - async with ClickServer(provider, host="localhost", port=5020) as server: + async with ClickServer(provider, host="localhost", port=5020): # Server is now accepting Modbus TCP connections await asyncio.sleep(60) asyncio.run(main()) ``` -Implement the `DataProvider` protocol for custom backends: - -```python -from pyclickplc.server import DataProvider, PlcValue - -class MyProvider: - def read(self, address: str) -> PlcValue: ... - def write(self, address: str, value: PlcValue) -> None: ... -``` +`MemoryDataProvider` convenience methods: +- `get(address)` +- `set(address, value)` +- `bulk_set({address: value, ...})` ## Nickname CSV Files @@ -93,8 +85,6 @@ for key, record in records.items(): count = write_csv("output.csv", records) ``` -MDB-format CSV files (exported by CLICK software) are also supported via `read_mdb_csv()`. - ## DataView CDV Files Read and write CLICK DataView `.cdv` files (UTF-16 LE format). @@ -129,41 +119,7 @@ display = format_address_display("XD", 1) # "XD0u" normalized = normalize_address("x1") # "X001" ``` -## Modbus Mapping - -Map between PLC addresses and raw Modbus coil/register addresses. - -```python -from pyclickplc import plc_to_modbus, modbus_to_plc, pack_value, unpack_value -from pyclickplc import DataType - -# PLC address → Modbus address -modbus_addr, reg_count = plc_to_modbus("DS", 1) # (0, 1) -modbus_addr, reg_count = plc_to_modbus("DF", 1) # (28672, 2) - -# Modbus address → PLC address -result = modbus_to_plc(0, is_coil=False) # ("DS", 1) -result = modbus_to_plc(0, is_coil=True) # ("X", 1) - -# Pack/unpack values for Modbus registers -regs = pack_value(3.14, DataType.FLOAT) # [low_word, high_word] -value = unpack_value(regs, DataType.FLOAT) # 3.14 -``` - -## Bank Definitions - -All 16 CLICK PLC memory banks are defined in `BANKS`: - -```python -from pyclickplc import BANKS, DataType - -ds = BANKS["DS"] -print(ds.min_addr, ds.max_addr, ds.data_type) # 1, 4500, DataType.INT - -# Sparse banks (X/Y) have valid_ranges for hardware slot validation -x = BANKS["X"] -print(x.valid_ranges) # ((1, 16), (21, 36), (101, 116), ...) -``` +Full API reference is available via MkDocs (including advanced modules). ## Development @@ -171,5 +127,7 @@ print(x.valid_ranges) # ((1, 16), (21, 36), (101, 116), ...) uv sync --all-extras --dev # Install dependencies make test # Run tests (uv run pytest) make lint # Lint (codespell, ruff, ty) +make docs-build # Build docs with mkdocstrings +make docs-serve # Serve docs locally make # All of the above ``` diff --git a/docs/gen_reference.py b/docs/gen_reference.py new file mode 100644 index 0000000..068d9da --- /dev/null +++ b/docs/gen_reference.py @@ -0,0 +1,37 @@ +"""Generate MkDocs API reference pages for pyclickplc modules.""" + +from pathlib import Path + +import mkdocs_gen_files + +PACKAGE = "pyclickplc" +ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = ROOT / "src" / PACKAGE + +overview_lines = [ + "# API Reference", + "", + "This section is generated from source using `mkdocstrings`.", + "", + "## Modules", +] + +for module_path in sorted(SRC_DIR.glob("*.py")): + if module_path.name == "__main__.py": + continue + + if module_path.name == "__init__.py": + identifier = PACKAGE + doc_rel_path = Path("reference/api") / f"{PACKAGE}.md" + else: + identifier = f"{PACKAGE}.{module_path.stem}" + doc_rel_path = Path("reference/api") / f"{module_path.stem}.md" + + with mkdocs_gen_files.open(doc_rel_path, "w") as fd: + fd.write(f"::: {identifier}\n") + + mkdocs_gen_files.set_edit_path(doc_rel_path, module_path.relative_to(ROOT)) + overview_lines.append(f"- [`{identifier}`](api/{doc_rel_path.name})") + +with mkdocs_gen_files.open("reference/index.md", "w") as fd: + fd.write("\n".join(overview_lines) + "\n") diff --git a/docs/guides/client.md b/docs/guides/client.md new file mode 100644 index 0000000..a2cdfb6 --- /dev/null +++ b/docs/guides/client.md @@ -0,0 +1,26 @@ +# Modbus Client + +`ClickClient` is the primary runtime API for reading and writing PLC values. + +```python +import asyncio +from pyclickplc import ClickClient + +async def main(): + async with ClickClient("192.168.1.10") as plc: + await plc.ds.write(1, 100) + one_value = await plc.ds[1] + many_values = await plc.ds.read(1, 5) + + await plc.addr.write("df1", 3.14) + df1 = await plc.addr.read("df1") + +asyncio.run(main()) +``` + +## Access Patterns + +- Bank accessors: `plc.ds`, `plc.df`, `plc.y`, etc. +- String addresses: `plc.addr.read("DS1")`, `plc.addr.write("C1", True)` +- Tags (with `tag_filepath`): `plc.tag.read("Name")`, `plc.tag.write("Name", value)` + diff --git a/docs/guides/files.md b/docs/guides/files.md new file mode 100644 index 0000000..891a538 --- /dev/null +++ b/docs/guides/files.md @@ -0,0 +1,30 @@ +# File I/O + +## Nickname CSV + +```python +from pyclickplc import read_csv, write_csv + +records = read_csv("nicknames.csv") +count = write_csv("output.csv", records) +``` + +## DataView CDV + +```python +from pyclickplc import load_cdv, save_cdv + +rows, has_new_values, header = load_cdv("dataview.cdv") +save_cdv("output.cdv", rows, has_new_values, header) +``` + +## Address Helpers + +```python +from pyclickplc import format_address_display, normalize_address, parse_address + +bank, index = parse_address("X001") # ("X", 1) +display = format_address_display("X", 1) # "X001" +normalized = normalize_address("x1") # "X001" +``` + diff --git a/docs/guides/server.md b/docs/guides/server.md new file mode 100644 index 0000000..7c8a06f --- /dev/null +++ b/docs/guides/server.md @@ -0,0 +1,29 @@ +# Modbus Server + +`ClickServer` simulates a CLICK PLC over Modbus TCP. + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider + +async def main(): + provider = MemoryDataProvider() + provider.bulk_set( + { + "DS1": 42, + "Y001": True, + } + ) + + async with ClickServer(provider, host="localhost", port=5020): + await asyncio.sleep(60) + +asyncio.run(main()) +``` + +`MemoryDataProvider` helper methods: + +- `get(address)` +- `set(address, value)` +- `bulk_set({...})` + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..27862b5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +# pyclickplc Docs + +`pyclickplc` provides tools for AutomationDirect CLICK PLCs: + +- Async Modbus TCP client (`ClickClient`) +- Modbus TCP simulator (`ClickServer`, `MemoryDataProvider`) +- Address parsing/normalization helpers +- CLICK nickname CSV and DataView CDV file I/O + +## Start Here + +- Core usage guides are under `Core Usage` in the left nav. +- Full API docs are generated under `API Reference`. + +## Local Docs Commands + +```bash +make docs-serve +make docs-build +``` + diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..1e32542 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,47 @@ +site_name: pyclickplc +site_description: Documentation for pyclickplc +repo_url: https://github.com/ssweber/pyclickplc + +theme: + name: material + +nav: + - Home: index.md + - Core Usage: + - Modbus Client: guides/client.md + - Modbus Server: guides/server.md + - File I/O: guides/files.md + - API Reference: + - Overview: reference/index.md + - Modules: + - pyclickplc: reference/api/pyclickplc.md + - addresses: reference/api/addresses.md + - banks: reference/api/banks.md + - blocks: reference/api/blocks.md + - client: reference/api/client.md + - dataview: reference/api/dataview.md + - modbus: reference/api/modbus.md + - nicknames: reference/api/nicknames.md + - server: reference/api/server.md + - validation: reference/api/validation.md + +plugins: + - search + - gen-files: + scripts: + - docs/gen_reference.py + - mkdocstrings: + handlers: + python: + paths: + - src + options: + filters: + - "!^_" + heading_level: 2 + inherited_members: false + members_order: source + separate_signature: true + show_root_heading: true + show_signature_annotations: true + show_source: false diff --git a/pyproject.toml b/pyproject.toml index 5ca2aca..a1b0011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,12 @@ dev = [ "ty>=0.0.14", "funlog>=0.2.0", ] +docs = [ + "mkdocs>=1.6.1", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.29.0", + "mkdocs-gen-files>=0.5.0", +] [project.scripts] # Add script entry points here: diff --git a/uv.lock b/uv.lock index e04c965..0b2dad5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,123 @@ version = 1 revision = 3 requires-python = ">=3.11, <4.0" +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "codespell" version = "2.4.1" @@ -29,6 +146,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/af/420b08227bd021f80008520aa20a001120e71042342801d0c782a6615a39/funlog-0.2.1-py3-none-any.whl", hash = "sha256:eed8d206c21ee8dc96137b4df51689470682d4700f6f99a1a6133a0e065f3798", size = 9399, upload-time = "2025-03-28T18:55:43.733Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, +] + +[[package]] +name = "griffecli" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -38,6 +208,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -50,6 +241,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -59,6 +324,146 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-gen-files" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/35/f26349f7fa18414eb2e25d75a6fa9c7e3186c36e1d227c0b2d785a7bd5c4/mkdocs_gen_files-0.6.0.tar.gz", hash = "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e", size = 8642, upload-time = "2025-11-23T12:13:22.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/ec/72417415563c60ae01b36f0d497f1f4c803972f447ef4fb7f7746d6e07db/mkdocs_gen_files-0.6.0-py3-none-any.whl", hash = "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2", size = 8182, upload-time = "2025-11-23T12:13:20.756Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/84/78243847ad9d5c21d30a2842720425b17e880d99dfe824dee11d6b2149b4/mkdocstrings_python-2.0.2.tar.gz", hash = "sha256:4a32ccfc4b8d29639864698e81cfeb04137bce76bb9f3c251040f55d4b6e1ad8", size = 199124, upload-time = "2026-02-09T15:12:01.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/31/7ee938abbde2322e553a2cb5f604cdd1e4728e08bba39c7ee6fae9af840b/mkdocstrings_python-2.0.2-py3-none-any.whl", hash = "sha256:31241c0f43d85a69306d704d5725786015510ea3f3c4bdfdb5a5731d83cdc2b0", size = 104900, upload-time = "2026-02-09T15:12:00.166Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -68,6 +473,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -94,6 +526,12 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-gen-files" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] [package.metadata] requires-dist = [{ name = "pymodbus", specifier = ">=3.8" }] @@ -108,6 +546,12 @@ dev = [ { name = "ruff", specifier = ">=0.11.0" }, { name = "ty", specifier = ">=0.0.14" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, + { name = "mkdocs-material", specifier = ">=9.6.0" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.0" }, +] [[package]] name = "pygments" @@ -118,6 +562,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, +] + [[package]] name = "pymodbus" version = "3.11.4" @@ -156,6 +613,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.3.2" @@ -194,6 +745,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "ty" version = "0.0.14" @@ -226,3 +786,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] From 502ca2ee3b16e54913cf5586dc9abc9bc00ca8d1 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:15:48 -0500 Subject: [PATCH 25/55] feat: separate port arg --- README.md | 4 +-- docs/guides/client.md | 2 +- src/pyclickplc/client.py | 54 ++++++++++++++++++++++++++++----------- tests/test_client.py | 15 ++++++++++- tests/test_integration.py | 2 +- 5 files changed, 57 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e53f464..daa3e49 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ import asyncio from pyclickplc import ClickClient async def main(): - async with ClickClient("192.168.1.10") as plc: + async with ClickClient("192.168.1.10", 502) as plc: # Bank accessor await plc.ds.write(1, 100) value = await plc.ds[1] # bare value @@ -31,7 +31,7 @@ async def main(): by_addr = await plc.addr.read("df1") # Tag interface (requires tag_filepath on client construction) - async with ClickClient("192.168.1.10", tag_filepath="nicknames.csv") as tagged: + async with ClickClient("192.168.1.10", 502, tag_filepath="nicknames.csv") as tagged: await tagged.tag.write("MyTag", 42) tag_value = await tagged.tag.read("MyTag") all_tag_values = await tagged.tag.read() diff --git a/docs/guides/client.md b/docs/guides/client.md index a2cdfb6..b045b15 100644 --- a/docs/guides/client.md +++ b/docs/guides/client.md @@ -7,7 +7,7 @@ import asyncio from pyclickplc import ClickClient async def main(): - async with ClickClient("192.168.1.10") as plc: + async with ClickClient("192.168.1.10", 502) as plc: await plc.ds.write(1, 100) one_value = await plc.ds[1] many_values = await plc.ds.read(1, 5) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index f78a868..060f0ef 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -569,19 +569,24 @@ class ClickClient: def __init__( self, - address: str, + host: str, + port: int = 502, tag_filepath: str = "", timeout: int = 1, + device_id: int = 1, ) -> None: - # Parse host:port - if ":" in address: - host, port_str = address.rsplit(":", 1) + # Backwards compatibility for legacy "host:port" first argument. + if ":" in host and port == 502: + host, port_str = host.rsplit(":", 1) port = int(port_str) - else: - host = address - port = 502 + + if not (1 <= port <= 65535): + raise ValueError("port must be in [1, 65535]") + if not (0 <= device_id <= 247): + raise ValueError("device_id must be in [0, 247]") self._client = AsyncModbusTcpClient(host, port=port, timeout=timeout) + self._device_id = device_id self._accessors: dict[str, AddressAccessor[PlcValue]] = {} self.tags: dict[str, dict[str, str]] = {} self.addr = AddressInterface(self) @@ -590,6 +595,15 @@ def __init__( if tag_filepath: self.tags = _load_tags(tag_filepath) + async def _call_modbus(self, method: Any, /, **kwargs: Any) -> Any: + """Call pymodbus methods with device_id, falling back to legacy slave.""" + try: + return await method(device_id=self._device_id, **kwargs) + except TypeError as exc: + if "device_id" not in str(exc): + raise + return await method(slave=self._device_id, **kwargs) + @overload def _get_accessor(self, bank: BoolBankName) -> AddressAccessor[bool]: ... @@ -649,9 +663,11 @@ async def _read_coils(self, address: int, count: int, bank: str) -> list[bool]: """Read coils using appropriate function code.""" mapping = MODBUS_MAPPINGS[bank] if 2 in mapping.function_codes: - result = await self._client.read_discrete_inputs(address, count=count) + result = await self._call_modbus( + self._client.read_discrete_inputs, address=address, count=count + ) else: - result = await self._client.read_coils(address, count=count) + result = await self._call_modbus(self._client.read_coils, address=address, count=count) if result.isError(): raise OSError(f"Modbus read error at coil {address}: {result}") return list(result.bits[:count]) @@ -659,9 +675,11 @@ async def _read_coils(self, address: int, count: int, bank: str) -> list[bool]: async def _write_coils(self, address: int, values: list[bool]) -> None: """Write coils.""" if len(values) == 1: - result = await self._client.write_coil(address, values[0]) + result = await self._call_modbus( + self._client.write_coil, address=address, value=values[0] + ) else: - result = await self._client.write_coils(address, values) + result = await self._call_modbus(self._client.write_coils, address=address, values=values) if result.isError(): raise OSError(f"Modbus write error at coil {address}: {result}") @@ -669,9 +687,13 @@ async def _read_registers(self, address: int, count: int, bank: str) -> list[int """Read registers using appropriate function code.""" mapping = MODBUS_MAPPINGS[bank] if 4 in mapping.function_codes: - result = await self._client.read_input_registers(address, count=count) + result = await self._call_modbus( + self._client.read_input_registers, address=address, count=count + ) else: - result = await self._client.read_holding_registers(address, count=count) + result = await self._call_modbus( + self._client.read_holding_registers, address=address, count=count + ) if result.isError(): raise OSError(f"Modbus read error at register {address}: {result}") return list(result.registers[:count]) @@ -679,8 +701,10 @@ async def _read_registers(self, address: int, count: int, bank: str) -> list[int async def _write_registers(self, address: int, values: list[int]) -> None: """Write registers.""" if len(values) == 1: - result = await self._client.write_register(address, values[0]) + result = await self._call_modbus( + self._client.write_register, address=address, value=values[0] + ) else: - result = await self._client.write_registers(address, values) + result = await self._call_modbus(self._client.write_registers, address=address, values=values) if result.isError(): raise OSError(f"Modbus write error at register {address}: {result}") diff --git a/tests/test_client.py b/tests/test_client.py index fcc6d33..e827447 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -27,7 +27,7 @@ def _make_plc(tag_filepath: str = "") -> ClickClient: """Create a ClickClient without connecting.""" - plc = ClickClient("localhost:5020", tag_filepath=tag_filepath) + plc = ClickClient("localhost", 5020, tag_filepath=tag_filepath) # Mock internal transport methods _set_read_coils(plc, [False]) _set_write_coils(plc) @@ -82,16 +82,29 @@ class TestClickClient: @pytest.mark.asyncio async def test_construction(self): plc = ClickClient("192.168.1.100") + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 502 assert plc.addr is not None assert plc.tag is not None assert plc.tags == {} @pytest.mark.asyncio async def test_construction_with_port(self): + plc = ClickClient("192.168.1.100", 5020) + assert plc._client.comm_params.host == "192.168.1.100" + assert plc._client.comm_params.port == 5020 + + @pytest.mark.asyncio + async def test_construction_legacy_host_port_string(self): plc = ClickClient("192.168.1.100:5020") assert plc._client.comm_params.host == "192.168.1.100" assert plc._client.comm_params.port == 5020 + @pytest.mark.asyncio + async def test_construction_with_device_id(self): + plc = ClickClient("192.168.1.100", 5020, device_id=7) + assert plc._device_id == 7 + @pytest.mark.asyncio async def test_getattr_df(self): plc = _make_plc() diff --git a/tests/test_integration.py b/tests/test_integration.py index f147307..757a4f8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -25,7 +25,7 @@ async def plc_fixture(): async with ClickServer(provider, port=TEST_PORT): # Small delay for server to be ready await asyncio.sleep(0.1) - async with ClickClient(f"localhost:{TEST_PORT}") as plc: + async with ClickClient("localhost", TEST_PORT) as plc: yield plc, provider From 83fdba8c767e7e141baa1ab96bf11f8f0d950d43 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:16:20 -0500 Subject: [PATCH 26/55] fix(modbus): correct packed TXT register reverse mapping in server paths **Description** `TXT` values are packed as 2 chars per Modbus register (little-endian byte layout), but reverse register mapping treated `TXT` as linear 1:1 register-to-address. This caused high-index writes like `TXT101` to land on `TXT51`/`TXT52` in server/provider routing. This change fixes TXT mapping consistency across forward/reverse paths: - `plc_to_modbus("TXT", index)` now maps via `(index - 1) // 2` - reverse register mapping (`modbus_to_plc`, `modbus_to_plc_register`) now returns the odd TXT base index for each packed register pair Added regressions to cover: - forward mapping (`TXT2` shares register with `TXT1`, `TXT1000` address) - reverse mapping (`36865 -> TXT3`, `37363 -> TXT999`) - server context read/write at high TXT indices (`TXT101`/`TXT102`) This restores correct server-side TXT routing for packed character registers. --- src/pyclickplc/modbus.py | 22 ++++++++++++++++++++++ tests/test_modbus.py | 14 +++++++++++++- tests/test_server.py | 20 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pyclickplc/modbus.py b/src/pyclickplc/modbus.py index 5d06c3c..a1a19d6 100644 --- a/src/pyclickplc/modbus.py +++ b/src/pyclickplc/modbus.py @@ -248,6 +248,10 @@ def plc_to_modbus(bank: str, index: int) -> tuple[int, int]: # Standard coils return mapping.base + (index - 1), 1 + # TXT is packed two chars per register. + if bank == "TXT": + return mapping.base + ((index - 1) // 2), 1 + # Registers: 0-based banks (XD/YD) use base + index, # 1-based banks use base + width * (index - 1) bank_cfg = BANKS[bank] @@ -311,6 +315,15 @@ def _reverse_register(address: int) -> tuple[str, int] | None: return bank, address - mapping.base continue + if bank == "TXT": + # TXT packs two addresses per register. Return the odd base index + # of the pair represented by this register. + txt_register_count = (max_mdb + 1) // 2 + end = mapping.base + txt_register_count + if mapping.base <= address < end: + return bank, (address - mapping.base) * 2 + 1 + continue + # Standard 1-based register banks end = mapping.base + mapping.width * max_mdb if mapping.base <= address < end: @@ -343,6 +356,15 @@ def modbus_to_plc_register(address: int) -> tuple[str, int, int] | None: return bank, address - mapping.base, 0 continue + if bank == "TXT": + # TXT packs two addresses per register. For a register address, + # return the odd base index of the pair. + txt_register_count = (max_mdb + 1) // 2 + end = mapping.base + txt_register_count + if mapping.base <= address < end: + return bank, (address - mapping.base) * 2 + 1, 0 + continue + # Standard 1-based register banks end = mapping.base + mapping.width * max_mdb if mapping.base <= address < end: diff --git a/tests/test_modbus.py b/tests/test_modbus.py index 5e5bd26..881345e 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -228,6 +228,12 @@ def test_dh1(self): def test_txt1(self): assert plc_to_modbus("TXT", 1) == (36864, 1) + def test_txt2_shares_register_with_txt1(self): + assert plc_to_modbus("TXT", 2) == (36864, 1) + + def test_txt1000(self): + assert plc_to_modbus("TXT", 1000) == (37363, 1) + def test_td1(self): assert plc_to_modbus("TD", 1) == (45056, 1) @@ -381,6 +387,12 @@ def test_reg_28674_df2(self): def test_reg_36864_txt1(self): assert modbus_to_plc(36864, is_coil=False) == ("TXT", 1) + def test_reg_36865_txt3(self): + assert modbus_to_plc(36865, is_coil=False) == ("TXT", 3) + + def test_reg_37363_txt999(self): + assert modbus_to_plc(37363, is_coil=False) == ("TXT", 999) + def test_reg_45056_td1(self): assert modbus_to_plc(45056, is_coil=False) == ("TD", 1) @@ -470,7 +482,7 @@ def test_coil_round_trip(self, bank: str, index: int): ("DF", 1), ("DF", 500), ("TXT", 1), - ("TXT", 1000), + ("TXT", 999), ("TD", 1), ("TD", 500), ("CTD", 1), diff --git a/tests/test_server.py b/tests/test_server.py index 3699081..1b864ae 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -190,6 +190,9 @@ def test_dh1(self): def test_txt1(self): assert modbus_to_plc_register(36864) == ("TXT", 1, 0) + def test_txt101(self): + assert modbus_to_plc_register(36914) == ("TXT", 101, 0) + def test_td1(self): assert modbus_to_plc_register(45056) == ("TD", 1, 0) @@ -325,6 +328,14 @@ def test_read_txt_register(self): result = ctx.getValues(3, 36864, 1) assert result == [ord("A") | (ord("B") << 8)] + def test_read_txt_register_high_index(self): + p = MemoryDataProvider() + p.set("TXT101", "Z") + p.set("TXT102", "Y") + ctx = _ClickDeviceContext(p) + result = ctx.getValues(3, 36914, 1) + assert result == [ord("Z") | (ord("Y") << 8)] + # ============================================================================== # _ClickDeviceContext — coil writes @@ -472,6 +483,15 @@ def test_fc16_write_txt(self): assert p.get("TXT1") == "H" assert p.get("TXT2") == "i" + def test_fc16_write_txt_high_index(self): + p = MemoryDataProvider() + ctx = _ClickDeviceContext(p) + reg_val = ord("Z") | (ord("Y") << 8) + result = ctx.setValues(16, 36914, [reg_val]) + assert result is None + assert p.get("TXT101") == "Z" + assert p.get("TXT102") == "Y" + # ============================================================================== # ClickServer construction From 22ef73a8fd28cd5c89c97b57301c8677153308c8 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:53:26 -0500 Subject: [PATCH 27/55] feat: Add `ClickHardwareProfile` ## Hardware Capability Profile `ClickHardwareProfile` provides table-driven ladder portability rules: - bank/address writability (`is_writable`) - fixed instruction-role compatibility (`valid_for_role`) - copy-family bank compatibility (`copy_compatible`) - compare compatibility (`compare_compatible`, `compare_constant_compatible`) ```python from pyclickplc import CLICK_HARDWARE_PROFILE CLICK_HARDWARE_PROFILE.is_writable("SC", 50) # True CLICK_HARDWARE_PROFILE.valid_for_role("T", "timer_done_bit") # True CLICK_HARDWARE_PROFILE.copy_compatible("single", "X", "Y") # True ``` --- README.md | 16 ++ mkdocs.yml | 1 + src/pyclickplc/__init__.py | 28 ++++ src/pyclickplc/capabilities.py | 271 +++++++++++++++++++++++++++++++++ tests/test_capabilities.py | 154 +++++++++++++++++++ 5 files changed, 470 insertions(+) create mode 100644 src/pyclickplc/capabilities.py create mode 100644 tests/test_capabilities.py diff --git a/README.md b/README.md index daa3e49..8b4ad33 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,22 @@ normalized = normalize_address("x1") # "X001" Full API reference is available via MkDocs (including advanced modules). +## Hardware Capability Profile + +`ClickHardwareProfile` provides table-driven ladder portability rules: +- bank/address writability (`is_writable`) +- fixed instruction-role compatibility (`valid_for_role`) +- copy-family bank compatibility (`copy_compatible`) +- compare compatibility (`compare_compatible`, `compare_constant_compatible`) + +```python +from pyclickplc import CLICK_HARDWARE_PROFILE + +CLICK_HARDWARE_PROFILE.is_writable("SC", 50) # True +CLICK_HARDWARE_PROFILE.valid_for_role("T", "timer_done_bit") # True +CLICK_HARDWARE_PROFILE.copy_compatible("single", "X", "Y") # True +``` + ## Development ```bash diff --git a/mkdocs.yml b/mkdocs.yml index 1e32542..c521010 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - pyclickplc: reference/api/pyclickplc.md - addresses: reference/api/addresses.md - banks: reference/api/banks.md + - capabilities: reference/api/capabilities.md - blocks: reference/api/blocks.md - client: reference/api/client.md - dataview: reference/api/dataview.md diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index c02bdea..acd2ed5 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -11,6 +11,21 @@ BankConfig, DataType, ) +from .capabilities import ( + CLICK_HARDWARE_PROFILE, + COMPARE_COMPATIBILITY, + COMPARE_CONSTANT_COMPATIBILITY, + COPY_COMPATIBILITY, + INSTRUCTION_ROLE_COMPATIBILITY, + LADDER_BANK_CAPABILITIES, + LADDER_WRITABLE_SC, + LADDER_WRITABLE_SD, + BankCapability, + ClickHardwareProfile, + CompareConstantKind, + CopyOperation, + InstructionRole, +) from .client import ClickClient, ModbusResponse from .dataview import ( DataviewRow, @@ -43,6 +58,19 @@ "normalize_address", "ClickClient", "ModbusResponse", + "InstructionRole", + "CopyOperation", + "CompareConstantKind", + "BankCapability", + "ClickHardwareProfile", + "CLICK_HARDWARE_PROFILE", + "LADDER_WRITABLE_SC", + "LADDER_WRITABLE_SD", + "LADDER_BANK_CAPABILITIES", + "INSTRUCTION_ROLE_COMPATIBILITY", + "COPY_COMPATIBILITY", + "COMPARE_COMPATIBILITY", + "COMPARE_CONSTANT_COMPATIBILITY", "ModbusMapping", "plc_to_modbus", "modbus_to_plc", diff --git a/src/pyclickplc/capabilities.py b/src/pyclickplc/capabilities.py new file mode 100644 index 0000000..42a070a --- /dev/null +++ b/src/pyclickplc/capabilities.py @@ -0,0 +1,271 @@ +"""Click hardware capability profile for ladder-portability validation. + +This module is table-driven and encodes the static compatibility rules used by +pyrung Click validation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .banks import BANKS + +InstructionRole = Literal[ + "timer_done_bit", + "timer_accumulator", + "timer_setpoint", + "counter_done_bit", + "counter_accumulator", + "counter_setpoint", + "copy_pointer", +] + +CopyOperation = Literal[ + "single", + "block", + "fill", + "pack_bits", + "pack_words", + "unpack_bits", + "unpack_words", +] + +CompareConstantKind = Literal["int1", "int2", "float", "hex", "text"] + + +@dataclass(frozen=True) +class BankCapability: + """Per-bank ladder validation write capability.""" + + writable: bool + writable_subset: frozenset[int] | None = None + + +# SC/SD writable subsets for ladder validation (different from Modbus writability). +LADDER_WRITABLE_SC: frozenset[int] = frozenset({50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) + +LADDER_WRITABLE_SD: frozenset[int] = frozenset( + { + 29, + 31, + 32, + 34, + 35, + 36, + 40, + 41, + 42, + 50, + 51, + 60, + 61, + 106, + 107, + 108, + 112, + 113, + 114, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 214, + 215, + } +) + +LADDER_BANK_CAPABILITIES: dict[str, BankCapability] = { + "X": BankCapability(writable=False), + "Y": BankCapability(writable=True), + "C": BankCapability(writable=True), + "T": BankCapability(writable=False), + "CT": BankCapability(writable=False), + "SC": BankCapability(writable=False, writable_subset=LADDER_WRITABLE_SC), + "DS": BankCapability(writable=True), + "DD": BankCapability(writable=True), + "DH": BankCapability(writable=True), + "DF": BankCapability(writable=True), + "XD": BankCapability(writable=False), + "YD": BankCapability(writable=False), + "TD": BankCapability(writable=True), + "CTD": BankCapability(writable=True), + "SD": BankCapability(writable=False, writable_subset=LADDER_WRITABLE_SD), + "TXT": BankCapability(writable=True), +} + +INSTRUCTION_ROLE_COMPATIBILITY: dict[InstructionRole, frozenset[str]] = { + "timer_done_bit": frozenset({"T"}), + "timer_accumulator": frozenset({"TD"}), + "timer_setpoint": frozenset({"DS"}), + "counter_done_bit": frozenset({"CT"}), + "counter_accumulator": frozenset({"CTD"}), + "counter_setpoint": frozenset({"DS", "DD"}), + "copy_pointer": frozenset({"DS"}), +} + +_BIT_SOURCES: frozenset[str] = frozenset({"X", "Y", "C", "T", "CT", "SC"}) +_BIT_DESTS: frozenset[str] = frozenset({"Y", "C"}) + +_REGISTER_SOURCES: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD", "TXT"}) +_SINGLE_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD", "TXT"}) + +_BLOCK_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD"}) +_BLOCK_TXT_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF"}) + +_FILL_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD"}) + +_PACK_BITS_16_SOURCES: frozenset[str] = frozenset({"X", "Y", "T", "CT", "SC"}) +_PACK_BITS_16_DESTS: frozenset[str] = frozenset({"DS", "DH"}) +_PACK_BITS_32_SOURCES: frozenset[str] = frozenset({"C"}) +_PACK_BITS_32_DESTS: frozenset[str] = frozenset({"DD", "DF"}) + +PACK_WORDS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( + (source, dest) for source in ("DS", "DH") for dest in ("DD", "DF") +) + +UNPACK_BITS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( + (source, dest) for source in ("DS", "DH", "DD", "DF") for dest in ("Y", "C") +) + +UNPACK_WORDS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( + (source, dest) for source in ("DD", "DF") for dest in ("DS", "DH") +) + +COPY_COMPATIBILITY: dict[CopyOperation, frozenset[tuple[str, str]]] = { + "single": frozenset( + [(source, dest) for source in _BIT_SOURCES for dest in _BIT_DESTS] + + [(source, dest) for source in _REGISTER_SOURCES for dest in _SINGLE_REGISTER_DESTS] + ), + "block": frozenset( + [(source, dest) for source in _BIT_SOURCES for dest in _BIT_DESTS] + + [ + (source, dest) + for source in ("DS", "DD", "DH", "DF", "SD") + for dest in _BLOCK_REGISTER_DESTS + ] + + [("TXT", dest) for dest in _BLOCK_TXT_DESTS] + ), + "fill": frozenset( + [ + (source, dest) + for source in ("DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD") + for dest in _FILL_REGISTER_DESTS + ] + + [("TXT", "TXT")] + ), + "pack_bits": frozenset( + [(source, dest) for source in _PACK_BITS_16_SOURCES for dest in _PACK_BITS_16_DESTS] + + [(source, dest) for source in _PACK_BITS_32_SOURCES for dest in _PACK_BITS_16_DESTS] + + [(source, dest) for source in _PACK_BITS_32_SOURCES for dest in _PACK_BITS_32_DESTS] + ), + "pack_words": PACK_WORDS_COMPATIBILITY, + "unpack_bits": UNPACK_BITS_COMPATIBILITY, + "unpack_words": UNPACK_WORDS_COMPATIBILITY, +} + +_HEX_COMPARE_BANKS: frozenset[str] = frozenset({"XD", "YD", "DH"}) +_NUMERIC_COMPARE_BANKS: frozenset[str] = frozenset({"TD", "CTD", "DS", "DD", "DF", "SD"}) +_TEXT_COMPARE_BANKS: frozenset[str] = frozenset({"TXT"}) + +COMPARE_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( + [(left, right) for left in _HEX_COMPARE_BANKS for right in _HEX_COMPARE_BANKS] + + [(left, right) for left in _NUMERIC_COMPARE_BANKS for right in _NUMERIC_COMPARE_BANKS] + + [("TXT", "TXT")] +) + +COMPARE_CONSTANT_COMPATIBILITY: dict[str, frozenset[CompareConstantKind]] = { + "XD": frozenset({"hex"}), + "YD": frozenset({"hex"}), + "DH": frozenset({"hex"}), + "TD": frozenset({"int1", "int2", "float"}), + "CTD": frozenset({"int1", "int2", "float"}), + "DS": frozenset({"int1", "int2", "float"}), + "DD": frozenset({"int1", "int2", "float"}), + "DF": frozenset({"int1", "int2", "float"}), + "SD": frozenset({"int1", "int2", "float"}), + "TXT": frozenset({"text"}), +} + + +class ClickHardwareProfile: + """Capability lookup API for Click portability validation.""" + + def is_writable(self, memory_type: str, address: int | None = None) -> bool: + if memory_type not in BANKS: + raise KeyError(f"Unknown bank: {memory_type!r}") + + capability = LADDER_BANK_CAPABILITIES[memory_type] + if capability.writable_subset is None: + return capability.writable + + if address is None: + return False + return address in capability.writable_subset + + def valid_for_role(self, memory_type: str, role: InstructionRole) -> bool: + if memory_type not in BANKS: + raise KeyError(f"Unknown bank: {memory_type!r}") + allowed = INSTRUCTION_ROLE_COMPATIBILITY.get(role) + if allowed is None: + raise KeyError(f"Unknown instruction role: {role!r}") + return memory_type in allowed + + def copy_compatible( + self, + operation: CopyOperation, + source_type: str, + dest_type: str, + ) -> bool: + if source_type not in BANKS: + raise KeyError(f"Unknown source bank: {source_type!r}") + if dest_type not in BANKS: + raise KeyError(f"Unknown destination bank: {dest_type!r}") + compatibility = COPY_COMPATIBILITY.get(operation) + if compatibility is None: + raise KeyError(f"Unknown copy operation: {operation!r}") + return (source_type, dest_type) in compatibility + + def compare_compatible(self, left_bank: str, right_bank: str) -> bool: + if left_bank not in BANKS: + raise KeyError(f"Unknown left bank: {left_bank!r}") + if right_bank not in BANKS: + raise KeyError(f"Unknown right bank: {right_bank!r}") + return (left_bank, right_bank) in COMPARE_COMPATIBILITY + + def compare_constant_compatible(self, bank: str, const_kind: CompareConstantKind) -> bool: + if bank not in BANKS: + raise KeyError(f"Unknown bank: {bank!r}") + compatibility = COMPARE_CONSTANT_COMPATIBILITY.get(bank) + if compatibility is None: + return False + if const_kind not in {"int1", "int2", "float", "hex", "text"}: + raise KeyError(f"Unknown constant kind: {const_kind!r}") + return const_kind in compatibility + + +CLICK_HARDWARE_PROFILE = ClickHardwareProfile() + +assert set(LADDER_BANK_CAPABILITIES) == set(BANKS), ( + "LADDER_BANK_CAPABILITIES keys must match BANKS keys" +) + +__all__ = [ + "InstructionRole", + "CopyOperation", + "CompareConstantKind", + "BankCapability", + "LADDER_WRITABLE_SC", + "LADDER_WRITABLE_SD", + "LADDER_BANK_CAPABILITIES", + "INSTRUCTION_ROLE_COMPATIBILITY", + "COPY_COMPATIBILITY", + "COMPARE_COMPATIBILITY", + "COMPARE_CONSTANT_COMPATIBILITY", + "ClickHardwareProfile", + "CLICK_HARDWARE_PROFILE", +] diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..c284b50 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,154 @@ +"""Tests for table-driven hardware capabilities.""" + +from __future__ import annotations + +import pytest + +from pyclickplc import ( + CLICK_HARDWARE_PROFILE, + COMPARE_COMPATIBILITY, + COMPARE_CONSTANT_COMPATIBILITY, + COPY_COMPATIBILITY, + INSTRUCTION_ROLE_COMPATIBILITY, + LADDER_WRITABLE_SC, + LADDER_WRITABLE_SD, + ClickHardwareProfile, +) + + +def test_profile_export_available(): + assert isinstance(CLICK_HARDWARE_PROFILE, ClickHardwareProfile) + + +@pytest.mark.parametrize( + ("memory_type", "address", "expected"), + [ + ("X", 1, False), + ("Y", 1, True), + ("C", 1, True), + ("T", 1, False), + ("CT", 1, False), + ("DS", 1, True), + ("DD", 1, True), + ("DH", 1, True), + ("DF", 1, True), + ("XD", 1, False), + ("YD", 1, False), + ("TD", 1, True), + ("CTD", 1, True), + ("TXT", 1, True), + ], +) +def test_is_writable_baseline(memory_type: str, address: int, expected: bool): + assert CLICK_HARDWARE_PROFILE.is_writable(memory_type, address) is expected + + +def test_is_writable_sc_subset(): + for address in LADDER_WRITABLE_SC: + assert CLICK_HARDWARE_PROFILE.is_writable("SC", address) is True + assert CLICK_HARDWARE_PROFILE.is_writable("SC", 1) is False + assert CLICK_HARDWARE_PROFILE.is_writable("SC", None) is False + + +def test_is_writable_sd_subset(): + for address in LADDER_WRITABLE_SD: + assert CLICK_HARDWARE_PROFILE.is_writable("SD", address) is True + assert CLICK_HARDWARE_PROFILE.is_writable("SD", 1) is False + assert CLICK_HARDWARE_PROFILE.is_writable("SD", None) is False + + +@pytest.mark.parametrize( + ("role", "ok_bank", "bad_bank"), + [ + ("timer_done_bit", "T", "C"), + ("timer_accumulator", "TD", "DS"), + ("timer_setpoint", "DS", "DD"), + ("counter_done_bit", "CT", "C"), + ("counter_accumulator", "CTD", "DD"), + ("counter_setpoint", "DD", "TD"), + ("copy_pointer", "DS", "DD"), + ], +) +def test_role_compatibility(role: str, ok_bank: str, bad_bank: str): + assert CLICK_HARDWARE_PROFILE.valid_for_role(ok_bank, role) is True + assert CLICK_HARDWARE_PROFILE.valid_for_role(bad_bank, role) is False + + +@pytest.mark.parametrize( + ("operation", "source", "dest", "expected"), + [ + ("single", "X", "Y", True), + ("single", "DS", "TXT", True), + ("single", "DS", "C", False), + ("block", "TXT", "DS", True), + ("block", "TXT", "TXT", False), + ("fill", "TXT", "TXT", True), + ("fill", "TXT", "DS", False), + ("pack_bits", "X", "DS", True), + ("pack_bits", "C", "DF", True), + ("pack_bits", "X", "DD", False), + ("pack_words", "DS", "DD", True), + ("pack_words", "DD", "DF", False), + ("unpack_bits", "DD", "Y", True), + ("unpack_bits", "DD", "DS", False), + ("unpack_words", "DF", "DH", True), + ("unpack_words", "DS", "DH", False), + ], +) +def test_copy_compatibility(operation: str, source: str, dest: str, expected: bool): + assert CLICK_HARDWARE_PROFILE.copy_compatible(operation, source, dest) is expected + + +@pytest.mark.parametrize( + ("left", "right", "expected"), + [ + ("DS", "DD", True), + ("DH", "XD", True), + ("TXT", "TXT", True), + ("TXT", "DD", False), + ("DH", "DD", False), + ], +) +def test_compare_compatibility(left: str, right: str, expected: bool): + assert CLICK_HARDWARE_PROFILE.compare_compatible(left, right) is expected + + +@pytest.mark.parametrize( + ("bank", "const_kind", "expected"), + [ + ("DS", "int1", True), + ("DS", "float", True), + ("DH", "hex", True), + ("TXT", "text", True), + ("DH", "int1", False), + ("TXT", "hex", False), + ], +) +def test_compare_constant_compatibility(bank: str, const_kind: str, expected: bool): + assert CLICK_HARDWARE_PROFILE.compare_constant_compatible(bank, const_kind) is expected + + +def test_lookup_tables_exported(): + assert "single" in COPY_COMPATIBILITY + assert "timer_done_bit" in INSTRUCTION_ROLE_COMPATIBILITY + assert ("DS", "DD") in COMPARE_COMPATIBILITY + assert "DS" in COMPARE_CONSTANT_COMPATIBILITY + + +@pytest.mark.parametrize( + "call", + [ + lambda: CLICK_HARDWARE_PROFILE.is_writable("ZZ", 1), + lambda: CLICK_HARDWARE_PROFILE.valid_for_role("ZZ", "timer_done_bit"), + lambda: CLICK_HARDWARE_PROFILE.valid_for_role("T", "bad_role"), # type: ignore[arg-type] + lambda: CLICK_HARDWARE_PROFILE.copy_compatible("bad_op", "DS", "DD"), # type: ignore[arg-type] + lambda: CLICK_HARDWARE_PROFILE.copy_compatible("single", "ZZ", "DD"), + lambda: CLICK_HARDWARE_PROFILE.copy_compatible("single", "DS", "ZZ"), + lambda: CLICK_HARDWARE_PROFILE.compare_compatible("ZZ", "DS"), + lambda: CLICK_HARDWARE_PROFILE.compare_constant_compatible("ZZ", "int1"), + lambda: CLICK_HARDWARE_PROFILE.compare_constant_compatible("DS", "bad_kind"), # type: ignore[arg-type] + ], +) +def test_unknown_inputs_raise_key_error(call): + with pytest.raises(KeyError): + call() From ce145df1bba42474d352c4bba68c52c472c656c8 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:25:57 -0500 Subject: [PATCH 28/55] refactor!: remove clicknick-specific MDB/CDV helper APIs from pyclickplc Description: Removed read_mdb_csv from pyclickplc and pyclickplc.nicknames. Removed export_cdv, get_dataview_folder, list_cdv_files from pyclickplc and pyclickplc.dataview. Made dataview folder/file discovery internal (_get_dataview_folder, _list_cdv_files) for check_cdv_files. Updated tests/docs/changelog to reflect the breaking API surface change. Footer: BREAKING CHANGE: read_mdb_csv, export_cdv, get_dataview_folder, and list_cdv_files are no longer public in pyclickplc. --- CHANGELOG.md | 10 ++++++ spec/ARCHITECTURE.md | 2 +- src/pyclickplc/__init__.py | 11 ++----- src/pyclickplc/dataview.py | 30 ++++-------------- src/pyclickplc/nicknames.py | 63 ------------------------------------- tests/test_nicknames.py | 36 ++------------------- 6 files changed, 22 insertions(+), 130 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d584978 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 2026-02-13 + +### Breaking Changes +- Removed `read_mdb_csv` from `pyclickplc` public API (`pyclickplc` and `pyclickplc.nicknames`). +- Removed `export_cdv`, `get_dataview_folder`, and `list_cdv_files` from `pyclickplc` public API (`pyclickplc` and `pyclickplc.dataview`). + +### Notes +- `load_cdv`, `save_cdv`, and `check_cdv_files` remain in `pyclickplc.dataview`. diff --git a/spec/ARCHITECTURE.md b/spec/ARCHITECTURE.md index 2a1ca64..06fbc03 100644 --- a/spec/ARCHITECTURE.md +++ b/spec/ARCHITECTURE.md @@ -110,7 +110,6 @@ From ClickNick extraction: - `CSV_COLUMNS`, `ADDRESS_PATTERN` - `DATA_TYPE_STR_TO_CODE` / `DATA_TYPE_CODE_TO_STR` - `read_csv(path)` → `dict[int, AddressRecord]` -- `read_mdb_csv(path)` → `dict[int, AddressRecord]` - `write_csv(path, records)` → count ### `dataview.py` — DataView CDV File I/O @@ -443,3 +442,4 @@ from pyclickplc.server import ClickServer, MemoryDataProvider, DataProvider - `load_nickname_file(path)` → `ClickProject` - `ClickProject` dataclass (records + banks + standalone tags) ? ``` + diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index acd2ed5..bcb406c 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -31,9 +31,6 @@ DataviewRow, check_cdv_file, check_cdv_files, - export_cdv, - get_dataview_folder, - list_cdv_files, load_cdv, save_cdv, ) @@ -44,7 +41,7 @@ plc_to_modbus, unpack_value, ) -from .nicknames import read_csv, read_mdb_csv, write_csv +from .nicknames import read_csv, write_csv from .server import ClickServer, MemoryDataProvider from .validation import validate_comment, validate_initial_value, validate_nickname @@ -81,15 +78,13 @@ "DataviewRow", "check_cdv_file", "check_cdv_files", - "export_cdv", "load_cdv", "save_cdv", - "get_dataview_folder", - "list_cdv_files", "read_csv", "write_csv", - "read_mdb_csv", "validate_nickname", "validate_comment", "validate_initial_value", ] + + diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 576f40d..1b142ab 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -570,26 +570,6 @@ def save_cdv( path.write_text(content, encoding="utf-16") -def export_cdv( - path: Path | str, - rows: list[DataviewRow], - has_new_values: bool, - header: str | None = None, -) -> None: - """Export a CDV file to a new location. - - This is identical to save_cdv but semantically indicates exporting - rather than saving to the original location. - - Args: - path: Path to export the CDV file. - rows: List of DataviewRow objects. - has_new_values: True if any rows have new values set. - header: Original header line to preserve. If None, uses default format. - """ - save_cdv(path, rows, has_new_values, header) - - def _validate_cdv_new_value( new_value: str, type_code: int, @@ -729,11 +709,11 @@ def check_cdv_files(project_path: Path | str) -> tuple[list[str], int]: files_checked = 0 try: - dataview_folder = get_dataview_folder(project_path) + dataview_folder = _get_dataview_folder(project_path) if dataview_folder is None: return issues, files_checked - for cdv_path in list_cdv_files(dataview_folder): + for cdv_path in _list_cdv_files(dataview_folder): files_checked += 1 issues.extend(check_cdv_file(cdv_path)) except Exception as exc: @@ -742,7 +722,7 @@ def check_cdv_files(project_path: Path | str) -> tuple[list[str], int]: return issues, files_checked -def get_dataview_folder(project_path: Path | str) -> Path | None: +def _get_dataview_folder(project_path: Path | str) -> Path | None: """Get the DataView folder for a CLICK project. The DataView folder is located at: {project_path}/CLICK ({unique_id})/DataView @@ -768,7 +748,7 @@ def get_dataview_folder(project_path: Path | str) -> Path | None: return None -def list_cdv_files(dataview_folder: Path | str) -> list[Path]: +def _list_cdv_files(dataview_folder: Path | str) -> list[Path]: """List all CDV files in a DataView folder. Args: @@ -782,3 +762,5 @@ def list_cdv_files(dataview_folder: Path | str) -> list[Path]: return [] return sorted(folder.glob("*.cdv"), key=lambda p: p.stem.lower()) + + diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py index e1fc7be..ca9a650 100644 --- a/src/pyclickplc/nicknames.py +++ b/src/pyclickplc/nicknames.py @@ -154,66 +154,3 @@ def format_quoted(text): return len(rows_to_write) -def read_mdb_csv(path: str | Path) -> dict[int, AddressRecord]: - """Read an MDB-dump CSV file into AddressRecords. - - The MDB-format CSV (exported by CLICK software) has columns: - AddrKey, MemoryType, Address, DataType, Nickname, Use, InitialValue, - Retentive, Comment. - - Only rows with a nickname or comment are included. - - Args: - path: Path to MDB-format CSV file. - - Returns: - Dict mapping addr_key (int) to AddressRecord. - """ - result: dict[int, AddressRecord] = {} - - with open(path, newline="", encoding="utf-8") as csvfile: - reader = csv.DictReader(csvfile) - - for row in reader: - nickname = row.get("Nickname", "").strip() - comment = row.get("Comment", "").strip() - if not nickname and not comment: - continue - - mem_type = row.get("MemoryType", "").strip() - if mem_type not in BANKS: - continue - - try: - address = int(row.get("Address", "0")) - except ValueError: - continue - - # Parse data type - try: - data_type = int(row.get("DataType", "0")) - except ValueError: - data_type = MEMORY_TYPE_TO_DATA_TYPE.get(mem_type, 0) - - # Parse retentive (0 or 1) - retentive_raw = row.get("Retentive", "").strip() - default_retentive = DEFAULT_RETENTIVE.get(mem_type, False) - retentive = retentive_raw in ("1",) if retentive_raw else default_retentive - - initial_value = row.get("InitialValue", "").strip() - - addr_key = get_addr_key(mem_type, address) - - record = AddressRecord( - memory_type=mem_type, - address=address, - nickname=nickname, - comment=comment, - initial_value=initial_value, - retentive=retentive, - data_type=data_type, - ) - - result[addr_key] = record - - return result diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index aa62505..6640688 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -7,7 +7,6 @@ DATA_TYPE_CODE_TO_STR, DATA_TYPE_STR_TO_CODE, read_csv, - read_mdb_csv, write_csv, ) @@ -136,39 +135,6 @@ def test_write_sorted_by_memory_type(self, tmp_path): assert "DS1" in lines[2] -class TestReadMdbCsv: - """Tests for read_mdb_csv function.""" - - def test_read_basic(self, tmp_path): - csv_path = tmp_path / "Address.csv" - csv_path.write_text( - "AddrKey,MemoryType,Address,DataType,Nickname,Use,InitialValue,Retentive,Comment\n" - "1,X,1,0,Input1,1,0,0,First input\n" - "100663297,DS,1,1,Temp,1,100,1,Temperature\n", - encoding="utf-8", - ) - - records = read_mdb_csv(csv_path) - assert len(records) == 2 - - x_records = [r for r in records.values() if r.memory_type == "X"] - assert len(x_records) == 1 - assert x_records[0].nickname == "Input1" - assert x_records[0].comment == "First input" - - def test_read_skips_empty_nicknames(self, tmp_path): - csv_path = tmp_path / "Address.csv" - csv_path.write_text( - "AddrKey,MemoryType,Address,DataType,Nickname,Use,InitialValue,Retentive,Comment\n" - "1,X,1,0,,,0,0,\n" - "2,X,2,0,Input2,1,0,0,Second\n", - encoding="utf-8", - ) - - records = read_mdb_csv(csv_path) - assert len(records) == 1 - - class TestRoundTrip: """Tests for write then read round-trip.""" @@ -211,3 +177,5 @@ def test_roundtrip(self, tmp_path): assert ds_records[0].nickname == "Speed" assert ds_records[0].comment == "Motor speed" assert ds_records[0].retentive is True + + From 83e444d9945f0dd5c87e56a3a063107150215912 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:03:40 -0500 Subject: [PATCH 29/55] refactor(dataview): remove project-level CDV file scanning helpers remove check_cdv_files, _get_dataview_folder, _list_cdv_files from dataview.py remove check_cdv_files from package exports in __init__.py remove related tests and update CHANGELOG.md note --- CHANGELOG.md | 2 +- src/pyclickplc/__init__.py | 4 -- src/pyclickplc/capabilities.py | 12 ++++-- src/pyclickplc/client.py | 8 +++- src/pyclickplc/dataview.py | 67 +--------------------------------- src/pyclickplc/nicknames.py | 2 - tests/test_dataview.py | 26 ------------- tests/test_nicknames.py | 2 - 8 files changed, 17 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d584978..67e5216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,4 @@ - Removed `export_cdv`, `get_dataview_folder`, and `list_cdv_files` from `pyclickplc` public API (`pyclickplc` and `pyclickplc.dataview`). ### Notes -- `load_cdv`, `save_cdv`, and `check_cdv_files` remain in `pyclickplc.dataview`. +- `load_cdv` and `save_cdv` remain in `pyclickplc.dataview`. diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index bcb406c..95db6b7 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -30,7 +30,6 @@ from .dataview import ( DataviewRow, check_cdv_file, - check_cdv_files, load_cdv, save_cdv, ) @@ -77,7 +76,6 @@ "MemoryDataProvider", "DataviewRow", "check_cdv_file", - "check_cdv_files", "load_cdv", "save_cdv", "read_csv", @@ -86,5 +84,3 @@ "validate_comment", "validate_initial_value", ] - - diff --git a/src/pyclickplc/capabilities.py b/src/pyclickplc/capabilities.py index 42a070a..8def4f2 100644 --- a/src/pyclickplc/capabilities.py +++ b/src/pyclickplc/capabilities.py @@ -43,7 +43,9 @@ class BankCapability: # SC/SD writable subsets for ladder validation (different from Modbus writability). -LADDER_WRITABLE_SC: frozenset[int] = frozenset({50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) +LADDER_WRITABLE_SC: frozenset[int] = frozenset( + {50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121} +) LADDER_WRITABLE_SD: frozenset[int] = frozenset( { @@ -111,8 +113,12 @@ class BankCapability: _BIT_SOURCES: frozenset[str] = frozenset({"X", "Y", "C", "T", "CT", "SC"}) _BIT_DESTS: frozenset[str] = frozenset({"Y", "C"}) -_REGISTER_SOURCES: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD", "TXT"}) -_SINGLE_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD", "TXT"}) +_REGISTER_SOURCES: frozenset[str] = frozenset( + {"DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD", "TXT"} +) +_SINGLE_REGISTER_DESTS: frozenset[str] = frozenset( + {"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD", "TXT"} +) _BLOCK_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD"}) _BLOCK_TXT_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF"}) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 060f0ef..8da4c30 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -679,7 +679,9 @@ async def _write_coils(self, address: int, values: list[bool]) -> None: self._client.write_coil, address=address, value=values[0] ) else: - result = await self._call_modbus(self._client.write_coils, address=address, values=values) + result = await self._call_modbus( + self._client.write_coils, address=address, values=values + ) if result.isError(): raise OSError(f"Modbus write error at coil {address}: {result}") @@ -705,6 +707,8 @@ async def _write_registers(self, address: int, values: list[int]) -> None: self._client.write_register, address=address, value=values[0] ) else: - result = await self._call_modbus(self._client.write_registers, address=address, values=values) + result = await self._call_modbus( + self._client.write_registers, address=address, values=values + ) if result.isError(): raise OSError(f"Modbus write error at register {address}: {result}") diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 1b142ab..b872236 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -635,9 +635,7 @@ def _validate_cdv_new_value( issues.append(f"{prefix} new_value '{new_value}' failed to convert to FLOAT") return issues if converted < FLOAT_MIN or converted > FLOAT_MAX: - issues.append( - f"{prefix} new_value converts to {converted}, outside FLOAT range" - ) + issues.append(f"{prefix} new_value converts to {converted}, outside FLOAT range") return issues if type_code == _CdvStorageCode.TXT: @@ -701,66 +699,3 @@ def check_cdv_file(path: Path | str) -> list[str]: ) return issues - - -def check_cdv_files(project_path: Path | str) -> tuple[list[str], int]: - """Validate all CDV files in a project DataView folder.""" - issues: list[str] = [] - files_checked = 0 - - try: - dataview_folder = _get_dataview_folder(project_path) - if dataview_folder is None: - return issues, files_checked - - for cdv_path in _list_cdv_files(dataview_folder): - files_checked += 1 - issues.extend(check_cdv_file(cdv_path)) - except Exception as exc: - issues.append(f"CDV: Error accessing dataview folder - {exc}") - - return issues, files_checked - - -def _get_dataview_folder(project_path: Path | str) -> Path | None: - """Get the DataView folder for a CLICK project. - - The DataView folder is located at: {project_path}/CLICK ({unique_id})/DataView - where {unique_id} is a hex identifier like "00010A98". - - Args: - project_path: Path to the CLICK project folder. - - Returns: - Path to the DataView folder, or None if not found. - """ - project_path = Path(project_path) - if not project_path.is_dir(): - return None - - # Look for CLICK (*) subdirectory - for child in project_path.iterdir(): - if child.is_dir() and child.name.startswith("CLICK ("): - dataview_path = child / "DataView" - if dataview_path.is_dir(): - return dataview_path - - return None - - -def _list_cdv_files(dataview_folder: Path | str) -> list[Path]: - """List all CDV files in a DataView folder. - - Args: - dataview_folder: Path to the DataView folder. - - Returns: - List of Path objects for each CDV file, sorted by name. - """ - folder = Path(dataview_folder) - if not folder.is_dir(): - return [] - - return sorted(folder.glob("*.cdv"), key=lambda p: p.stem.lower()) - - diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py index ca9a650..f223fb1 100644 --- a/src/pyclickplc/nicknames.py +++ b/src/pyclickplc/nicknames.py @@ -152,5 +152,3 @@ def format_quoted(text): csvfile.write(",".join(line_parts) + "\n") return len(rows_to_write) - - diff --git a/tests/test_dataview.py b/tests/test_dataview.py index db49efe..dfab830 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -9,7 +9,6 @@ DataviewRow, _CdvStorageCode, check_cdv_file, - check_cdv_files, create_empty_dataview, datatype_to_display, datatype_to_storage, @@ -585,28 +584,3 @@ def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): issues = check_cdv_file(cdv) assert len(issues) == 1 assert "not writable" in issues[0] - - def test_check_cdv_files_counts_and_aggregation(self, tmp_path): - project = tmp_path / "MyProject" - dataview_dir = project / "CLICK (00010A98)" / "DataView" - dataview_dir.mkdir(parents=True) - - rows_ok = create_empty_dataview() - rows_ok[0].address = "X001" - rows_ok[0].type_code = TypeCode.BIT - save_cdv(dataview_dir / "ok.cdv", rows_ok, has_new_values=False) - - rows_bad = create_empty_dataview() - rows_bad[0].address = "DS1" - rows_bad[0].type_code = TypeCode.BIT - save_cdv(dataview_dir / "bad.cdv", rows_bad, has_new_values=False) - - issues, checked = check_cdv_files(project) - assert checked == 2 - assert len(issues) == 1 - assert "Type code mismatch" in issues[0] - - def test_check_cdv_files_missing_folder(self, tmp_path): - issues, checked = check_cdv_files(tmp_path / "NoProject") - assert checked == 0 - assert issues == [] diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 6640688..04c0687 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -177,5 +177,3 @@ def test_roundtrip(self, tmp_path): assert ds_records[0].nickname == "Speed" assert ds_records[0].comment == "Motor speed" assert ds_records[0].retentive is True - - From 798dd3420f898e2fcb427db7a6f611e3acf55a44 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:07:20 -0500 Subject: [PATCH 30/55] lint: Fix literal typing in capability tables and tests ty check was failing due to overly broad str inference in literal-typed compatibility data and test parameters. Added _constant_kinds(*kinds: CompareConstantKind) -> frozenset[CompareConstantKind] in capabilities.py and used it for COMPARE_CONSTANT_COMPATIBILITY values, so they type-check as CompareConstantKind instead of str. Updated test_capabilities.py to use literal type aliases (InstructionRole, CopyOperation, CompareConstantKind) in parametrized test function signatures. Lint/type pipeline now passes (make lint). --- src/pyclickplc/capabilities.py | 25 +++++++++++++++---------- tests/test_capabilities.py | 9 ++++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/pyclickplc/capabilities.py b/src/pyclickplc/capabilities.py index 8def4f2..7b84bc8 100644 --- a/src/pyclickplc/capabilities.py +++ b/src/pyclickplc/capabilities.py @@ -184,17 +184,22 @@ class BankCapability: + [("TXT", "TXT")] ) + +def _constant_kinds(*kinds: CompareConstantKind) -> frozenset[CompareConstantKind]: + return frozenset(kinds) + + COMPARE_CONSTANT_COMPATIBILITY: dict[str, frozenset[CompareConstantKind]] = { - "XD": frozenset({"hex"}), - "YD": frozenset({"hex"}), - "DH": frozenset({"hex"}), - "TD": frozenset({"int1", "int2", "float"}), - "CTD": frozenset({"int1", "int2", "float"}), - "DS": frozenset({"int1", "int2", "float"}), - "DD": frozenset({"int1", "int2", "float"}), - "DF": frozenset({"int1", "int2", "float"}), - "SD": frozenset({"int1", "int2", "float"}), - "TXT": frozenset({"text"}), + "XD": _constant_kinds("hex"), + "YD": _constant_kinds("hex"), + "DH": _constant_kinds("hex"), + "TD": _constant_kinds("int1", "int2", "float"), + "CTD": _constant_kinds("int1", "int2", "float"), + "DS": _constant_kinds("int1", "int2", "float"), + "DD": _constant_kinds("int1", "int2", "float"), + "DF": _constant_kinds("int1", "int2", "float"), + "SD": _constant_kinds("int1", "int2", "float"), + "TXT": _constant_kinds("text"), } diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index c284b50..9c4dee1 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -13,6 +13,9 @@ LADDER_WRITABLE_SC, LADDER_WRITABLE_SD, ClickHardwareProfile, + CompareConstantKind, + CopyOperation, + InstructionRole, ) @@ -69,7 +72,7 @@ def test_is_writable_sd_subset(): ("copy_pointer", "DS", "DD"), ], ) -def test_role_compatibility(role: str, ok_bank: str, bad_bank: str): +def test_role_compatibility(role: InstructionRole, ok_bank: str, bad_bank: str): assert CLICK_HARDWARE_PROFILE.valid_for_role(ok_bank, role) is True assert CLICK_HARDWARE_PROFILE.valid_for_role(bad_bank, role) is False @@ -95,7 +98,7 @@ def test_role_compatibility(role: str, ok_bank: str, bad_bank: str): ("unpack_words", "DS", "DH", False), ], ) -def test_copy_compatibility(operation: str, source: str, dest: str, expected: bool): +def test_copy_compatibility(operation: CopyOperation, source: str, dest: str, expected: bool): assert CLICK_HARDWARE_PROFILE.copy_compatible(operation, source, dest) is expected @@ -124,7 +127,7 @@ def test_compare_compatibility(left: str, right: str, expected: bool): ("TXT", "hex", False), ], ) -def test_compare_constant_compatibility(bank: str, const_kind: str, expected: bool): +def test_compare_constant_compatibility(bank: str, const_kind: CompareConstantKind, expected: bool): assert CLICK_HARDWARE_PROFILE.compare_constant_compatible(bank, const_kind) is expected From 82366bb52183075a07c9b090ade98d69ca2264bc Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:18:11 -0500 Subject: [PATCH 31/55] refactor(dataview): unify on DataType and add New Value APIs Description: - replace `DataviewRow.type_code` with `data_type: DataType | None` - add `DataviewRow.update_data_type()` and clear `data_type` in `clear()` - rename `get_type_code_for_address()` to `get_data_type_for_address()` - remove type-code memory maps and use `banks.MEMORY_TYPE_TO_DATA_TYPE` - keep CDV integer storage codes only at file boundaries via: - `_CDV_CODE_TO_DATA_TYPE` - `_DATA_TYPE_TO_CDV_CODE` - update conversion functions to dispatch on `DataType`: - `storage_to_datatype` - `datatype_to_storage` - `datatype_to_display` - `display_to_datatype` - add New Value helpers: - `DataviewRow.new_value_display` - `DataviewRow.set_new_value_from_display()` - `validate_new_value()` - `DataviewRow.validate_new_value()` - update `check_cdv_file()` and `_validate_cdv_new_value()` to use `DataType` - update `tests/test_dataview.py` for `DataType` + new API coverage - export only `get_data_type_for_address` and `validate_new_value` at package root; keep conversion helpers out of `__all__` Verification: - `make lint` passes - `make test` passes --- scratchpad/dataview-unify-plan.md | 161 +++++++++++ src/pyclickplc/__init__.py | 4 + src/pyclickplc/dataview.py | 266 ++++++++++-------- tests/test_dataview.py | 444 +++++++++++++++++------------- 4 files changed, 569 insertions(+), 306 deletions(-) create mode 100644 scratchpad/dataview-unify-plan.md diff --git a/scratchpad/dataview-unify-plan.md b/scratchpad/dataview-unify-plan.md new file mode 100644 index 0000000..69ae470 --- /dev/null +++ b/scratchpad/dataview-unify-plan.md @@ -0,0 +1,161 @@ +# Dataview: Unify on DataType + Add New Value UI Support + +## Context + +ClickNick needs pyclickplc's dataview module to support live DataView editing with: +- **New Value** column: user-typed display strings, validated before acceptance +- **Live** column: values read via ClickClient, formatted for display +- Both columns use `datatype_to_display()` for rendering + +Currently `DataviewRow.type_code` stores CDV file-format integers (`_CdvStorageCode`: 768, 0, 256...), +which leak a file-format detail into the data model. The `DataType` enum in `banks.py` is the canonical +type system. This refactor unifies on `DataType` and adds the convenience API clicknick needs. + +--- + +## Part 1: Replace `type_code` with `DataType` + +### 1a. Add private CDV code ↔ DataType bridge dicts + +In `dataview.py`, keep `_CdvStorageCode` but add two private mappings used only by `load_cdv`/`save_cdv`: + +```python +_CDV_CODE_TO_DATA_TYPE: dict[int, DataType] = { + _CdvStorageCode.BIT: DataType.BIT, + _CdvStorageCode.INT: DataType.INT, + _CdvStorageCode.INT2: DataType.INT2, + _CdvStorageCode.HEX: DataType.HEX, + _CdvStorageCode.FLOAT: DataType.FLOAT, + _CdvStorageCode.TXT: DataType.TXT, +} +_DATA_TYPE_TO_CDV_CODE: dict[DataType, int] = {v: k for k, v in _CDV_CODE_TO_DATA_TYPE.items()} +``` + +### 1b. `DataviewRow.type_code: int` → `DataviewRow.data_type: DataType | None` + +- Field becomes `data_type: DataType | None = None` (None for empty rows) +- Remove `update_type_code()` method → replace with `update_data_type()` that uses `get_data_type_for_address()` +- `clear()` sets `data_type = None` + +### 1c. Rename `get_type_code_for_address()` → `get_data_type_for_address()` + +Returns `DataType | None`. Implementation: `parse_address()` → memory_type → `MEMORY_TYPE_TO_DATA_TYPE[memory_type]` (from `banks.py`, already exists). + +Remove `MEMORY_TYPE_TO_CODE` dict (replaced by `banks.MEMORY_TYPE_TO_DATA_TYPE`). +Remove `CODE_TO_MEMORY_TYPES` dict (unused outside of this mapping). + +### 1d. Conversion functions dispatch on `DataType` + +All four functions change signature from `type_code: int` to `data_type: DataType`: +- `storage_to_datatype(value, data_type)` — dispatch on `DataType.BIT`, `DataType.INT`, etc. +- `datatype_to_storage(value, data_type)` +- `datatype_to_display(value, data_type)` +- `display_to_datatype(value, data_type)` + +The switch statements change from `_CdvStorageCode.BIT` → `DataType.BIT` etc. + +### 1e. `_validate_cdv_new_value` dispatches on `DataType` + +Signature: `_validate_cdv_new_value(new_value, data_type, address, filename, row_num)`. + +### 1f. `load_cdv` / `save_cdv` handle CDV code conversion at the boundary + +- `load_cdv`: reads CDV integer → `_CDV_CODE_TO_DATA_TYPE[code]` → stores `DataType` on row +- `save_cdv`: reads `row.data_type` → `_DATA_TYPE_TO_CDV_CODE[data_type]` → writes CDV integer + +### 1g. `check_cdv_file` uses `DataType` + +Calls `get_data_type_for_address()` instead of `get_type_code_for_address()`. + +--- + +## Part 2: Add New Value Convenience API + +### 2a. `DataviewRow.new_value_display` property + +```python +@property +def new_value_display(self) -> str: + if not self.new_value or self.data_type is None: + return "" + native = storage_to_datatype(self.new_value, self.data_type) + return datatype_to_display(native, self.data_type) +``` + +### 2b. `DataviewRow.set_new_value_from_display(display_str)` method + +```python +def set_new_value_from_display(self, display_str: str) -> bool: + if not display_str: + self.new_value = "" + return True + if self.data_type is None: + return False + native = display_to_datatype(display_str, self.data_type) + if native is None: + return False + self.new_value = datatype_to_storage(native, self.data_type) + return True +``` + +### 2c. Standalone `validate_new_value()` function + +```python +def validate_new_value(display_str: str, data_type: DataType) -> tuple[bool, str]: + """Validate a user-entered display string for the New Value column.""" + if not display_str: + return True, "" + return validate_initial_value(display_str, data_type) +``` + +### 2d. `DataviewRow.validate_new_value()` method + +```python +def validate_new_value(self, display_str: str) -> tuple[bool, str]: + if not self.is_writable: + return False, "Read-only address" + if self.data_type is None: + return False, "No address set" + return validate_new_value(display_str, self.data_type) +``` + +--- + +## Part 3: Exports & Tests + +### 3a. `__init__.py` — add exports + +New public API: +- `get_data_type_for_address` +- `validate_new_value` +- `storage_to_datatype`, `datatype_to_storage`, `datatype_to_display`, `display_to_datatype` + +### 3b. `test_dataview.py` — update all tests + +- `TypeCode.BIT` → `DataType.BIT` etc. throughout +- `.type_code` → `.data_type` on all DataviewRow assertions +- Add new test classes: + - `TestValidateNewValue` — standalone function + - `TestDataviewRowValidateNewValue` — method on row + - `TestNewValueDisplay` — property + - `TestSetNewValueFromDisplay` — method + +--- + +## Files Modified + +| File | Summary | +|------|---------| +| `src/pyclickplc/dataview.py` | Core refactor + new API | +| `src/pyclickplc/__init__.py` | New exports | +| `tests/test_dataview.py` | Update all tests + new test classes | + +--- + +## Verification + +1. `make lint` — ruff + ty pass +2. `make test` — all tests pass (except 2 pre-existing T-bank failures in test_modbus.py) +3. Spot-check: `DataviewRow(address="DS1")` → `.update_data_type()` → `.data_type == DataType.INT` +4. Round-trip: `set_new_value_from_display("100")` → `.new_value_display == "100"` +5. Validation: `row.validate_new_value("abc")` → `(False, "Must be integer")` diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 95db6b7..b275bc4 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -30,8 +30,10 @@ from .dataview import ( DataviewRow, check_cdv_file, + get_data_type_for_address, load_cdv, save_cdv, + validate_new_value, ) from .modbus import ( ModbusMapping, @@ -76,6 +78,8 @@ "MemoryDataProvider", "DataviewRow", "check_cdv_file", + "get_data_type_for_address", + "validate_new_value", "load_cdv", "save_cdv", "read_csv", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index b872236..4c1b008 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -1,6 +1,6 @@ """DataView model and CDV file I/O for CLICK PLC DataView files. -Provides the DataviewRow dataclass, type code mappings, CDV file read/write, +Provides the DataviewRow dataclass, CDV file read/write, value conversion functions between CDV storage, native Python types, UI display strings, and CDV verification helpers. """ @@ -12,7 +12,16 @@ from pathlib import Path from .addresses import format_address_display, parse_address -from .validation import FLOAT_MAX, FLOAT_MIN, INT2_MAX, INT2_MIN, INT_MAX, INT_MIN +from .banks import MEMORY_TYPE_TO_DATA_TYPE, DataType +from .validation import ( + FLOAT_MAX, + FLOAT_MIN, + INT2_MAX, + INT2_MIN, + INT_MAX, + INT_MIN, + validate_initial_value, +) # Type codes used in CDV files to identify address types @@ -27,35 +36,16 @@ class _CdvStorageCode: TXT = 1024 -# Map memory type prefixes to their type codes -MEMORY_TYPE_TO_CODE: dict[str, int] = { - "X": _CdvStorageCode.BIT, - "Y": _CdvStorageCode.BIT, - "C": _CdvStorageCode.BIT, - "T": _CdvStorageCode.BIT, - "CT": _CdvStorageCode.BIT, - "SC": _CdvStorageCode.BIT, - "DS": _CdvStorageCode.INT, - "TD": _CdvStorageCode.INT, - "SD": _CdvStorageCode.INT, - "DD": _CdvStorageCode.INT2, - "CTD": _CdvStorageCode.INT2, - "DH": _CdvStorageCode.HEX, - "XD": _CdvStorageCode.HEX, - "YD": _CdvStorageCode.HEX, - "DF": _CdvStorageCode.FLOAT, - "TXT": _CdvStorageCode.TXT, +_CDV_CODE_TO_DATA_TYPE: dict[int, DataType] = { + _CdvStorageCode.BIT: DataType.BIT, + _CdvStorageCode.INT: DataType.INT, + _CdvStorageCode.INT2: DataType.INT2, + _CdvStorageCode.HEX: DataType.HEX, + _CdvStorageCode.FLOAT: DataType.FLOAT, + _CdvStorageCode.TXT: DataType.TXT, } +_DATA_TYPE_TO_CDV_CODE: dict[DataType, int] = {v: k for k, v in _CDV_CODE_TO_DATA_TYPE.items()} -# Reverse mapping: type code to list of memory types -CODE_TO_MEMORY_TYPES: dict[int, list[str]] = { - _CdvStorageCode.BIT: ["X", "Y", "C", "T", "CT", "SC"], - _CdvStorageCode.INT: ["DS", "TD", "SD"], - _CdvStorageCode.INT2: ["DD", "CTD"], - _CdvStorageCode.HEX: ["DH", "XD", "YD"], - _CdvStorageCode.FLOAT: ["DF"], - _CdvStorageCode.TXT: ["TXT"], -} # SC addresses that are writable (most SC are read-only system controls) WRITABLE_SC: frozenset[int] = frozenset({50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}) @@ -99,20 +89,21 @@ class _CdvStorageCode: MAX_DATAVIEW_ROWS = 100 -def get_type_code_for_address(address: str) -> int | None: - """Get the type code for an address. +def get_data_type_for_address(address: str) -> DataType | None: + """Get the DataType for an address. Args: address: Address string like "X001", "DS1" Returns: - Type code or None if address is invalid. + DataType or None if address is invalid. """ try: memory_type, _ = parse_address(address) except ValueError: return None - return MEMORY_TYPE_TO_CODE.get(memory_type) + data_type = MEMORY_TYPE_TO_DATA_TYPE.get(memory_type) + return DataType(data_type) if data_type is not None else None def is_address_writable(address: str) -> bool: @@ -159,7 +150,7 @@ class DataviewRow: # Core data (stored in CDV file) address: str = "" # e.g., "X001", "DS1", "CTD250" - type_code: int = 0 # Type code for the address + data_type: DataType | None = None # DataType for the address new_value: str = "" # Optional new value to write # Display-only fields (populated from SharedAddressData) @@ -196,22 +187,51 @@ def address_number(self) -> str | None: display = format_address_display(memory_type, mdb_address) return display[len(memory_type) :] - def update_type_code(self) -> bool: - """Update the type code based on the current address. + @property + def new_value_display(self) -> str: + """Get New Value as a display string.""" + if not self.new_value or self.data_type is None: + return "" + native = storage_to_datatype(self.new_value, self.data_type) + return datatype_to_display(native, self.data_type) + + def update_data_type(self) -> bool: + """Update the DataType based on the current address. Returns: - True if type code was updated, False if address is invalid. + True if data_type was updated, False if address is invalid. """ - code = get_type_code_for_address(self.address) - if code is not None: - self.type_code = code + data_type = get_data_type_for_address(self.address) + if data_type is not None: + self.data_type = data_type return True return False + def set_new_value_from_display(self, display_str: str) -> bool: + """Set New Value from a user-entered display string.""" + if not display_str: + self.new_value = "" + return True + if self.data_type is None: + return False + native = display_to_datatype(display_str, self.data_type) + if native is None: + return False + self.new_value = datatype_to_storage(native, self.data_type) + return True + + def validate_new_value(self, display_str: str) -> tuple[bool, str]: + """Validate a user-entered New Value for this row.""" + if not self.is_writable: + return False, "Read-only address" + if self.data_type is None: + return False, "No address set" + return validate_new_value(display_str, self.data_type) + def clear(self) -> None: """Clear all fields in this row.""" self.address = "" - self.type_code = 0 + self.data_type = None self.new_value = "" self.nickname = "" self.comment = "" @@ -239,12 +259,12 @@ def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataviewRow]: # The display layer handles presentation (hex formatting, float precision, etc.). -def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str | None: +def storage_to_datatype(value: str, data_type: DataType) -> int | float | bool | str | None: """Convert a CDV storage string to its native Python type. Args: value: The raw value string from the CDV file. - type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) + data_type: DataType for conversion. Returns: Native Python value (bool for BIT, int for INT/INT2/HEX, @@ -254,50 +274,49 @@ def storage_to_datatype(value: str, type_code: int) -> int | float | bool | str return None try: - if type_code == _CdvStorageCode.BIT: + if data_type == DataType.BIT: return value == "1" - elif type_code == _CdvStorageCode.INT: - # Stored as unsigned 32-bit with sign extension → signed 16-bit + if data_type == DataType.INT: + # Stored as unsigned 32-bit with sign extension -> signed 16-bit unsigned_val = int(value) val_16bit = unsigned_val & 0xFFFF if val_16bit >= 0x8000: val_16bit -= 0x10000 return val_16bit - elif type_code == _CdvStorageCode.INT2: - # Stored as unsigned 32-bit → signed 32-bit + if data_type == DataType.INT2: + # Stored as unsigned 32-bit -> signed 32-bit unsigned_val = int(value) if unsigned_val >= 0x80000000: unsigned_val -= 0x100000000 return unsigned_val - elif type_code == _CdvStorageCode.HEX: + if data_type == DataType.HEX: return int(value) - elif type_code == _CdvStorageCode.FLOAT: - # Stored as IEEE 754 32-bit integer representation → float + if data_type == DataType.FLOAT: + # Stored as IEEE 754 32-bit integer representation -> float int_val = int(value) bytes_val = struct.pack(">I", int_val & 0xFFFFFFFF) return struct.unpack(">f", bytes_val)[0] - elif type_code == _CdvStorageCode.TXT: + if data_type == DataType.TXT: code = int(value) return chr(code) if 0 < code < 128 else "" - else: - return None + return None except (ValueError, struct.error): return None -def datatype_to_storage(value: int | float | bool | str | None, type_code: int) -> str: +def datatype_to_storage(value: int | float | bool | str | None, data_type: DataType) -> str: """Convert a native Python value to CDV storage format. Args: value: The native Python value (bool, int, or float). - type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) + data_type: DataType for conversion. Returns: Value formatted for CDV file storage, or "" if None. @@ -306,53 +325,52 @@ def datatype_to_storage(value: int | float | bool | str | None, type_code: int) return "" try: - if type_code == _CdvStorageCode.BIT: + if data_type == DataType.BIT: return "1" if value else "0" - elif type_code == _CdvStorageCode.INT: - # Signed 16-bit → unsigned 32-bit with sign extension + if data_type == DataType.INT: + # Signed 16-bit -> unsigned 32-bit with sign extension signed_val = int(value) signed_val = max(-32768, min(32767, signed_val)) if signed_val < 0: return str(signed_val + 0x100000000) return str(signed_val) - elif type_code == _CdvStorageCode.INT2: - # Signed 32-bit → unsigned 32-bit + if data_type == DataType.INT2: + # Signed 32-bit -> unsigned 32-bit signed_val = int(value) signed_val = max(-2147483648, min(2147483647, signed_val)) if signed_val < 0: return str(signed_val + 0x100000000) return str(signed_val) - elif type_code == _CdvStorageCode.HEX: + if data_type == DataType.HEX: return str(int(value)) - elif type_code == _CdvStorageCode.FLOAT: - # Float → IEEE 754 bytes → unsigned 32-bit integer string + if data_type == DataType.FLOAT: + # Float -> IEEE 754 bytes -> unsigned 32-bit integer string float_val = float(value) bytes_val = struct.pack(">f", float_val) int_val = struct.unpack(">I", bytes_val)[0] return str(int_val) - elif type_code == _CdvStorageCode.TXT: + if data_type == DataType.TXT: if isinstance(value, str): return str(ord(value)) if value else "0" return str(int(value)) - else: - return "" + return "" except (ValueError, struct.error): return "" -def datatype_to_display(value: int | float | bool | str | None, type_code: int) -> str: +def datatype_to_display(value: int | float | bool | str | None, data_type: DataType) -> str: """Convert a native Python value to a UI-friendly display string. Args: value: The native Python value (bool, int, or float). - type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) + data_type: DataType for conversion. Returns: Human-readable display string, or "" if None. @@ -361,19 +379,19 @@ def datatype_to_display(value: int | float | bool | str | None, type_code: int) return "" try: - if type_code == _CdvStorageCode.BIT: + if data_type == DataType.BIT: return "1" if value else "0" - elif type_code in (_CdvStorageCode.INT, _CdvStorageCode.INT2): + if data_type in (DataType.INT, DataType.INT2): return str(int(value)) - elif type_code == _CdvStorageCode.HEX: + if data_type == DataType.HEX: return format(int(value), "04X") - elif type_code == _CdvStorageCode.FLOAT: + if data_type == DataType.FLOAT: return f"{float(value):.7G}" - elif type_code == _CdvStorageCode.TXT: + if data_type == DataType.TXT: if isinstance(value, str): return value if value else "" code = int(value) @@ -381,19 +399,18 @@ def datatype_to_display(value: int | float | bool | str | None, type_code: int) return chr(code) return str(code) - else: - return str(value) + return str(value) except (ValueError, TypeError): return "" -def display_to_datatype(value: str, type_code: int) -> int | float | bool | str | None: +def display_to_datatype(value: str, data_type: DataType) -> int | float | bool | str | None: """Convert a UI display string to its native Python type. Args: value: The human-readable display string. - type_code: The type code (_CdvStorageCode.BIT, _CdvStorageCode.INT, etc.) + data_type: DataType for conversion. Returns: Native Python value (bool for BIT, int for INT/INT2/HEX, @@ -403,34 +420,40 @@ def display_to_datatype(value: str, type_code: int) -> int | float | bool | str return None try: - if type_code == _CdvStorageCode.BIT: + if data_type == DataType.BIT: return value in ("1", "True", "true", "ON", "on") - elif type_code in (_CdvStorageCode.INT, _CdvStorageCode.INT2): + if data_type in (DataType.INT, DataType.INT2): return int(value) - elif type_code == _CdvStorageCode.HEX: + if data_type == DataType.HEX: hex_val = value.strip() if hex_val.lower().startswith("0x"): hex_val = hex_val[2:] return int(hex_val, 16) - elif type_code == _CdvStorageCode.FLOAT: + if data_type == DataType.FLOAT: return float(value) - elif type_code == _CdvStorageCode.TXT: + if data_type == DataType.TXT: if len(value) == 1: return value code = int(value) return chr(code) if 0 < code < 128 else "" - else: - return None + return None except (ValueError, TypeError): return None +def validate_new_value(display_str: str, data_type: DataType) -> tuple[bool, str]: + """Validate a user-entered display string for the New Value column.""" + if not display_str: + return True, "" + return validate_initial_value(display_str, data_type) + + # ============================================================================= # CDV File I/O # ============================================================================= @@ -497,18 +520,17 @@ def load_cdv(path: Path | str) -> tuple[list[DataviewRow], bool, str]: address = parts[0] rows[i].address = address - # Parse type code + # Parse type code and map to DataType if len(parts) > 1 and parts[1]: try: - rows[i].type_code = int(parts[1]) + cdv_code = int(parts[1]) + rows[i].data_type = _CDV_CODE_TO_DATA_TYPE.get(cdv_code) except ValueError: - # Try to infer from address - code = get_type_code_for_address(address) - rows[i].type_code = code if code is not None else 0 - else: - # Infer type code from address - code = get_type_code_for_address(address) - rows[i].type_code = code if code is not None else 0 + rows[i].data_type = None + + # Infer DataType from address if missing/invalid in file + if rows[i].data_type is None: + rows[i].data_type = get_data_type_for_address(address) # Parse new value (if present and has_new_values flag is set) if len(parts) > 2 and parts[2]: @@ -558,10 +580,19 @@ def save_cdv( if row.is_empty: lines.append(",0") else: + data_type = ( + row.data_type + if row.data_type is not None + else get_data_type_for_address(row.address) + ) + if data_type is None: + cdv_code = _CdvStorageCode.INT + else: + cdv_code = _DATA_TYPE_TO_CDV_CODE[data_type] if row.new_value: - lines.append(f"{row.address},{row.type_code},{row.new_value}") + lines.append(f"{row.address},{cdv_code},{row.new_value}") else: - lines.append(f"{row.address},{row.type_code}") + lines.append(f"{row.address},{cdv_code}") # Join with newlines and add trailing newline content = "\n".join(lines) + "\n" @@ -572,7 +603,7 @@ def save_cdv( def _validate_cdv_new_value( new_value: str, - type_code: int, + data_type: DataType, address: str, filename: str, row_num: int, @@ -582,17 +613,17 @@ def _validate_cdv_new_value( prefix = f"CDV {filename} row {row_num}: {address}" try: - if type_code == _CdvStorageCode.BIT: + if data_type == DataType.BIT: if new_value not in ("0", "1"): issues.append(f"{prefix} new_value '{new_value}' invalid for BIT (must be 0 or 1)") return issues - if type_code == _CdvStorageCode.INT: + if data_type == DataType.INT: raw = int(new_value) if raw < 0 or raw > 0xFFFFFFFF: issues.append(f"{prefix} new_value '{new_value}' out of range for INT storage") return issues - converted = storage_to_datatype(new_value, type_code) + converted = storage_to_datatype(new_value, data_type) if not isinstance(converted, int): issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT") return issues @@ -603,12 +634,12 @@ def _validate_cdv_new_value( ) return issues - if type_code == _CdvStorageCode.INT2: + if data_type == DataType.INT2: raw = int(new_value) if raw < 0 or raw > 0xFFFFFFFF: issues.append(f"{prefix} new_value '{new_value}' out of range for INT2 storage") return issues - converted = storage_to_datatype(new_value, type_code) + converted = storage_to_datatype(new_value, data_type) if not isinstance(converted, int): issues.append(f"{prefix} new_value '{new_value}' failed to convert to INT2") return issues @@ -619,18 +650,18 @@ def _validate_cdv_new_value( ) return issues - if type_code == _CdvStorageCode.HEX: + if data_type == DataType.HEX: raw = int(new_value) if raw < 0 or raw > 0xFFFF: issues.append(f"{prefix} new_value '{new_value}' out of range for HEX (0-65535)") return issues - if type_code == _CdvStorageCode.FLOAT: + if data_type == DataType.FLOAT: raw = int(new_value) if raw < 0 or raw > 0xFFFFFFFF: issues.append(f"{prefix} new_value '{new_value}' invalid for FLOAT storage") return issues - converted = storage_to_datatype(new_value, type_code) + converted = storage_to_datatype(new_value, data_type) if not isinstance(converted, float): issues.append(f"{prefix} new_value '{new_value}' failed to convert to FLOAT") return issues @@ -638,7 +669,7 @@ def _validate_cdv_new_value( issues.append(f"{prefix} new_value converts to {converted}, outside FLOAT range") return issues - if type_code == _CdvStorageCode.TXT: + if data_type == DataType.TXT: raw = int(new_value) if raw < 0 or raw > 127: issues.append( @@ -675,23 +706,28 @@ def check_cdv_file(path: Path | str) -> list[str]: issues.append(f"CDV {filename} row {row_num}: Invalid address format '{row.address}'") continue - if memory_type not in MEMORY_TYPE_TO_CODE: + if memory_type not in MEMORY_TYPE_TO_DATA_TYPE: issues.append(f"CDV {filename} row {row_num}: Unknown memory type '{memory_type}'") continue - expected_code = get_type_code_for_address(row.address) - if expected_code is not None and row.type_code != expected_code: + expected_data_type = get_data_type_for_address(row.address) + if expected_data_type is not None and row.data_type != expected_data_type: issues.append( - f"CDV {filename} row {row_num}: Type code mismatch for {row.address} " - f"(has {row.type_code}, expected {expected_code})" + f"CDV {filename} row {row_num}: Data type mismatch for {row.address} " + f"(has {row.data_type}, expected {expected_data_type})" ) if row.new_value: - issues.extend( - _validate_cdv_new_value( - row.new_value, row.type_code, row.address, filename, row_num + if row.data_type is None: + issues.append( + f"CDV {filename} row {row_num}: {row.address} has new_value but no data_type set" + ) + else: + issues.extend( + _validate_cdv_new_value( + row.new_value, row.data_type, row.address, filename, row_num + ) ) - ) if not is_address_writable(row.address): issues.append( f"CDV {filename} row {row_num}: {row.address} has new_value " diff --git a/tests/test_dataview.py b/tests/test_dataview.py index dfab830..5dfa6c8 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -1,7 +1,8 @@ -"""Tests for pyclickplc.dataview — DataView model and CDV file I/O.""" +"""Tests for pyclickplc.dataview - DataView model and CDV file I/O.""" import pytest +from pyclickplc.banks import DataType from pyclickplc.dataview import ( MAX_DATAVIEW_ROWS, WRITABLE_SC, @@ -13,50 +14,49 @@ datatype_to_display, datatype_to_storage, display_to_datatype, - get_type_code_for_address, + get_data_type_for_address, is_address_writable, load_cdv, save_cdv, storage_to_datatype, + validate_new_value, ) -TypeCode = _CdvStorageCode - -class TestGetTypeCodeForAddress: - """Tests for get_type_code_for_address function.""" +class TestGetDataTypeForAddress: + """Tests for get_data_type_for_address function.""" def test_bit_addresses(self): - assert get_type_code_for_address("X001") == TypeCode.BIT - assert get_type_code_for_address("Y001") == TypeCode.BIT - assert get_type_code_for_address("C1") == TypeCode.BIT - assert get_type_code_for_address("T1") == TypeCode.BIT - assert get_type_code_for_address("CT1") == TypeCode.BIT - assert get_type_code_for_address("SC1") == TypeCode.BIT + assert get_data_type_for_address("X001") == DataType.BIT + assert get_data_type_for_address("Y001") == DataType.BIT + assert get_data_type_for_address("C1") == DataType.BIT + assert get_data_type_for_address("T1") == DataType.BIT + assert get_data_type_for_address("CT1") == DataType.BIT + assert get_data_type_for_address("SC1") == DataType.BIT def test_int_addresses(self): - assert get_type_code_for_address("DS1") == TypeCode.INT - assert get_type_code_for_address("TD1") == TypeCode.INT - assert get_type_code_for_address("SD1") == TypeCode.INT + assert get_data_type_for_address("DS1") == DataType.INT + assert get_data_type_for_address("TD1") == DataType.INT + assert get_data_type_for_address("SD1") == DataType.INT def test_int2_addresses(self): - assert get_type_code_for_address("DD1") == TypeCode.INT2 - assert get_type_code_for_address("CTD1") == TypeCode.INT2 + assert get_data_type_for_address("DD1") == DataType.INT2 + assert get_data_type_for_address("CTD1") == DataType.INT2 def test_hex_addresses(self): - assert get_type_code_for_address("DH1") == TypeCode.HEX - assert get_type_code_for_address("XD0") == TypeCode.HEX - assert get_type_code_for_address("YD0") == TypeCode.HEX + assert get_data_type_for_address("DH1") == DataType.HEX + assert get_data_type_for_address("XD0") == DataType.HEX + assert get_data_type_for_address("YD0") == DataType.HEX def test_float_addresses(self): - assert get_type_code_for_address("DF1") == TypeCode.FLOAT + assert get_data_type_for_address("DF1") == DataType.FLOAT def test_txt_addresses(self): - assert get_type_code_for_address("TXT1") == TypeCode.TXT + assert get_data_type_for_address("TXT1") == DataType.TXT def test_invalid_address(self): - assert get_type_code_for_address("INVALID") is None - assert get_type_code_for_address("") is None + assert get_data_type_for_address("INVALID") is None + assert get_data_type_for_address("") is None class TestIsAddressWritable: @@ -103,7 +103,7 @@ class TestDataviewRow: def test_default_values(self): row = DataviewRow() assert row.address == "" - assert row.type_code == 0 + assert row.data_type is None assert row.new_value == "" assert row.nickname == "" assert row.comment == "" @@ -139,25 +139,25 @@ def test_address_number(self): row.address = "XD0u" assert row.address_number == "0u" - def test_update_type_code(self): + def test_update_data_type(self): row = DataviewRow(address="DS100") - assert row.update_type_code() is True - assert row.type_code == TypeCode.INT + assert row.update_data_type() is True + assert row.data_type == DataType.INT row.address = "INVALID" - assert row.update_type_code() is False + assert row.update_data_type() is False def test_clear(self): row = DataviewRow( address="X001", - type_code=TypeCode.BIT, + data_type=DataType.BIT, new_value="1", nickname="Test", comment="Comment", ) row.clear() assert row.address == "" - assert row.type_code == 0 + assert row.data_type is None assert row.new_value == "" assert row.nickname == "" assert row.comment == "" @@ -184,205 +184,196 @@ class TestStorageToDatatype: """Tests for storage_to_datatype: CDV string -> native Python type.""" def test_bit_values(self): - assert storage_to_datatype("1", TypeCode.BIT) is True - assert storage_to_datatype("0", TypeCode.BIT) is False + assert storage_to_datatype("1", DataType.BIT) is True + assert storage_to_datatype("0", DataType.BIT) is False def test_int_positive(self): - assert storage_to_datatype("0", TypeCode.INT) == 0 - assert storage_to_datatype("100", TypeCode.INT) == 100 - assert storage_to_datatype("32767", TypeCode.INT) == 32767 + assert storage_to_datatype("0", DataType.INT) == 0 + assert storage_to_datatype("100", DataType.INT) == 100 + assert storage_to_datatype("32767", DataType.INT) == 32767 def test_int_negative(self): - assert storage_to_datatype("4294934528", TypeCode.INT) == -32768 - assert storage_to_datatype("4294967295", TypeCode.INT) == -1 - assert storage_to_datatype("65535", TypeCode.INT) == -1 + assert storage_to_datatype("4294934528", DataType.INT) == -32768 + assert storage_to_datatype("4294967295", DataType.INT) == -1 + assert storage_to_datatype("65535", DataType.INT) == -1 def test_int2_positive(self): - assert storage_to_datatype("0", TypeCode.INT2) == 0 - assert storage_to_datatype("100", TypeCode.INT2) == 100 - assert storage_to_datatype("2147483647", TypeCode.INT2) == 2147483647 + assert storage_to_datatype("0", DataType.INT2) == 0 + assert storage_to_datatype("100", DataType.INT2) == 100 + assert storage_to_datatype("2147483647", DataType.INT2) == 2147483647 def test_int2_negative(self): - assert storage_to_datatype("2147483648", TypeCode.INT2) == -2147483648 - assert storage_to_datatype("4294967294", TypeCode.INT2) == -2 - assert storage_to_datatype("4294967295", TypeCode.INT2) == -1 + assert storage_to_datatype("2147483648", DataType.INT2) == -2147483648 + assert storage_to_datatype("4294967294", DataType.INT2) == -2 + assert storage_to_datatype("4294967295", DataType.INT2) == -1 def test_hex_values(self): - assert storage_to_datatype("65535", TypeCode.HEX) == 65535 - assert storage_to_datatype("255", TypeCode.HEX) == 255 - assert storage_to_datatype("0", TypeCode.HEX) == 0 + assert storage_to_datatype("65535", DataType.HEX) == 65535 + assert storage_to_datatype("255", DataType.HEX) == 255 + assert storage_to_datatype("0", DataType.HEX) == 0 def test_float_values(self): - assert storage_to_datatype("0", TypeCode.FLOAT) == 0.0 - assert storage_to_datatype("1065353216", TypeCode.FLOAT) == 1.0 - val = storage_to_datatype("1078523331", TypeCode.FLOAT) + assert storage_to_datatype("0", DataType.FLOAT) == 0.0 + assert storage_to_datatype("1065353216", DataType.FLOAT) == 1.0 + val = storage_to_datatype("1078523331", DataType.FLOAT) assert val == pytest.approx(3.14, abs=1e-5) - val = storage_to_datatype("4286578685", TypeCode.FLOAT) + val = storage_to_datatype("4286578685", DataType.FLOAT) assert isinstance(val, (int, float)) assert val < 0 def test_txt_values(self): - assert storage_to_datatype("48", TypeCode.TXT) == "0" - assert storage_to_datatype("65", TypeCode.TXT) == "A" - assert storage_to_datatype("90", TypeCode.TXT) == "Z" - assert storage_to_datatype("32", TypeCode.TXT) == " " + assert storage_to_datatype("48", DataType.TXT) == "0" + assert storage_to_datatype("65", DataType.TXT) == "A" + assert storage_to_datatype("90", DataType.TXT) == "Z" + assert storage_to_datatype("32", DataType.TXT) == " " def test_empty_value(self): - assert storage_to_datatype("", TypeCode.INT) is None - assert storage_to_datatype("", TypeCode.HEX) is None - assert storage_to_datatype("", TypeCode.BIT) is None + assert storage_to_datatype("", DataType.INT) is None + assert storage_to_datatype("", DataType.HEX) is None + assert storage_to_datatype("", DataType.BIT) is None def test_invalid_value(self): - assert storage_to_datatype("abc", TypeCode.INT) is None - - def test_unknown_type_code(self): - assert storage_to_datatype("42", 9999) is None + assert storage_to_datatype("abc", DataType.INT) is None class TestDatatypeToStorage: """Tests for datatype_to_storage: native Python type -> CDV string.""" def test_bit_values(self): - assert datatype_to_storage(True, TypeCode.BIT) == "1" - assert datatype_to_storage(False, TypeCode.BIT) == "0" - assert datatype_to_storage(1, TypeCode.BIT) == "1" - assert datatype_to_storage(0, TypeCode.BIT) == "0" + assert datatype_to_storage(True, DataType.BIT) == "1" + assert datatype_to_storage(False, DataType.BIT) == "0" + assert datatype_to_storage(1, DataType.BIT) == "1" + assert datatype_to_storage(0, DataType.BIT) == "0" def test_int_positive(self): - assert datatype_to_storage(0, TypeCode.INT) == "0" - assert datatype_to_storage(100, TypeCode.INT) == "100" - assert datatype_to_storage(32767, TypeCode.INT) == "32767" + assert datatype_to_storage(0, DataType.INT) == "0" + assert datatype_to_storage(100, DataType.INT) == "100" + assert datatype_to_storage(32767, DataType.INT) == "32767" def test_int_negative(self): - assert datatype_to_storage(-32768, TypeCode.INT) == "4294934528" - assert datatype_to_storage(-1, TypeCode.INT) == "4294967295" + assert datatype_to_storage(-32768, DataType.INT) == "4294934528" + assert datatype_to_storage(-1, DataType.INT) == "4294967295" def test_int2_positive(self): - assert datatype_to_storage(0, TypeCode.INT2) == "0" - assert datatype_to_storage(100, TypeCode.INT2) == "100" + assert datatype_to_storage(0, DataType.INT2) == "0" + assert datatype_to_storage(100, DataType.INT2) == "100" def test_int2_negative(self): - assert datatype_to_storage(-2147483648, TypeCode.INT2) == "2147483648" - assert datatype_to_storage(-2, TypeCode.INT2) == "4294967294" + assert datatype_to_storage(-2147483648, DataType.INT2) == "2147483648" + assert datatype_to_storage(-2, DataType.INT2) == "4294967294" def test_hex_values(self): - assert datatype_to_storage(65535, TypeCode.HEX) == "65535" - assert datatype_to_storage(255, TypeCode.HEX) == "255" - assert datatype_to_storage(0, TypeCode.HEX) == "0" + assert datatype_to_storage(65535, DataType.HEX) == "65535" + assert datatype_to_storage(255, DataType.HEX) == "255" + assert datatype_to_storage(0, DataType.HEX) == "0" def test_float_values(self): - assert datatype_to_storage(0.0, TypeCode.FLOAT) == "0" - assert datatype_to_storage(1.0, TypeCode.FLOAT) == "1065353216" - assert datatype_to_storage(-1.0, TypeCode.FLOAT) == "3212836864" + assert datatype_to_storage(0.0, DataType.FLOAT) == "0" + assert datatype_to_storage(1.0, DataType.FLOAT) == "1065353216" + assert datatype_to_storage(-1.0, DataType.FLOAT) == "3212836864" def test_txt_values(self): - assert datatype_to_storage(48, TypeCode.TXT) == "48" - assert datatype_to_storage(65, TypeCode.TXT) == "65" - assert datatype_to_storage(90, TypeCode.TXT) == "90" + assert datatype_to_storage(48, DataType.TXT) == "48" + assert datatype_to_storage(65, DataType.TXT) == "65" + assert datatype_to_storage(90, DataType.TXT) == "90" def test_none_value(self): - assert datatype_to_storage(None, TypeCode.INT) == "" - assert datatype_to_storage(None, TypeCode.HEX) == "" - - def test_unknown_type_code(self): - assert datatype_to_storage(42, 9999) == "" + assert datatype_to_storage(None, DataType.INT) == "" + assert datatype_to_storage(None, DataType.HEX) == "" class TestDatatypeToDisplay: """Tests for datatype_to_display: native Python type -> UI string.""" def test_bit_values(self): - assert datatype_to_display(True, TypeCode.BIT) == "1" - assert datatype_to_display(False, TypeCode.BIT) == "0" + assert datatype_to_display(True, DataType.BIT) == "1" + assert datatype_to_display(False, DataType.BIT) == "0" def test_int_values(self): - assert datatype_to_display(0, TypeCode.INT) == "0" - assert datatype_to_display(100, TypeCode.INT) == "100" - assert datatype_to_display(-32768, TypeCode.INT) == "-32768" - assert datatype_to_display(32767, TypeCode.INT) == "32767" + assert datatype_to_display(0, DataType.INT) == "0" + assert datatype_to_display(100, DataType.INT) == "100" + assert datatype_to_display(-32768, DataType.INT) == "-32768" + assert datatype_to_display(32767, DataType.INT) == "32767" def test_int2_values(self): - assert datatype_to_display(0, TypeCode.INT2) == "0" - assert datatype_to_display(-2147483648, TypeCode.INT2) == "-2147483648" - assert datatype_to_display(2147483647, TypeCode.INT2) == "2147483647" + assert datatype_to_display(0, DataType.INT2) == "0" + assert datatype_to_display(-2147483648, DataType.INT2) == "-2147483648" + assert datatype_to_display(2147483647, DataType.INT2) == "2147483647" def test_hex_values(self): - assert datatype_to_display(65535, TypeCode.HEX) == "FFFF" - assert datatype_to_display(255, TypeCode.HEX) == "00FF" - assert datatype_to_display(0, TypeCode.HEX) == "0000" - assert datatype_to_display(1, TypeCode.HEX) == "0001" + assert datatype_to_display(65535, DataType.HEX) == "FFFF" + assert datatype_to_display(255, DataType.HEX) == "00FF" + assert datatype_to_display(0, DataType.HEX) == "0000" + assert datatype_to_display(1, DataType.HEX) == "0001" def test_float_values(self): - assert datatype_to_display(0.0, TypeCode.FLOAT) == "0" - assert datatype_to_display(1.0, TypeCode.FLOAT) == "1" - assert datatype_to_display(3.1400001049041748, TypeCode.FLOAT) == "3.14" - assert datatype_to_display(3.4028234663852886e38, TypeCode.FLOAT) == "3.402823E+38" - assert datatype_to_display(-3.4028234663852886e38, TypeCode.FLOAT) == "-3.402823E+38" + assert datatype_to_display(0.0, DataType.FLOAT) == "0" + assert datatype_to_display(1.0, DataType.FLOAT) == "1" + assert datatype_to_display(3.1400001049041748, DataType.FLOAT) == "3.14" + assert datatype_to_display(3.4028234663852886e38, DataType.FLOAT) == "3.402823E+38" + assert datatype_to_display(-3.4028234663852886e38, DataType.FLOAT) == "-3.402823E+38" def test_txt_printable(self): - assert datatype_to_display(48, TypeCode.TXT) == "0" - assert datatype_to_display(65, TypeCode.TXT) == "A" - assert datatype_to_display(90, TypeCode.TXT) == "Z" - assert datatype_to_display(32, TypeCode.TXT) == " " + assert datatype_to_display(48, DataType.TXT) == "0" + assert datatype_to_display(65, DataType.TXT) == "A" + assert datatype_to_display(90, DataType.TXT) == "Z" + assert datatype_to_display(32, DataType.TXT) == " " def test_txt_nonprintable(self): - assert datatype_to_display(5, TypeCode.TXT) == "5" - assert datatype_to_display(127, TypeCode.TXT) == "127" + assert datatype_to_display(5, DataType.TXT) == "5" + assert datatype_to_display(127, DataType.TXT) == "127" def test_none_value(self): - assert datatype_to_display(None, TypeCode.INT) == "" - assert datatype_to_display(None, TypeCode.HEX) == "" + assert datatype_to_display(None, DataType.INT) == "" + assert datatype_to_display(None, DataType.HEX) == "" class TestDisplayToDatatype: """Tests for display_to_datatype: UI string -> native Python type.""" def test_bit_values(self): - assert display_to_datatype("1", TypeCode.BIT) is True - assert display_to_datatype("0", TypeCode.BIT) is False - assert display_to_datatype("True", TypeCode.BIT) is True - assert display_to_datatype("ON", TypeCode.BIT) is True + assert display_to_datatype("1", DataType.BIT) is True + assert display_to_datatype("0", DataType.BIT) is False + assert display_to_datatype("True", DataType.BIT) is True + assert display_to_datatype("ON", DataType.BIT) is True def test_int_values(self): - assert display_to_datatype("0", TypeCode.INT) == 0 - assert display_to_datatype("100", TypeCode.INT) == 100 - assert display_to_datatype("-32768", TypeCode.INT) == -32768 - assert display_to_datatype("32767", TypeCode.INT) == 32767 + assert display_to_datatype("0", DataType.INT) == 0 + assert display_to_datatype("100", DataType.INT) == 100 + assert display_to_datatype("-32768", DataType.INT) == -32768 + assert display_to_datatype("32767", DataType.INT) == 32767 def test_int2_values(self): - assert display_to_datatype("0", TypeCode.INT2) == 0 - assert display_to_datatype("-2147483648", TypeCode.INT2) == -2147483648 - assert display_to_datatype("2147483647", TypeCode.INT2) == 2147483647 + assert display_to_datatype("0", DataType.INT2) == 0 + assert display_to_datatype("-2147483648", DataType.INT2) == -2147483648 + assert display_to_datatype("2147483647", DataType.INT2) == 2147483647 def test_hex_values(self): - assert display_to_datatype("FFFF", TypeCode.HEX) == 65535 - assert display_to_datatype("FF", TypeCode.HEX) == 255 - assert display_to_datatype("0xFF", TypeCode.HEX) == 255 - assert display_to_datatype("0", TypeCode.HEX) == 0 + assert display_to_datatype("FFFF", DataType.HEX) == 65535 + assert display_to_datatype("FF", DataType.HEX) == 255 + assert display_to_datatype("0xFF", DataType.HEX) == 255 + assert display_to_datatype("0", DataType.HEX) == 0 def test_float_values(self): - assert display_to_datatype("3.14", TypeCode.FLOAT) == pytest.approx(3.14) - assert display_to_datatype("0.0", TypeCode.FLOAT) == 0.0 - assert display_to_datatype("-1.0", TypeCode.FLOAT) == -1.0 + assert display_to_datatype("3.14", DataType.FLOAT) == pytest.approx(3.14) + assert display_to_datatype("0.0", DataType.FLOAT) == 0.0 + assert display_to_datatype("-1.0", DataType.FLOAT) == -1.0 def test_txt_char(self): - assert display_to_datatype("A", TypeCode.TXT) == "A" - assert display_to_datatype("Z", TypeCode.TXT) == "Z" - assert display_to_datatype("0", TypeCode.TXT) == "0" - assert display_to_datatype(" ", TypeCode.TXT) == " " + assert display_to_datatype("A", DataType.TXT) == "A" + assert display_to_datatype("Z", DataType.TXT) == "Z" + assert display_to_datatype("0", DataType.TXT) == "0" + assert display_to_datatype(" ", DataType.TXT) == " " def test_txt_numeric(self): - assert display_to_datatype("65", TypeCode.TXT) == "A" + assert display_to_datatype("65", DataType.TXT) == "A" def test_empty_value(self): - assert display_to_datatype("", TypeCode.INT) is None - assert display_to_datatype("", TypeCode.HEX) is None + assert display_to_datatype("", DataType.INT) is None + assert display_to_datatype("", DataType.HEX) is None def test_invalid_value(self): - assert display_to_datatype("abc", TypeCode.INT) is None - - def test_unknown_type_code(self): - assert display_to_datatype("42", 9999) is None + assert display_to_datatype("abc", DataType.INT) is None class TestRoundTripConversion: @@ -390,80 +381,140 @@ class TestRoundTripConversion: def test_storage_datatype_roundtrip_int(self): for storage_val in ["0", "100", "32767", "4294934528", "4294967295"]: - native = storage_to_datatype(storage_val, TypeCode.INT) - storage = datatype_to_storage(native, TypeCode.INT) - assert storage_to_datatype(storage, TypeCode.INT) == native + native = storage_to_datatype(storage_val, DataType.INT) + storage = datatype_to_storage(native, DataType.INT) + assert storage_to_datatype(storage, DataType.INT) == native def test_storage_datatype_roundtrip_int2(self): for storage_val in ["0", "100", "2147483647", "2147483648", "4294967294"]: - native = storage_to_datatype(storage_val, TypeCode.INT2) - storage = datatype_to_storage(native, TypeCode.INT2) - assert storage_to_datatype(storage, TypeCode.INT2) == native + native = storage_to_datatype(storage_val, DataType.INT2) + storage = datatype_to_storage(native, DataType.INT2) + assert storage_to_datatype(storage, DataType.INT2) == native def test_storage_datatype_roundtrip_hex(self): for storage_val in ["0", "255", "65535"]: - native = storage_to_datatype(storage_val, TypeCode.HEX) - storage = datatype_to_storage(native, TypeCode.HEX) + native = storage_to_datatype(storage_val, DataType.HEX) + storage = datatype_to_storage(native, DataType.HEX) assert storage == storage_val def test_storage_datatype_roundtrip_float(self): for storage_val in ["0", "1065353216", "3212836864"]: - native = storage_to_datatype(storage_val, TypeCode.FLOAT) - storage = datatype_to_storage(native, TypeCode.FLOAT) + native = storage_to_datatype(storage_val, DataType.FLOAT) + storage = datatype_to_storage(native, DataType.FLOAT) assert storage == storage_val def test_storage_datatype_roundtrip_txt(self): for storage_val in ["32", "48", "65", "90"]: - native = storage_to_datatype(storage_val, TypeCode.TXT) - storage = datatype_to_storage(native, TypeCode.TXT) + native = storage_to_datatype(storage_val, DataType.TXT) + storage = datatype_to_storage(native, DataType.TXT) assert storage == storage_val def test_display_datatype_roundtrip_hex(self): for display_val, expected in [("0", "0000"), ("FF", "00FF"), ("FFFF", "FFFF")]: - native = display_to_datatype(display_val, TypeCode.HEX) - display = datatype_to_display(native, TypeCode.HEX) + native = display_to_datatype(display_val, DataType.HEX) + display = datatype_to_display(native, DataType.HEX) assert display == expected def test_display_datatype_roundtrip_txt(self): for char in ["A", "Z", "0", " "]: - native = display_to_datatype(char, TypeCode.TXT) - display = datatype_to_display(native, TypeCode.TXT) + native = display_to_datatype(char, DataType.TXT) + display = datatype_to_display(native, DataType.TXT) assert display == char def test_full_pipeline_snapshot(self): """Full pipeline: CDV storage -> datatype -> display string.""" cases = [ - ("4286578685", TypeCode.FLOAT, "-3.402823E+38"), - ("2139095037", TypeCode.FLOAT, "3.402823E+38"), - ("0", TypeCode.HEX, "0000"), - ("65535", TypeCode.HEX, "FFFF"), - ("1", TypeCode.HEX, "0001"), - ("4294967295", TypeCode.INT, "-1"), - ("4294967295", TypeCode.INT2, "-1"), + ("4286578685", DataType.FLOAT, "-3.402823E+38"), + ("2139095037", DataType.FLOAT, "3.402823E+38"), + ("0", DataType.HEX, "0000"), + ("65535", DataType.HEX, "FFFF"), + ("1", DataType.HEX, "0001"), + ("4294967295", DataType.INT, "-1"), + ("4294967295", DataType.INT2, "-1"), ] - for storage_val, type_code, expected_display in cases: - native = storage_to_datatype(storage_val, type_code) - display = datatype_to_display(native, type_code) + for storage_val, data_type, expected_display in cases: + native = storage_to_datatype(storage_val, data_type) + display = datatype_to_display(native, data_type) assert display == expected_display, ( - f"Pipeline failed for {storage_val} (type {type_code}): " + f"Pipeline failed for {storage_val} (type {data_type}): " f"got {display!r}, expected {expected_display!r}" ) def test_full_pipeline_float_pi(self): """Full pipeline for pi-ish float value.""" - native = storage_to_datatype("1078523331", TypeCode.FLOAT) - display = datatype_to_display(native, TypeCode.FLOAT) + native = storage_to_datatype("1078523331", DataType.FLOAT) + display = datatype_to_display(native, DataType.FLOAT) assert display.startswith("3.14") +class TestValidateNewValue: + def test_empty_is_valid(self): + assert validate_new_value("", DataType.INT) == (True, "") + + def test_invalid_int(self): + assert validate_new_value("abc", DataType.INT) == (False, "Must be integer") + + def test_valid_int(self): + assert validate_new_value("100", DataType.INT) == (True, "") + + +class TestDataviewRowValidateNewValue: + def test_read_only_address(self): + row = DataviewRow(address="XD0", data_type=DataType.HEX) + assert row.validate_new_value("0001") == (False, "Read-only address") + + def test_no_data_type(self): + row = DataviewRow(address="DS1") + assert row.validate_new_value("100") == (False, "No address set") + + def test_delegates_validation(self): + row = DataviewRow(address="DS1", data_type=DataType.INT) + assert row.validate_new_value("abc") == (False, "Must be integer") + + +class TestNewValueDisplay: + def test_empty_when_no_new_value(self): + row = DataviewRow(address="DS1", data_type=DataType.INT) + assert row.new_value_display == "" + + def test_empty_when_no_data_type(self): + row = DataviewRow(address="DS1", new_value="100") + assert row.new_value_display == "" + + def test_int_round_trip_display(self): + row = DataviewRow(address="DS1", data_type=DataType.INT, new_value="100") + assert row.new_value_display == "100" + + +class TestSetNewValueFromDisplay: + def test_clear_on_empty(self): + row = DataviewRow(address="DS1", data_type=DataType.INT, new_value="100") + assert row.set_new_value_from_display("") is True + assert row.new_value == "" + + def test_fails_without_data_type(self): + row = DataviewRow(address="DS1") + assert row.set_new_value_from_display("100") is False + + def test_fails_on_invalid_input(self): + row = DataviewRow(address="DS1", data_type=DataType.INT) + assert row.set_new_value_from_display("abc") is False + + def test_sets_storage_value(self): + row = DataviewRow(address="DS1", data_type=DataType.INT) + assert row.set_new_value_from_display("100") is True + assert row.new_value == "100" + assert row.new_value_display == "100" + + class TestLoadCdv: """Tests for load_cdv function.""" def test_load_basic_cdv(self, tmp_path): cdv = tmp_path / "test.cdv" lines = ["0,0,0\n"] - lines.append("X001,768\n") - lines.append("DS1,0\n") + lines.append(f"X001,{_CdvStorageCode.BIT}\n") + lines.append(f"DS1,{_CdvStorageCode.INT}\n") for _ in range(98): lines.append(",0\n") cdv.write_text("".join(lines), encoding="utf-16") @@ -472,15 +523,26 @@ def test_load_basic_cdv(self, tmp_path): assert len(rows) == MAX_DATAVIEW_ROWS assert has_new_values is False assert rows[0].address == "X001" - assert rows[0].type_code == 768 + assert rows[0].data_type == DataType.BIT assert rows[1].address == "DS1" - assert rows[1].type_code == 0 + assert rows[1].data_type == DataType.INT assert rows[2].is_empty + assert header == "0,0,0" + + def test_load_infers_data_type_when_missing(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["0,0,0\n", "DS1,\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows, _has_new_values, _header = load_cdv(cdv) + assert rows[0].data_type == DataType.INT def test_load_with_new_values(self, tmp_path): cdv = tmp_path / "test.cdv" lines = ["-1,0,0\n"] - lines.append("X001,768,1\n") + lines.append(f"X001,{_CdvStorageCode.BIT},1\n") for _ in range(99): lines.append(",0\n") cdv.write_text("".join(lines), encoding="utf-16") @@ -501,25 +563,25 @@ def test_save_and_reload(self, tmp_path): cdv = tmp_path / "test.cdv" rows = create_empty_dataview() rows[0].address = "X001" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT rows[1].address = "DS1" - rows[1].type_code = TypeCode.INT + rows[1].data_type = DataType.INT save_cdv(cdv, rows, has_new_values=False) - loaded_rows, has_new_values, header = load_cdv(cdv) + loaded_rows, has_new_values, _header = load_cdv(cdv) assert has_new_values is False assert loaded_rows[0].address == "X001" - assert loaded_rows[0].type_code == TypeCode.BIT + assert loaded_rows[0].data_type == DataType.BIT assert loaded_rows[1].address == "DS1" - assert loaded_rows[1].type_code == TypeCode.INT + assert loaded_rows[1].data_type == DataType.INT assert loaded_rows[2].is_empty def test_save_with_new_values(self, tmp_path): cdv = tmp_path / "test.cdv" rows = create_empty_dataview() rows[0].address = "X001" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT rows[0].new_value = "1" save_cdv(cdv, rows, has_new_values=True) @@ -534,7 +596,7 @@ def test_check_cdv_file_valid(self, tmp_path): cdv = tmp_path / "valid.cdv" rows = create_empty_dataview() rows[0].address = "X001" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT save_cdv(cdv, rows, has_new_values=False) assert check_cdv_file(cdv) == [] @@ -543,7 +605,7 @@ def test_check_cdv_file_invalid_address(self, tmp_path): cdv = tmp_path / "invalid-address.cdv" rows = create_empty_dataview() rows[0].address = "INVALID" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT save_cdv(cdv, rows, has_new_values=False) issues = check_cdv_file(cdv) @@ -554,18 +616,18 @@ def test_check_cdv_file_type_mismatch(self, tmp_path): cdv = tmp_path / "mismatch.cdv" rows = create_empty_dataview() rows[0].address = "DS1" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT save_cdv(cdv, rows, has_new_values=False) issues = check_cdv_file(cdv) assert len(issues) == 1 - assert "Type code mismatch" in issues[0] + assert "Data type mismatch" in issues[0] def test_check_cdv_file_invalid_new_value_bit(self, tmp_path): cdv = tmp_path / "invalid-bit.cdv" rows = create_empty_dataview() rows[0].address = "X001" - rows[0].type_code = TypeCode.BIT + rows[0].data_type = DataType.BIT rows[0].new_value = "2" save_cdv(cdv, rows, has_new_values=True) @@ -577,7 +639,7 @@ def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): cdv = tmp_path / "non-writable.cdv" rows = create_empty_dataview() rows[0].address = "XD0" - rows[0].type_code = TypeCode.HEX + rows[0].data_type = DataType.HEX rows[0].new_value = "1" save_cdv(cdv, rows, has_new_values=True) From 5b67704cd7b65d07126ff15c1cbbc1f33418b34e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:38:43 -0500 Subject: [PATCH 32/55] Refactor CDV API to DataviewFile and remove load_cdv/save_cdv - Refactor DataviewRow.new_value to native Python types with None as unset - Add DataviewFile dataclass to own CDV path/header/has_new_values/rows and provide load/save/verify instance workflows - Add read_cdv/write_cdv/verify_cdv APIs and DataviewFile display/validation helpers - Move storage<->native conversion to file boundary logic - Implement native-value verification with type-aware normalization - Preserve byte-identical output for unchanged read->save round trips save_cdv from pyclickplc.dataview and package exports - Update docs/examples to use read_cdv/write_cdv - Update dataview tests for native new_value semantics and new API surface --- README.md | 14 +- docs/guides/files.md | 6 +- src/pyclickplc/__init__.py | 12 +- src/pyclickplc/dataview.py | 500 ++++++++++++++++++++++++++----------- tests/test_dataview.py | 154 ++++++++---- 5 files changed, 474 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index 8b4ad33..f24ebbd 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,16 @@ count = write_csv("output.csv", records) Read and write CLICK DataView `.cdv` files (UTF-16 LE format). ```python -from pyclickplc import load_cdv, save_cdv +from pyclickplc import read_cdv, write_cdv -# Load — returns (rows, has_new_values, header) -rows, has_new_values, header = load_cdv("dataview.cdv") -for row in rows: +# Read +dataview = read_cdv("dataview.cdv") +for row in dataview.rows: if not row.is_empty: - print(row.address, row.type_code, row.new_value) + print(row.address, row.data_type, row.new_value) -# Save -save_cdv("output.cdv", rows, has_new_values, header) +# Write +write_cdv("output.cdv", dataview) ``` ## Address Parsing diff --git a/docs/guides/files.md b/docs/guides/files.md index 891a538..5269df2 100644 --- a/docs/guides/files.md +++ b/docs/guides/files.md @@ -12,10 +12,10 @@ count = write_csv("output.csv", records) ## DataView CDV ```python -from pyclickplc import load_cdv, save_cdv +from pyclickplc import read_cdv, write_cdv -rows, has_new_values, header = load_cdv("dataview.cdv") -save_cdv("output.cdv", rows, has_new_values, header) +dataview = read_cdv("dataview.cdv") +write_cdv("output.cdv", dataview) ``` ## Address Helpers diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index b275bc4..c2ee43d 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -28,12 +28,14 @@ ) from .client import ClickClient, ModbusResponse from .dataview import ( + DataviewFile, DataviewRow, check_cdv_file, get_data_type_for_address, - load_cdv, - save_cdv, + read_cdv, validate_new_value, + verify_cdv, + write_cdv, ) from .modbus import ( ModbusMapping, @@ -76,12 +78,14 @@ "unpack_value", "ClickServer", "MemoryDataProvider", + "DataviewFile", "DataviewRow", "check_cdv_file", "get_data_type_for_address", "validate_new_value", - "load_cdv", - "save_cdv", + "read_cdv", + "write_cdv", + "verify_cdv", "read_csv", "write_csv", "validate_nickname", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 4c1b008..6ca964f 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -10,6 +10,7 @@ import struct from dataclasses import dataclass, field from pathlib import Path +from typing import TypeAlias from .addresses import format_address_display, parse_address from .banks import MEMORY_TYPE_TO_DATA_TYPE, DataType @@ -87,6 +88,7 @@ class _CdvStorageCode: # Max rows in a dataview MAX_DATAVIEW_ROWS = 100 +DataviewValue: TypeAlias = bool | int | float | str | None def get_data_type_for_address(address: str) -> DataType | None: @@ -151,7 +153,7 @@ class DataviewRow: # Core data (stored in CDV file) address: str = "" # e.g., "X001", "DS1", "CTD250" data_type: DataType | None = None # DataType for the address - new_value: str = "" # Optional new value to write + new_value: DataviewValue = None # Native Python value for optional write # Display-only fields (populated from SharedAddressData) nickname: str = field(default="", compare=False) @@ -187,14 +189,6 @@ def address_number(self) -> str | None: display = format_address_display(memory_type, mdb_address) return display[len(memory_type) :] - @property - def new_value_display(self) -> str: - """Get New Value as a display string.""" - if not self.new_value or self.data_type is None: - return "" - native = storage_to_datatype(self.new_value, self.data_type) - return datatype_to_display(native, self.data_type) - def update_data_type(self) -> bool: """Update the DataType based on the current address. @@ -207,32 +201,11 @@ def update_data_type(self) -> bool: return True return False - def set_new_value_from_display(self, display_str: str) -> bool: - """Set New Value from a user-entered display string.""" - if not display_str: - self.new_value = "" - return True - if self.data_type is None: - return False - native = display_to_datatype(display_str, self.data_type) - if native is None: - return False - self.new_value = datatype_to_storage(native, self.data_type) - return True - - def validate_new_value(self, display_str: str) -> tuple[bool, str]: - """Validate a user-entered New Value for this row.""" - if not self.is_writable: - return False, "Read-only address" - if self.data_type is None: - return False, "No address set" - return validate_new_value(display_str, self.data_type) - def clear(self) -> None: """Clear all fields in this row.""" self.address = "" self.data_type = None - self.new_value = "" + self.new_value = None self.nickname = "" self.comment = "" @@ -459,146 +432,349 @@ def validate_new_value(display_str: str, data_type: DataType) -> tuple[bool, str # ============================================================================= -def load_cdv(path: Path | str) -> tuple[list[DataviewRow], bool, str]: - """Load a CDV file. +@dataclass(frozen=True) +class DisplayParseResult: + """Result object for non-throwing display -> native parsing.""" - Args: - path: Path to the CDV file. + ok: bool + value: DataviewValue = None + error: str = "" - Returns: - Tuple of (rows, has_new_values, header) where: - - rows: List of DataviewRow objects (always MAX_DATAVIEW_ROWS length) - - has_new_values: True if the dataview has new values set - - header: The original header line from the file - - Raises: - FileNotFoundError: If the file doesn't exist. - ValueError: If the file format is invalid. - """ - path = Path(path) - if not path.exists(): - raise FileNotFoundError(f"CDV file not found: {path}") - # Read file with UTF-16 encoding - content = path.read_text(encoding="utf-16") - lines = content.strip().split("\n") +def _default_cdv_header(has_new_values: bool) -> str: + return f"{-1 if has_new_values else 0},0,0" - if not lines: - raise ValueError(f"Empty CDV file: {path}") - # Parse header line - preserve the original - header = lines[0].strip() - header_parts = [p.strip() for p in header.split(",")] - if len(header_parts) < 1: - raise ValueError(f"Invalid CDV header: {header}") +def _rows_snapshot(rows: list[DataviewRow]) -> list[tuple[str, DataType | None, DataviewValue]]: + return [(row.address, row.data_type, row.new_value) for row in rows] - # First value: 0 = no new values, -1 = has new values - try: - has_new_values = int(header_parts[0]) == -1 - except ValueError: - has_new_values = False - # Parse data rows - rows = create_empty_dataview() - data_lines = lines[1 : MAX_DATAVIEW_ROWS + 1] +def _coerce_for_compare(value: DataviewValue, data_type: DataType) -> DataviewValue: + if value is None: + return None + + if data_type == DataType.BIT: + if isinstance(value, bool): + return value + if isinstance(value, int): + if value in (0, 1): + return bool(value) + return None + if isinstance(value, float): + if value.is_integer() and int(value) in (0, 1): + return bool(int(value)) + return None + return None - for i, line in enumerate(data_lines): - if i >= MAX_DATAVIEW_ROWS: - break + if data_type in (DataType.INT, DataType.INT2, DataType.HEX): + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + if value.is_integer(): + return int(value) + return None + return None - line = line.strip() - if not line: - continue + if data_type == DataType.FLOAT: + try: + return float(value) + except (TypeError, ValueError): + return None - parts = [p.strip() for p in line.split(",")] + if data_type == DataType.TXT: + if isinstance(value, str): + return value + if isinstance(value, int) and 0 <= value <= 127: + return chr(value) + return None - # Empty row: ",0" or just "," - if not parts[0]: - continue + return value - # Parse address - address = parts[0] - rows[i].address = address - # Parse type code and map to DataType - if len(parts) > 1 and parts[1]: - try: - cdv_code = int(parts[1]) - rows[i].data_type = _CDV_CODE_TO_DATA_TYPE.get(cdv_code) - except ValueError: - rows[i].data_type = None +def _values_equal_for_data_type( + expected: DataviewValue, + actual: DataviewValue, + data_type: DataType | None, +) -> bool: + if expected is None and actual is None: + return True + if data_type is None: + return expected == actual - # Infer DataType from address if missing/invalid in file - if rows[i].data_type is None: - rows[i].data_type = get_data_type_for_address(address) + expected_norm = _coerce_for_compare(expected, data_type) + actual_norm = _coerce_for_compare(actual, data_type) - # Parse new value (if present and has_new_values flag is set) - if len(parts) > 2 and parts[2]: - rows[i].new_value = parts[2] + if expected_norm is None or actual_norm is None: + return expected == actual - return rows, has_new_values, header + if data_type == DataType.FLOAT: + return abs(float(expected_norm) - float(actual_norm)) <= 1e-6 + return expected_norm == actual_norm -def save_cdv( - path: Path | str, - rows: list[DataviewRow], - has_new_values: bool, - header: str | None = None, -) -> None: - """Save a CDV file. - Args: - path: Path to save the CDV file. - rows: List of DataviewRow objects (may exceed MAX_DATAVIEW_ROWS). - has_new_values: True if any rows have new values set. - header: Original header line to preserve. If None, uses default format. - - Note: - Only the first MAX_DATAVIEW_ROWS (100) rows are saved to maintain - file format compatibility. Overflow rows (index 100+) are not persisted. - """ - path = Path(path) +def _row_placeholder(rows: list[DataviewRow], index: int) -> DataviewRow: + return rows[index] if index < len(rows) else DataviewRow() - # Build content - lines: list[str] = [] - # Header line - use original if provided, otherwise build default - if header is not None: - lines.append(header) - else: - header_flag = -1 if has_new_values else 0 - lines.append(f"{header_flag},0,0") +@dataclass +class DataviewFile: + """CDV file model with row data in native Python types.""" + + rows: list[DataviewRow] = field(default_factory=create_empty_dataview) + has_new_values: bool = False + header: str = "0,0,0" + path: Path | None = None + _original_bytes: bytes | None = field(default=None, repr=False, compare=False) + _original_rows: list[tuple[str, DataType | None, DataviewValue]] = field( + default_factory=list, + repr=False, + compare=False, + ) + _original_has_new_values: bool | None = field(default=None, repr=False, compare=False) + _original_header: str | None = field(default=None, repr=False, compare=False) + _row_storage_tokens: list[str | None] = field(default_factory=list, repr=False, compare=False) + + @staticmethod + def value_to_display(value: DataviewValue, data_type: DataType | None) -> str: + """Render a native value as a display string.""" + if data_type is None: + return "" + return datatype_to_display(value, data_type) - # Data rows - only save first MAX_DATAVIEW_ROWS - rows_to_save = list(rows[:MAX_DATAVIEW_ROWS]) + @staticmethod + def validate_display(display_str: str, data_type: DataType | None) -> tuple[bool, str]: + """Validate display text for a target data type.""" + if data_type is None: + return False, "No address set" + return validate_new_value(display_str, data_type) + + @staticmethod + def try_parse_display(display_str: str, data_type: DataType | None) -> DisplayParseResult: + """Parse a display string to a native value without raising.""" + if not display_str: + return DisplayParseResult(ok=True, value=None) + ok, error = DataviewFile.validate_display(display_str, data_type) + if not ok or data_type is None: + return DisplayParseResult(ok=False, error=error) + native = display_to_datatype(display_str, data_type) + if native is None: + return DisplayParseResult(ok=False, error="Invalid value") + return DisplayParseResult(ok=True, value=native) - # Pad with empty rows if needed to always have exactly 100 lines - while len(rows_to_save) < MAX_DATAVIEW_ROWS: - rows_to_save.append(DataviewRow()) + @staticmethod + def validate_row_display(row: DataviewRow, display_str: str) -> tuple[bool, str]: + """Validate a user edit for a specific row.""" + if not row.is_writable: + return False, "Read-only address" + return DataviewFile.validate_display(display_str, row.data_type) + + @staticmethod + def set_row_new_value_from_display(row: DataviewRow, display_str: str) -> None: + """Strictly set a row's native new_value from a display string.""" + ok, error = DataviewFile.validate_row_display(row, display_str) + if not ok: + raise ValueError(error) + parsed = DataviewFile.try_parse_display(display_str, row.data_type) + if not parsed.ok: + raise ValueError(parsed.error) + row.new_value = parsed.value + + @classmethod + def load(cls, path: Path | str) -> DataviewFile: + """Load a CDV file and parse new values into native Python types.""" + path_obj = Path(path) + if not path_obj.exists(): + raise FileNotFoundError(f"CDV file not found: {path_obj}") + + raw_bytes = path_obj.read_bytes() + content = raw_bytes.decode("utf-16") + lines = content.splitlines() + if not lines: + raise ValueError(f"Empty CDV file: {path_obj}") + + header = lines[0].strip() + header_parts = [p.strip() for p in header.split(",")] + if len(header_parts) < 1: + raise ValueError(f"Invalid CDV header: {header}") - for row in rows_to_save: - if row.is_empty: - lines.append(",0") - else: - data_type = ( - row.data_type - if row.data_type is not None - else get_data_type_for_address(row.address) - ) - if data_type is None: - cdv_code = _CdvStorageCode.INT - else: - cdv_code = _DATA_TYPE_TO_CDV_CODE[data_type] - if row.new_value: - lines.append(f"{row.address},{cdv_code},{row.new_value}") + try: + has_new_values = int(header_parts[0]) == -1 + except ValueError: + has_new_values = False + + rows = create_empty_dataview() + row_storage_tokens: list[str | None] = [None] * MAX_DATAVIEW_ROWS + + for i, raw_line in enumerate(lines[1 : MAX_DATAVIEW_ROWS + 1]): + line = raw_line.strip() + if not line: + continue + + parts = [p.strip() for p in line.split(",")] + if not parts[0]: + continue + + row = rows[i] + row.address = parts[0] + + if len(parts) > 1 and parts[1]: + try: + cdv_code = int(parts[1]) + row.data_type = _CDV_CODE_TO_DATA_TYPE.get(cdv_code) + except ValueError: + row.data_type = None + + if row.data_type is None: + row.data_type = get_data_type_for_address(row.address) + + if len(parts) > 2 and parts[2]: + row_storage_tokens[i] = parts[2] + if row.data_type is None: + row.new_value = parts[2] + else: + row.new_value = storage_to_datatype(parts[2], row.data_type) + + dataview = cls( + rows=rows, + has_new_values=has_new_values, + header=header, + path=path_obj, + ) + dataview._original_bytes = raw_bytes + dataview._original_rows = _rows_snapshot(rows) + dataview._original_has_new_values = has_new_values + dataview._original_header = header + dataview._row_storage_tokens = row_storage_tokens + return dataview + + def _is_pristine(self) -> bool: + if self._original_bytes is None: + return False + if self._original_has_new_values is None or self._original_header is None: + return False + return ( + self.header == self._original_header + and self.has_new_values == self._original_has_new_values + and _rows_snapshot(self.rows) == self._original_rows + ) + + def _header_with_current_flag(self) -> str: + header = self.header or _default_cdv_header(self.has_new_values) + parts = [p.strip() for p in header.split(",")] + if not parts: + return _default_cdv_header(self.has_new_values) + parts[0] = "-1" if self.has_new_values else "0" + return ",".join(parts) + + def save(self, path: Path | str | None = None) -> None: + """Save CDV back to disk, converting native values at the file boundary.""" + target = Path(path) if path is not None else self.path + if target is None: + raise ValueError("No path provided for save") + + self.has_new_values = any( + row.new_value is not None for row in self.rows[:MAX_DATAVIEW_ROWS] if not row.is_empty + ) + self.header = self._header_with_current_flag() + + if self._is_pristine(): + target.write_bytes(self._original_bytes or b"") + self.path = target + return + + lines: list[str] = [self.header] + rows_to_save = list(self.rows[:MAX_DATAVIEW_ROWS]) + while len(rows_to_save) < MAX_DATAVIEW_ROWS: + rows_to_save.append(DataviewRow()) + + new_storage_tokens: list[str | None] = [] + for row in rows_to_save: + if row.is_empty: + lines.append(",0") + new_storage_tokens.append(None) + continue + + data_type = row.data_type if row.data_type is not None else get_data_type_for_address(row.address) + cdv_code = _CdvStorageCode.INT if data_type is None else _DATA_TYPE_TO_CDV_CODE[data_type] + + if row.new_value is not None: + if data_type is None: + storage_value = str(row.new_value) + else: + storage_value = datatype_to_storage(row.new_value, data_type) + lines.append(f"{row.address},{cdv_code},{storage_value}") + new_storage_tokens.append(storage_value) else: lines.append(f"{row.address},{cdv_code}") + new_storage_tokens.append(None) + + content = "\n".join(lines) + "\n" + encoded = content.encode("utf-16") + target.write_bytes(encoded) + self.path = target + self._original_bytes = encoded + self._original_rows = _rows_snapshot(self.rows) + self._original_has_new_values = self.has_new_values + self._original_header = self.header + self._row_storage_tokens = new_storage_tokens + + def verify(self, path: Path | str | None = None) -> list[str]: + """Compare this in-memory dataview to a CDV file on disk.""" + target = Path(path) if path is not None else self.path + if target is None: + raise ValueError("No path provided for verify") + + disk = DataviewFile.load(target) + issues: list[str] = [] + + for i in range(MAX_DATAVIEW_ROWS): + expected = _row_placeholder(self.rows, i) + actual = _row_placeholder(disk.rows, i) + row_num = i + 1 + + if expected.address != actual.address: + issues.append( + f"CDV {target.name} row {row_num}: address mismatch " + f"(memory={expected.address!r}, file={actual.address!r})" + ) + continue + + if expected.is_empty and actual.is_empty: + continue + + if expected.data_type != actual.data_type: + issues.append( + f"CDV {target.name} row {row_num}: data_type mismatch " + f"(memory={expected.data_type}, file={actual.data_type})" + ) + continue + + compare_type = expected.data_type or actual.data_type + if not _values_equal_for_data_type(expected.new_value, actual.new_value, compare_type): + issues.append( + f"CDV {target.name} row {row_num}: new_value mismatch " + f"(memory={expected.new_value!r}, file={actual.new_value!r})" + ) - # Join with newlines and add trailing newline - content = "\n".join(lines) + "\n" + if self.has_new_values != disk.has_new_values: + issues.append( + f"CDV {target.name}: has_new_values mismatch " + f"(memory={self.has_new_values}, file={disk.has_new_values})" + ) + + return issues + + +def read_cdv(path: Path | str) -> DataviewFile: + """Read a CDV file into a DataviewFile model.""" + return DataviewFile.load(path) - # Write with UTF-16 encoding (includes BOM automatically) - path.write_text(content, encoding="utf-16") +def write_cdv(path: Path | str, dataview: DataviewFile) -> None: + """Write a DataviewFile to a CDV path.""" + dataview.save(path) def _validate_cdv_new_value( @@ -690,15 +866,20 @@ def check_cdv_file(path: Path | str) -> list[str]: filename = path.name try: - rows, _has_new_values, _header = load_cdv(path) + dataview = DataviewFile.load(path) except Exception as exc: # pragma: no cover - exercised by caller tests return [f"CDV {filename}: Error loading file - {exc}"] - for i, row in enumerate(rows): + for i, row in enumerate(dataview.rows): if row.is_empty: continue row_num = i + 1 + raw_new_value = ( + dataview._row_storage_tokens[i] + if i < len(dataview._row_storage_tokens) + else None + ) try: memory_type, _mdb_address = parse_address(row.address) @@ -717,7 +898,7 @@ def check_cdv_file(path: Path | str) -> list[str]: f"(has {row.data_type}, expected {expected_data_type})" ) - if row.new_value: + if raw_new_value: if row.data_type is None: issues.append( f"CDV {filename} row {row_num}: {row.address} has new_value but no data_type set" @@ -725,7 +906,11 @@ def check_cdv_file(path: Path | str) -> list[str]: else: issues.extend( _validate_cdv_new_value( - row.new_value, row.data_type, row.address, filename, row_num + raw_new_value, + row.data_type, + row.address, + filename, + row_num, ) ) if not is_address_writable(row.address): @@ -735,3 +920,24 @@ def check_cdv_file(path: Path | str) -> list[str]: ) return issues + + +def verify_cdv( + path: Path | str, + rows: list[DataviewRow], + has_new_values: bool | None = None, +) -> list[str]: + """Verify in-memory rows against a CDV file using native value comparison.""" + has_values = has_new_values + if has_values is None: + has_values = any( + row.new_value is not None for row in rows[:MAX_DATAVIEW_ROWS] if not row.is_empty + ) + + dataview = DataviewFile( + rows=rows, + has_new_values=has_values, + header=_default_cdv_header(has_values), + path=Path(path), + ) + return dataview.verify(path) diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 5dfa6c8..fa90cdf 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -7,7 +7,9 @@ MAX_DATAVIEW_ROWS, WRITABLE_SC, WRITABLE_SD, + DataviewFile, DataviewRow, + DisplayParseResult, _CdvStorageCode, check_cdv_file, create_empty_dataview, @@ -16,13 +18,28 @@ display_to_datatype, get_data_type_for_address, is_address_writable, - load_cdv, - save_cdv, + read_cdv, storage_to_datatype, validate_new_value, + verify_cdv, + write_cdv, ) +def load_cdv(path): + dataview = read_cdv(path) + return dataview.rows, dataview.has_new_values, dataview.header + + +def save_cdv(path, rows, has_new_values: bool, header: str | None = None): + dataview = DataviewFile( + rows=rows, + has_new_values=has_new_values, + header=header or f"{-1 if has_new_values else 0},0,0", + ) + write_cdv(path, dataview) + + class TestGetDataTypeForAddress: """Tests for get_data_type_for_address function.""" @@ -104,7 +121,7 @@ def test_default_values(self): row = DataviewRow() assert row.address == "" assert row.data_type is None - assert row.new_value == "" + assert row.new_value is None assert row.nickname == "" assert row.comment == "" @@ -151,14 +168,14 @@ def test_clear(self): row = DataviewRow( address="X001", data_type=DataType.BIT, - new_value="1", + new_value=True, nickname="Test", comment="Comment", ) row.clear() assert row.address == "" assert row.data_type is None - assert row.new_value == "" + assert row.new_value is None assert row.nickname == "" assert row.comment == "" @@ -458,53 +475,42 @@ def test_valid_int(self): assert validate_new_value("100", DataType.INT) == (True, "") -class TestDataviewRowValidateNewValue: - def test_read_only_address(self): - row = DataviewRow(address="XD0", data_type=DataType.HEX) - assert row.validate_new_value("0001") == (False, "Read-only address") - - def test_no_data_type(self): - row = DataviewRow(address="DS1") - assert row.validate_new_value("100") == (False, "No address set") - - def test_delegates_validation(self): - row = DataviewRow(address="DS1", data_type=DataType.INT) - assert row.validate_new_value("abc") == (False, "Must be integer") - - -class TestNewValueDisplay: - def test_empty_when_no_new_value(self): - row = DataviewRow(address="DS1", data_type=DataType.INT) - assert row.new_value_display == "" +class TestDataviewFileDisplayHelpers: + def test_value_to_display(self): + assert DataviewFile.value_to_display(100, DataType.INT) == "100" + assert DataviewFile.value_to_display(None, DataType.INT) == "" - def test_empty_when_no_data_type(self): - row = DataviewRow(address="DS1", new_value="100") - assert row.new_value_display == "" + def test_try_parse_display(self): + parsed = DataviewFile.try_parse_display("100", DataType.INT) + assert parsed == DisplayParseResult(ok=True, value=100, error="") - def test_int_round_trip_display(self): - row = DataviewRow(address="DS1", data_type=DataType.INT, new_value="100") - assert row.new_value_display == "100" + parsed_empty = DataviewFile.try_parse_display("", DataType.INT) + assert parsed_empty == DisplayParseResult(ok=True, value=None, error="") + parsed_invalid = DataviewFile.try_parse_display("abc", DataType.INT) + assert parsed_invalid.ok is False + assert parsed_invalid.error == "Must be integer" -class TestSetNewValueFromDisplay: - def test_clear_on_empty(self): - row = DataviewRow(address="DS1", data_type=DataType.INT, new_value="100") - assert row.set_new_value_from_display("") is True - assert row.new_value == "" + def test_validate_row_display(self): + row = DataviewRow(address="XD0", data_type=DataType.HEX) + assert DataviewFile.validate_row_display(row, "0001") == (False, "Read-only address") - def test_fails_without_data_type(self): row = DataviewRow(address="DS1") - assert row.set_new_value_from_display("100") is False + assert DataviewFile.validate_row_display(row, "100") == (False, "No address set") - def test_fails_on_invalid_input(self): row = DataviewRow(address="DS1", data_type=DataType.INT) - assert row.set_new_value_from_display("abc") is False + assert DataviewFile.validate_row_display(row, "abc") == (False, "Must be integer") - def test_sets_storage_value(self): + def test_set_row_new_value_from_display(self): row = DataviewRow(address="DS1", data_type=DataType.INT) - assert row.set_new_value_from_display("100") is True - assert row.new_value == "100" - assert row.new_value_display == "100" + DataviewFile.set_row_new_value_from_display(row, "100") + assert row.new_value == 100 + + DataviewFile.set_row_new_value_from_display(row, "") + assert row.new_value is None + + with pytest.raises(ValueError, match="Must be integer"): + DataviewFile.set_row_new_value_from_display(row, "abc") class TestLoadCdv: @@ -549,7 +555,7 @@ def test_load_with_new_values(self, tmp_path): rows, has_new_values, _header = load_cdv(cdv) assert has_new_values is True - assert rows[0].new_value == "1" + assert rows[0].new_value is True def test_load_nonexistent(self, tmp_path): with pytest.raises(FileNotFoundError): @@ -582,13 +588,60 @@ def test_save_with_new_values(self, tmp_path): rows = create_empty_dataview() rows[0].address = "X001" rows[0].data_type = DataType.BIT - rows[0].new_value = "1" + rows[0].new_value = True save_cdv(cdv, rows, has_new_values=True) loaded_rows, has_new_values, _header = load_cdv(cdv) assert has_new_values is True - assert loaded_rows[0].new_value == "1" + assert loaded_rows[0].new_value is True + + +class TestDataviewFileIO: + def test_read_write_aliases(self, tmp_path): + cdv = tmp_path / "test.cdv" + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].data_type = DataType.INT + rows[0].new_value = 42 + save_cdv(cdv, rows, has_new_values=True) + + dataview = read_cdv(cdv) + assert isinstance(dataview, DataviewFile) + assert dataview.rows[0].new_value == 42 + + out = tmp_path / "out.cdv" + write_cdv(out, dataview) + loaded_rows, has_new_values, _header = load_cdv(out) + assert has_new_values is True + assert loaded_rows[0].new_value == 42 + + def test_byte_identical_roundtrip(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n", f"DS1,{_CdvStorageCode.INT},100\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + original_bytes = cdv.read_bytes() + + dataview = DataviewFile.load(cdv) + dataview.save() + + assert cdv.read_bytes() == original_bytes + + def test_verify_cdv_int_float_equivalent(self, tmp_path): + cdv = tmp_path / "test.cdv" + lines = ["-1,0,0\n", f"DS1,{_CdvStorageCode.INT},1\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") + + rows = create_empty_dataview() + rows[0].address = "DS1" + rows[0].data_type = DataType.INT + rows[0].new_value = 1.0 + + assert verify_cdv(cdv, rows, has_new_values=True) == [] class TestCheckCdv: @@ -625,11 +678,10 @@ def test_check_cdv_file_type_mismatch(self, tmp_path): def test_check_cdv_file_invalid_new_value_bit(self, tmp_path): cdv = tmp_path / "invalid-bit.cdv" - rows = create_empty_dataview() - rows[0].address = "X001" - rows[0].data_type = DataType.BIT - rows[0].new_value = "2" - save_cdv(cdv, rows, has_new_values=True) + lines = ["-1,0,0\n", f"X001,{_CdvStorageCode.BIT},2\n"] + for _ in range(99): + lines.append(",0\n") + cdv.write_text("".join(lines), encoding="utf-16") issues = check_cdv_file(cdv) assert len(issues) == 1 @@ -640,7 +692,7 @@ def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): rows = create_empty_dataview() rows[0].address = "XD0" rows[0].data_type = DataType.HEX - rows[0].new_value = "1" + rows[0].new_value = 1 save_cdv(cdv, rows, has_new_values=True) issues = check_cdv_file(cdv) From f4ca095925365fb39a37a0d9f263b33186e0e2f8 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 15 Feb 2026 08:20:29 -0500 Subject: [PATCH 33/55] feat(modbus): add synchronous ModbusService with polling and batched I/O Introduce a pyclickplc-owned ModbusService for synchronous/UI callers. - Add `ModbusService`, `ConnectionState`, and `WriteResult` in `src/pyclickplc/modbus_service.py` - Implement background thread + asyncio loop bridge with connect/disconnect lifecycle - Add replace-style poll configuration (`set_poll_addresses`, `clear_poll_addresses`, `stop_polling`) and periodic `on_values` callback emission - Add synchronous `read` with canonical normalized address keys and ClickClient-style error semantics (`ValueError` for invalid addresses, `OSError` for transport failures) - Add synchronous `write` accepting mapping or iterable inputs and returning per-address outcomes for partial success/error reporting - Implement deterministic read/write batching with sparse bank and width-2 bank handling, respecting Modbus request size limits - Export new public API from `src/pyclickplc/__init__.py` - Add docs: README updates, `docs/guides/modbus_service.md`, docs nav/index links - Add `tests/test_modbus_service.py` covering lifecycle, polling semantics, read/write behavior, batching heuristics, and thread-safety Validation: `make lint`, `uv run ty check src tests`, and `make test` all pass. --- README.md | 33 ++ docs/guides/modbus_service.md | 36 +++ docs/index.md | 1 + mkdocs.yml | 2 + scratchpad/modbus-service-plan.md | 153 +++++++++ src/pyclickplc/__init__.py | 4 + src/pyclickplc/modbus_service.py | 514 ++++++++++++++++++++++++++++++ tests/test_modbus_service.py | 380 ++++++++++++++++++++++ 8 files changed, 1123 insertions(+) create mode 100644 docs/guides/modbus_service.md create mode 100644 scratchpad/modbus-service-plan.md create mode 100644 src/pyclickplc/modbus_service.py create mode 100644 tests/test_modbus_service.py diff --git a/README.md b/README.md index f24ebbd..503bc5e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,39 @@ asyncio.run(main()) All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`). Lookups are normalized (`resp["ds1"]` resolves `"DS1"`). Use `await plc.ds[1]` for a bare value. +## ModbusService (Sync + Polling) + +`ModbusService` is a synchronous wrapper intended for UI/event-driven callers. It owns a background asyncio loop and provides polling plus bulk writes. + +```python +from pyclickplc import ModbusService + +def on_values(values): + print(values) # ModbusResponse keyed by canonical addresses + +svc = ModbusService(poll_interval_s=0.5, on_values=on_values) +svc.connect("192.168.1.10", 502, device_id=1, timeout=1) + +svc.set_poll_addresses(["ds1", "df1", "y1"]) +print(svc.read(["ds1", "df1"])) + +results = svc.write( + { + "ds1": 100, + "y1": True, + "x1": True, # not writable -> per-item failure entry + } +) +print(results) + +svc.disconnect() +``` + +Error semantics: +- invalid read addresses raise `ValueError` +- transport/protocol read failures raise `OSError` +- writes return per-address outcomes (`ok` + `error`) for UI reporting + ## Modbus Server `ClickServer` simulates a CLICK PLC over Modbus TCP. `MemoryDataProvider` is the built-in in-memory backend. diff --git a/docs/guides/modbus_service.md b/docs/guides/modbus_service.md new file mode 100644 index 0000000..a7e6b11 --- /dev/null +++ b/docs/guides/modbus_service.md @@ -0,0 +1,36 @@ +# Modbus Service + +`ModbusService` provides a synchronous API on top of `ClickClient` for UI and service callers that do not want to manage `asyncio` directly. + +```python +from pyclickplc import ModbusService + +def on_values(values): + # Runs on the service thread. + # GUI apps should marshal this callback to the UI thread. + print(values) + +svc = ModbusService(poll_interval_s=0.5, on_values=on_values) +svc.connect("192.168.1.10", 502, device_id=1, timeout=1) + +svc.set_poll_addresses(["DS1", "DF1", "Y1"]) +latest = svc.read(["DS1", "DF1"]) +write_results = svc.write({"DS1": 10, "Y1": True}) + +svc.disconnect() +``` + +## API Notes + +- `set_poll_addresses(addresses)` replaces the active poll set. +- `clear_poll_addresses()` clears the set. +- `stop_polling()` pauses polling until `set_poll_addresses(...)` is called again. +- `read(...)` returns `ModbusResponse` keyed by canonical uppercase addresses. +- `write(...)` accepts either a mapping or iterable of `(address, value)` pairs and returns per-address results. + +## Error Semantics + +- Invalid addresses/values at write-time are returned per address with `ok=False`. +- Invalid addresses passed to `read(...)` raise `ValueError`. +- Transport/protocol errors raise `OSError` for reads and are reported per-address for writes. +- `on_state` and `on_values` callbacks run on the service thread. diff --git a/docs/index.md b/docs/index.md index 27862b5..0163ddf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ `pyclickplc` provides tools for AutomationDirect CLICK PLCs: - Async Modbus TCP client (`ClickClient`) +- Sync + polling Modbus service (`ModbusService`) - Modbus TCP simulator (`ClickServer`, `MemoryDataProvider`) - Address parsing/normalization helpers - CLICK nickname CSV and DataView CDV file I/O diff --git a/mkdocs.yml b/mkdocs.yml index c521010..b5251df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Home: index.md - Core Usage: - Modbus Client: guides/client.md + - Modbus Service: guides/modbus_service.md - Modbus Server: guides/server.md - File I/O: guides/files.md - API Reference: @@ -22,6 +23,7 @@ nav: - client: reference/api/client.md - dataview: reference/api/dataview.md - modbus: reference/api/modbus.md + - modbus_service: reference/api/modbus_service.md - nicknames: reference/api/nicknames.md - server: reference/api/server.md - validation: reference/api/validation.md diff --git a/scratchpad/modbus-service-plan.md b/scratchpad/modbus-service-plan.md new file mode 100644 index 0000000..eb5d9ff --- /dev/null +++ b/scratchpad/modbus-service-plan.md @@ -0,0 +1,153 @@ +# ModbusService Plan (pyclickplc) + +## Goal + +Add a `pyclickplc`-owned `ModbusService` that gives synchronous/UI callers a simple API for: + +- live polling of a dynamic address set +- bulk writes of address/value pairs +- automatic batching and Modbus-efficient execution + +This service must match existing `pyclickplc` ergonomics and error semantics used by `ClickClient` and `ClickServer`. + +## Ergonomics Contract + +Use the same conventions already established by `ClickClient`: + +- Address inputs accept normal display strings and are canonicalized (`normalize_address`). +- Invalid addresses / invalid values / non-writable writes fail with `ValueError`. +- Modbus transport/protocol failures fail with `OSError`. +- Read results are normalized mappings keyed by canonical uppercase addresses. +- API names stay action-oriented and explicit (`read`, `write`, `connect`, `disconnect`). + +## Public API + +New module: `src/pyclickplc/modbus_service.py` + +```python +from collections.abc import Callable, Iterable, Mapping +from enum import Enum + +PlcValue = bool | int | float | str + +class ConnectionState(Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + +class WriteResult(TypedDict): + address: str + ok: bool + error: str | None + +class ModbusService: + def __init__( + self, + poll_interval_s: float = 1.5, + on_state: Callable[[ConnectionState, Exception | None], None] | None = None, + on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, + ) -> None: ... + + # Lifecycle + def connect(self, host: str, port: int = 502, *, device_id: int = 1, timeout: int = 1) -> None: ... + def disconnect(self) -> None: ... + + # Poll configuration (replace semantics) + def set_poll_addresses(self, addresses: Iterable[str]) -> None: ... + def clear_poll_addresses(self) -> None: ... + def stop_polling(self) -> None: ... + + # Sync convenience operations + def read(self, addresses: Iterable[str]) -> ModbusResponse[PlcValue]: ... + def write(self, values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]]) -> list[WriteResult]: ... +``` + +Notes: + +- `set_poll_addresses(...)` means "replace current poll set", not incremental subscribe. +- `write(...)` accepts either mapping or iterable for ergonomic parity with existing APIs. +- `write(...)` returns per-address outcomes to support partial success reporting in UIs. + +## Internal Design + +1. Thread + event loop bridge +- One background daemon thread owns one asyncio event loop. +- A readiness event gates scheduling work until loop is initialized. +- `connect()`/`disconnect()`/`set_poll_addresses()`/`read()`/`write()` schedule coroutines with thread-safe submission and wait only when required (`read`, `write`). + +2. Client ownership +- Service owns one `ClickClient` instance while connected. +- Use async context lifecycle semantics equivalent to `ClickClient.__aenter__/__aexit__`. + +3. Polling model +- Poll loop runs only when connected and poll set is non-empty. +- Each cycle reads current poll set, performs bank-batched reads, emits one merged `ModbusResponse`. +- `set_poll_addresses(...)` is atomic replacement and takes effect next poll cycle. + +4. Batch planning +- Parse/normalize addresses once per plan update. +- Group by bank. +- Build contiguous spans where efficient and legal. +- Respect sparse bank behavior (`X`/`Y`) and width-2 banks (`DD`/`DF`/`CTD`). +- Respect Modbus limits per call (max coil/register count). + +5. Write execution +- Normalize/validate each address and value using existing `ClickClient` semantics. +- Coalesce consecutive writes by bank/type where safe; fallback to per-address writes when needed. +- Return `WriteResult` per requested address in original input order. + +6. Callback delivery +- `on_state` and `on_values` are invoked from the service thread. +- GUI consumers must marshal to UI thread (`widget.after(...)` in Tk). + +## Integration Points + +1. Exports +- Add `ModbusService`, `ConnectionState`, and `WriteResult` to `src/pyclickplc/__init__.py`. + +2. Docs +- Update `README.md` quickstart with one `ModbusService` polling/write example. +- Add `docs/guides/modbus_service.md`. +- Link from `docs/index.md` and include error semantics. + +## Tests + +New test file: `tests/test_modbus_service.py` + +1. Lifecycle/state +- DISCONNECTED -> CONNECTING -> CONNECTED -> DISCONNECTED. +- connection failure -> ERROR with callback payload. + +2. Poll configuration +- `set_poll_addresses(...)` replaces set. +- `clear_poll_addresses()` empties set. +- polling stops when no addresses. + +3. Read behavior +- `read([...])` returns `ModbusResponse` with canonical keys. +- invalid addresses raise `ValueError`. +- transport errors raise `OSError`. + +4. Write behavior +- accepts mapping and iterable inputs. +- non-writable address result marked failed (`ok=False`, error message). +- invalid value gives failed result and does not crash service. +- transport failure produces failed result with error text. + +5. Batching heuristics +- group-by-bank and contiguous range choices are deterministic. +- sparse bank handling does not bridge invalid gaps incorrectly. +- width-2 banks honor register width when building spans. + +6. Thread safety +- concurrent `set_poll_addresses(...)` during poll does not crash. +- `disconnect()` during active poll exits cleanly. + +## Acceptance Criteria + +- `ModbusService` API is stable and documented in `pyclickplc`. +- Read/write behavior matches `ClickClient` conventions (normalization + exceptions). +- Poll set replacement via `set_poll_addresses(...)` works without reconnecting. +- Write results provide per-address outcome for UI-level reporting. +- New tests pass under `make test`. diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index c2ee43d..9986d54 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -44,6 +44,7 @@ plc_to_modbus, unpack_value, ) +from .modbus_service import ConnectionState, ModbusService, WriteResult from .nicknames import read_csv, write_csv from .server import ClickServer, MemoryDataProvider from .validation import validate_comment, validate_initial_value, validate_nickname @@ -76,6 +77,9 @@ "modbus_to_plc", "pack_value", "unpack_value", + "ModbusService", + "ConnectionState", + "WriteResult", "ClickServer", "MemoryDataProvider", "DataviewFile", diff --git a/src/pyclickplc/modbus_service.py b/src/pyclickplc/modbus_service.py new file mode 100644 index 0000000..2d97e60 --- /dev/null +++ b/src/pyclickplc/modbus_service.py @@ -0,0 +1,514 @@ +"""Synchronous Modbus service for UI callers. + +`ModbusService` wraps `ClickClient` in a background asyncio loop so callers can: +- connect/disconnect synchronously +- poll a replaceable set of addresses +- perform synchronous batched reads/writes +""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import Callable, Coroutine, Iterable, Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Any, TypedDict, TypeVar, cast + +from .addresses import normalize_address, parse_address +from .banks import BANKS, DataType +from .client import ClickClient, ModbusResponse +from .modbus import MODBUS_MAPPINGS +from .validation import assert_runtime_value + +PlcValue = bool | int | float | str + +MAX_READ_COILS = 2000 +MAX_READ_REGISTERS = 125 +MAX_WRITE_COILS = 1968 +MAX_WRITE_REGISTERS = 123 +T = TypeVar("T") + + +class ConnectionState(Enum): + """Connection state notifications emitted by ModbusService.""" + + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + + +class WriteResult(TypedDict): + """Per-address write outcome.""" + + address: str + ok: bool + error: str | None + + +@dataclass(frozen=True) +class _ReadSpan: + bank: str + start: int + end: int + + +@dataclass(frozen=True) +class _PollConfig: + addresses: tuple[str, ...] + plan: tuple[_ReadSpan, ...] + enabled: bool + + +@dataclass(frozen=True) +class _WriteItem: + pos: int + normalized: str + bank: str + index: int + value: PlcValue + + +@dataclass(frozen=True) +class _WriteBatch: + bank: str + items: tuple[_WriteItem, ...] + + +def _default_for_bank(bank: str) -> PlcValue: + data_type = BANKS[bank].data_type + if data_type == DataType.BIT: + return False + if data_type == DataType.FLOAT: + return 0.0 + if data_type == DataType.TXT: + return "\x00" + return 0 + + +def _is_writable(bank: str, index: int) -> bool: + mapping = MODBUS_MAPPINGS[bank] + if mapping.writable is not None: + return index in mapping.writable + return mapping.is_writable + + +def _modbus_count_for_span(bank: str, start: int, end: int) -> int: + if end < start: + raise ValueError("end must be >= start") + + mapping = MODBUS_MAPPINGS[bank] + if mapping.is_coil: + return end - start + 1 + + if bank == "TXT": + first = (start - 1) // 2 + last = (end - 1) // 2 + return last - first + 1 + + return (end - start + 1) * mapping.width + + +def _max_span_count(bank: str, *, for_write: bool) -> int: + mapping = MODBUS_MAPPINGS[bank] + if mapping.is_coil: + return MAX_WRITE_COILS if for_write else MAX_READ_COILS + return MAX_WRITE_REGISTERS if for_write else MAX_READ_REGISTERS + + +def _build_spans(bank: str, indices: Iterable[int], *, for_write: bool) -> list[_ReadSpan]: + sorted_indices = sorted(set(indices)) + if not sorted_indices: + return [] + + spans: list[_ReadSpan] = [] + limit = _max_span_count(bank, for_write=for_write) + start = sorted_indices[0] + prev = start + + for idx in sorted_indices[1:]: + contiguous = idx == prev + 1 + within_limit = _modbus_count_for_span(bank, start, idx) <= limit + if contiguous and within_limit: + prev = idx + continue + + spans.append(_ReadSpan(bank=bank, start=start, end=prev)) + start = idx + prev = idx + + spans.append(_ReadSpan(bank=bank, start=start, end=prev)) + return spans + + +def _normalize_addresses_for_read( + addresses: Iterable[str], +) -> tuple[list[str], dict[str, tuple[str, int]]]: + ordered: list[str] = [] + parsed: dict[str, tuple[str, int]] = {} + seen: set[str] = set() + for address in addresses: + if not isinstance(address, str): + raise ValueError(f"Invalid address format: {address!r}") + normalized = normalize_address(address) + if normalized is None: + raise ValueError(f"Invalid address format: {address!r}") + if normalized not in seen: + seen.add(normalized) + ordered.append(normalized) + parsed[normalized] = parse_address(normalized) + return ordered, parsed + + +def _build_read_plan( + ordered: Iterable[str], parsed: Mapping[str, tuple[str, int]] +) -> list[_ReadSpan]: + by_bank: dict[str, list[int]] = {} + for address in ordered: + bank, index = parsed[address] + by_bank.setdefault(bank, []).append(index) + + plan: list[_ReadSpan] = [] + for bank, indices in by_bank.items(): + plan.extend(_build_spans(bank, indices, for_write=False)) + return plan + + +class ModbusService: + """Synchronous wrapper over ClickClient with background polling.""" + + def __init__( + self, + poll_interval_s: float = 1.5, + on_state: Callable[[ConnectionState, Exception | None], None] | None = None, + on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, + ) -> None: + if poll_interval_s <= 0: + raise ValueError("poll_interval_s must be > 0") + + self._poll_interval_s = poll_interval_s + self._on_state = on_state + self._on_values = on_values + + self._state = ConnectionState.DISCONNECTED + self._client: ClickClient | None = None + self._poll_task: asyncio.Task[None] | None = None + self._poll_config = _PollConfig(addresses=(), plan=(), enabled=True) + + self._loop_ready = threading.Event() + self._loop: asyncio.AbstractEventLoop | None = None + self._thread = threading.Thread( + target=self._run_loop, name="pyclickplc-modbus-service", daemon=True + ) + self._thread.start() + + if not self._loop_ready.wait(timeout=5): + raise RuntimeError("ModbusService event loop thread failed to start") + + # Lifecycle -------------------------------------------------------------- + + def connect( + self, + host: str, + port: int = 502, + *, + device_id: int = 1, + timeout: int = 1, + ) -> None: + self._submit_wait(self._connect_async(host, port, device_id=device_id, timeout=timeout)) + + def disconnect(self) -> None: + self._submit_wait(self._disconnect_async()) + + # Poll configuration ----------------------------------------------------- + + def set_poll_addresses(self, addresses: Iterable[str]) -> None: + normalized, parsed = _normalize_addresses_for_read(addresses) + plan = _build_read_plan(normalized, parsed) + self._submit_wait(self._set_poll_config_async(tuple(normalized), tuple(plan), enabled=True)) + + def clear_poll_addresses(self) -> None: + self._submit_wait(self._set_poll_config_async((), (), enabled=True)) + + def stop_polling(self) -> None: + self._submit_wait( + self._set_poll_config_async( + self._poll_config.addresses, + self._poll_config.plan, + enabled=False, + ) + ) + + # Sync operations -------------------------------------------------------- + + def read(self, addresses: Iterable[str]) -> ModbusResponse[PlcValue]: + normalized, parsed = _normalize_addresses_for_read(addresses) + plan = _build_read_plan(normalized, parsed) + result = self._submit_wait(self._read_plan_async(plan)) + ordered_result: dict[str, PlcValue] = {} + for address in normalized: + ordered_result[address] = result.get(address, _default_for_bank(parsed[address][0])) + return ModbusResponse(ordered_result) + + def write( + self, + values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]], + ) -> list[WriteResult]: + items: list[tuple[object, object]] + if isinstance(values, Mapping): + items = list(values.items()) + else: + items = list(values) + + return self._submit_wait(self._write_async(items)) + + # Internal loop bridge --------------------------------------------------- + + def _run_loop(self) -> None: + loop = asyncio.new_event_loop() + self._loop = loop + asyncio.set_event_loop(loop) + self._loop_ready.set() + try: + loop.run_forever() + finally: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.close() + + def _submit_wait(self, coro: Coroutine[Any, Any, T]) -> T: + loop = self._loop + if loop is None: + raise RuntimeError("Service event loop is not available") + future = asyncio.run_coroutine_threadsafe(coro, loop) + return future.result() + + # Async implementation --------------------------------------------------- + + async def _connect_async( + self, + host: str, + port: int, + *, + device_id: int, + timeout: int, + ) -> None: + if self._client is not None: + await self._disconnect_async() + + self._emit_state(ConnectionState.CONNECTING, None) + candidate: ClickClient | None = None + try: + candidate = ClickClient(host, port, timeout=timeout, device_id=device_id) + await candidate.__aenter__() + if not candidate._client.connected: # pyright: ignore[reportPrivateUsage] + raise OSError(f"Failed to connect to {host}:{port}") + self._client = candidate + self._ensure_poll_task() + self._emit_state(ConnectionState.CONNECTED, None) + except Exception as exc: + if candidate is not None: + await candidate.__aexit__(None, None, None) + if isinstance(exc, (OSError, ValueError)): + self._emit_state(ConnectionState.ERROR, exc) + raise + + wrapped = OSError(str(exc)) + self._emit_state(ConnectionState.ERROR, wrapped) + raise wrapped from exc + + async def _disconnect_async(self) -> None: + poll_task = self._poll_task + self._poll_task = None + if poll_task is not None: + poll_task.cancel() + await asyncio.gather(poll_task, return_exceptions=True) + + client = self._client + self._client = None + if client is not None: + await client.__aexit__(None, None, None) + + self._emit_state(ConnectionState.DISCONNECTED, None) + + async def _set_poll_config_async( + self, + addresses: tuple[str, ...], + plan: tuple[_ReadSpan, ...], + *, + enabled: bool, + ) -> None: + self._poll_config = _PollConfig(addresses=addresses, plan=plan, enabled=enabled) + + def _ensure_poll_task(self) -> None: + if self._poll_task is None or self._poll_task.done(): + self._poll_task = asyncio.create_task(self._poll_loop(), name="pyclickplc-poll-loop") + + async def _poll_loop(self) -> None: + try: + while True: + await asyncio.sleep(self._poll_interval_s) + + if self._client is None: + continue + + poll_config = self._poll_config + if not poll_config.enabled or not poll_config.addresses: + continue + + try: + data = await self._read_plan_async(list(poll_config.plan)) + except OSError as exc: + self._emit_state(ConnectionState.ERROR, exc) + continue + + ordered: dict[str, PlcValue] = {} + for address in poll_config.addresses: + ordered[address] = data[address] + + if self._on_values is not None: + try: + self._on_values(ModbusResponse(ordered)) + except Exception: + continue + except asyncio.CancelledError: + return + + async def _read_plan_async(self, plan: list[_ReadSpan]) -> dict[str, PlcValue]: + client = self._client + if client is None: + raise OSError("Not connected") + + merged: dict[str, PlcValue] = {} + for span in plan: + accessor = client._get_accessor(span.bank) # pyright: ignore[reportPrivateUsage] + if span.start == span.end: + response = await accessor.read(span.start) + else: + response = await accessor.read(span.start, span.end) + merged.update(response) + return merged + + async def _write_async(self, items: list[tuple[object, object]]) -> list[WriteResult]: + if not items: + return [] + + results: list[WriteResult] = [ + {"address": str(addr), "ok": False, "error": None} for addr, _ in items + ] + + validated: list[_WriteItem] = [] + for pos, (address, value) in enumerate(items): + if not isinstance(address, str): + results[pos] = { + "address": str(address), + "ok": False, + "error": "Invalid address format", + } + continue + normalized = normalize_address(address) + if normalized is None: + results[pos] = { + "address": str(address), + "ok": False, + "error": "Invalid address format", + } + continue + + bank, index = parse_address(normalized) + + if not _is_writable(bank, index): + results[pos] = { + "address": normalized, + "ok": False, + "error": f"{bank}{index} is not writable.", + } + continue + + try: + assert_runtime_value(BANKS[bank].data_type, value, bank=bank, index=index) + except ValueError as exc: + results[pos] = {"address": normalized, "ok": False, "error": str(exc)} + continue + + validated.append( + _WriteItem( + pos=pos, + normalized=normalized, + bank=bank, + index=index, + value=cast(PlcValue, value), + ) + ) + + batches = self._build_write_batches(validated) + client = self._client + if client is None: + error_text = "Not connected" + for item in validated: + results[item.pos] = {"address": item.normalized, "ok": False, "error": error_text} + return results + + for batch in batches: + accessor = client._get_accessor(batch.bank) # pyright: ignore[reportPrivateUsage] + try: + if len(batch.items) == 1: + one = batch.items[0] + await accessor.write(one.index, one.value) + else: + start = batch.items[0].index + values = [item.value for item in batch.items] + payload = cast( + "list[bool] | list[int] | list[float] | list[str]", + values, + ) + await accessor.write(start, payload) + + for item in batch.items: + results[item.pos] = {"address": item.normalized, "ok": True, "error": None} + except (OSError, ValueError) as exc: + for item in batch.items: + results[item.pos] = {"address": item.normalized, "ok": False, "error": str(exc)} + + return results + + def _build_write_batches(self, items: list[_WriteItem]) -> list[_WriteBatch]: + if not items: + return [] + + batches: list[_WriteBatch] = [] + current: list[_WriteItem] = [items[0]] + + for item in items[1:]: + prev = current[-1] + same_bank = item.bank == prev.bank + contiguous_index = item.index == prev.index + 1 + contiguous_input_order = item.pos == prev.pos + 1 + + if same_bank and contiguous_index and contiguous_input_order: + start_index = current[0].index + if _modbus_count_for_span(item.bank, start_index, item.index) <= _max_span_count( + item.bank, for_write=True + ): + current.append(item) + continue + + batches.append(_WriteBatch(bank=current[0].bank, items=tuple(current))) + current = [item] + + batches.append(_WriteBatch(bank=current[0].bank, items=tuple(current))) + return batches + + def _emit_state(self, state: ConnectionState, error: Exception | None) -> None: + if state is self._state and error is None: + return + self._state = state + if self._on_state is not None: + try: + self._on_state(state, error) + except Exception: + return diff --git a/tests/test_modbus_service.py b/tests/test_modbus_service.py new file mode 100644 index 0000000..be75305 --- /dev/null +++ b/tests/test_modbus_service.py @@ -0,0 +1,380 @@ +"""Tests for pyclickplc.modbus_service.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from collections.abc import Iterable +from dataclasses import dataclass +from typing import cast + +import pytest + +from pyclickplc.addresses import format_address_display +from pyclickplc.banks import BANKS, DataType +from pyclickplc.client import ModbusResponse +from pyclickplc.modbus_service import ConnectionState, ModbusService + + +def _default_for_data_type(data_type: DataType) -> bool | int | float | str: + if data_type == DataType.BIT: + return False + if data_type == DataType.FLOAT: + return 0.0 + if data_type == DataType.TXT: + return "\x00" + return 0 + + +class _FakeAccessor: + def __init__(self, client: _FakeClickClient, bank: str) -> None: + self._client = client + self._bank = bank + + async def read(self, start: int, end: int | None = None) -> ModbusResponse: + self._client.read_calls.append((self._bank, start, end)) + if self._client.slow_read_s > 0: + await asyncio.sleep(self._client.slow_read_s) + if self._client.read_error_bank == self._bank: + raise OSError(f"read failed for {self._bank}") + + stop = start if end is None else end + payload: dict[str, bool | int | float | str] = {} + for index in range(start, stop + 1): + address = format_address_display(self._bank, index) + payload[address] = self._client.data.get( + address, + _default_for_data_type(BANKS[self._bank].data_type), + ) + return ModbusResponse(payload) + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + self._client.write_calls.append((self._bank, start, data)) + if self._client.write_error_bank == self._bank: + raise OSError(f"write failed for {self._bank}") + + if isinstance(data, list): + for offset, value in enumerate(data): + address = format_address_display(self._bank, start + offset) + self._client.data[address] = value + return + + address = format_address_display(self._bank, start) + self._client.data[address] = data + + +class _FakeClickClient: + connect_error_hosts: set[str] = set() + default_read_error_bank: str | None = None + default_write_error_bank: str | None = None + default_slow_read_s: float = 0.0 + instances: list[_FakeClickClient] = [] + + def __init__( + self, + host: str, + port: int = 502, + tag_filepath: str = "", + timeout: int = 1, + device_id: int = 1, + ) -> None: + del tag_filepath + del timeout + del device_id + self.host = host + self.port = port + self._client = _FakeConn() + self.data: dict[str, bool | int | float | str] = {} + self.read_calls: list[tuple[str, int, int | None]] = [] + self.write_calls: list[tuple[str, int, object]] = [] + self.read_error_bank = _FakeClickClient.default_read_error_bank + self.write_error_bank = _FakeClickClient.default_write_error_bank + self.slow_read_s = _FakeClickClient.default_slow_read_s + self._accessors: dict[str, _FakeAccessor] = {} + _FakeClickClient.instances.append(self) + + async def __aenter__(self) -> _FakeClickClient: + if self.host in self.connect_error_hosts: + raise OSError(f"connect failed for {self.host}:{self.port}") + self._client.connected = True + return self + + async def __aexit__(self, *args: object) -> None: + del args + self._client.connected = False + + def _get_accessor(self, bank: str) -> _FakeAccessor: + if bank not in self._accessors: + self._accessors[bank] = _FakeAccessor(self, bank) + return self._accessors[bank] + + +@dataclass +class _FakeConn: + connected: bool = False + + +@pytest.fixture(autouse=True) +def _reset_fake(): + _FakeClickClient.connect_error_hosts = set() + _FakeClickClient.default_read_error_bank = None + _FakeClickClient.default_write_error_bank = None + _FakeClickClient.default_slow_read_s = 0.0 + _FakeClickClient.instances = [] + + +@pytest.fixture +def service(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + yield svc + finally: + svc.disconnect() + + +def _wait_for(predicate, *, timeout: float = 1.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return + time.sleep(0.01) + raise AssertionError("Condition not met before timeout") + + +class TestLifecycleState: + def test_connect_disconnect_state_transitions(self, service: ModbusService): + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + + state_service = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + state_service.connect("localhost", 15020) + state_service.disconnect() + finally: + state_service.disconnect() + + assert states == [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED, + ] + assert errors[0] is None + assert errors[1] is None + assert errors[2] is None + + def test_connect_failure_emits_error(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + _FakeClickClient.connect_error_hosts = {"bad-host"} + + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + with pytest.raises(OSError): + svc.connect("bad-host", 15020) + finally: + svc.disconnect() + + assert states[0] == ConnectionState.CONNECTING + assert states[1] == ConnectionState.ERROR + assert isinstance(errors[1], OSError) + + +class TestPollConfiguration: + def test_set_poll_addresses_replaces_and_clear_stops(self, service: ModbusService): + values_seen: list[ModbusResponse] = [] + callback_event = threading.Event() + + callback_service = ModbusService( + poll_interval_s=0.03, + on_values=lambda r: (values_seen.append(r), callback_event.set()), + ) + try: + callback_service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 11 + fake.data["DS2"] = 22 + fake.data["DS3"] = 33 + + callback_service.set_poll_addresses(["ds1", "ds2"]) + _wait_for(lambda: len(values_seen) >= 1) + assert set(values_seen[-1].keys()) == {"DS1", "DS2"} + + callback_service.set_poll_addresses(["ds3"]) + _wait_for(lambda: any(set(v.keys()) == {"DS3"} for v in values_seen)) + + before = len(values_seen) + callback_service.clear_poll_addresses() + time.sleep(0.09) + assert len(values_seen) == before + finally: + callback_service.disconnect() + + def test_stop_polling_pauses_until_reconfigured(self, service: ModbusService): + values_seen: list[ModbusResponse] = [] + + callback_service = ModbusService( + poll_interval_s=0.03, + on_values=lambda r: values_seen.append(r), + ) + try: + callback_service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 1 + fake.data["DS2"] = 2 + + callback_service.set_poll_addresses(["DS1"]) + _wait_for(lambda: len(values_seen) >= 1) + + before = len(values_seen) + callback_service.stop_polling() + time.sleep(0.09) + assert len(values_seen) == before + + callback_service.set_poll_addresses(["DS2"]) + _wait_for(lambda: any("DS2" in sample for sample in values_seen)) + finally: + callback_service.disconnect() + + +class TestReadBehavior: + def test_read_returns_modbus_response_with_canonical_keys(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.data["DS1"] = 42 + + result = service.read(["ds1"]) + assert isinstance(result, ModbusResponse) + assert result == {"DS1": 42} + + def test_read_invalid_address_raises_value_error(self, service: ModbusService): + service.connect("localhost", 15020) + with pytest.raises(ValueError): + service.read(["not-an-address"]) + + def test_read_transport_error_raises_oserror(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.read_error_bank = "DS" + with pytest.raises(OSError): + service.read(["DS1"]) + + +class TestWriteBehavior: + def test_write_accepts_mapping_and_iterable(self, service: ModbusService): + service.connect("localhost", 15020) + + result_map = service.write({"ds1": 1, "ds2": 2}) + assert [r["ok"] for r in result_map] == [True, True] + assert [r["address"] for r in result_map] == ["DS1", "DS2"] + + result_iter = service.write([("DS3", 3), ("DS4", 4)]) + assert [r["ok"] for r in result_iter] == [True, True] + assert [r["address"] for r in result_iter] == ["DS3", "DS4"] + + def test_write_non_writable_address_fails_per_item(self, service: ModbusService): + service.connect("localhost", 15020) + result = service.write([("X1", True)]) + assert result == [{"address": "X001", "ok": False, "error": "X1 is not writable."}] + + def test_write_invalid_value_fails_and_service_continues(self, service: ModbusService): + service.connect("localhost", 15020) + result = service.write([("DS1", "bad"), ("DS2", 5)]) + assert result[0]["ok"] is False + assert "DS1 value must be int" in cast(str, result[0]["error"]) + assert result[1] == {"address": "DS2", "ok": True, "error": None} + + def test_write_transport_failure_marks_batch_failed(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.write_error_bank = "DS" + + result = service.write([("DS1", 10), ("DS2", 20)]) + assert result[0]["ok"] is False + assert result[1]["ok"] is False + assert "write failed for DS" in cast(str, result[0]["error"]) + + +class TestBatchingHeuristics: + def test_contiguous_writes_are_batched_deterministically(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + + service.write([("DS1", 1), ("DS2", 2), ("DS3", 3), ("DF1", 1.0), ("DF2", 2.0)]) + + assert fake.write_calls == [ + ("DS", 1, [1, 2, 3]), + ("DF", 1, [1.0, 2.0]), + ] + + def test_sparse_bank_reads_do_not_bridge_invalid_gaps(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + + service.read(["X001", "X016", "X021", "X022"]) + + x_reads = [call for call in fake.read_calls if call[0] == "X"] + assert x_reads == [ + ("X", 1, None), + ("X", 16, None), + ("X", 21, 22), + ] + + def test_width_two_banks_honor_register_limits(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + addresses = [f"DF{i}" for i in range(1, 64)] + + service.read(addresses) + + df_reads = [call for call in fake.read_calls if call[0] == "DF"] + assert df_reads == [ + ("DF", 1, 62), + ("DF", 63, None), + ] + + +class TestThreadSafety: + def test_concurrent_set_poll_addresses_during_poll_is_safe(self, service: ModbusService): + service.connect("localhost", 15020) + service.set_poll_addresses(["DS1", "DS2"]) + errors: list[Exception] = [] + + def worker(values: Iterable[str]) -> None: + try: + for _ in range(20): + service.set_poll_addresses(values) + except Exception as exc: # pragma: no cover - safety capture + errors.append(exc) + + t1 = threading.Thread(target=worker, args=(["DS1"],)) + t2 = threading.Thread(target=worker, args=(["DS2"],)) + t1.start() + t2.start() + t1.join() + t2.join() + + assert errors == [] + + def test_disconnect_during_active_poll_exits_cleanly(self, service: ModbusService): + service.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.slow_read_s = 0.2 + service.set_poll_addresses(["DS1"]) + + time.sleep(0.05) + service.disconnect() + # second disconnect should be a no-op + service.disconnect() From b3cd4e907f6f5cf837da9e631c7cfbf3bed8bff5 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:47:32 -0500 Subject: [PATCH 34/55] fix(modbus_service): fully stop service loop on disconnect; prevent callback deadlocks ModbusService now performs a full shutdown on disconnect/close by stopping and joining its background asyncio thread, instead of only disconnecting the client. Also added a fail-fast guard for sync API calls made from on_state/on_values callbacks (service thread), so these now raise RuntimeError instead of deadlocking. Includes: - loop thread lifecycle management (start/ensure/stop) and reconnect-after-disconnect support - coroutine cleanup on early submit failures to avoid unawaited-coroutine warnings - close() alias for disconnect() - tests for thread shutdown, reconnect behavior, and callback re-entrancy protection - docs update in guides/modbus_service.md for shutdown semantics and callback usage rules --- docs/guides/modbus_service.md | 5 +- src/pyclickplc/modbus_service.py | 99 +++++++++++++++++++++++++++++--- tests/test_modbus_service.py | 58 +++++++++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) diff --git a/docs/guides/modbus_service.md b/docs/guides/modbus_service.md index a7e6b11..3f2dde6 100644 --- a/docs/guides/modbus_service.md +++ b/docs/guides/modbus_service.md @@ -17,7 +17,7 @@ svc.set_poll_addresses(["DS1", "DF1", "Y1"]) latest = svc.read(["DS1", "DF1"]) write_results = svc.write({"DS1": 10, "Y1": True}) -svc.disconnect() +svc.close() # same as disconnect() ``` ## API Notes @@ -27,6 +27,8 @@ svc.disconnect() - `stop_polling()` pauses polling until `set_poll_addresses(...)` is called again. - `read(...)` returns `ModbusResponse` keyed by canonical uppercase addresses. - `write(...)` accepts either a mapping or iterable of `(address, value)` pairs and returns per-address results. +- `disconnect()` (or `close()`) fully stops the background service loop/thread. +- The next sync call (`connect`, `read`, `write`, etc.) will start the loop again. ## Error Semantics @@ -34,3 +36,4 @@ svc.disconnect() - Invalid addresses passed to `read(...)` raise `ValueError`. - Transport/protocol errors raise `OSError` for reads and are reported per-address for writes. - `on_state` and `on_values` callbacks run on the service thread. +- Do not call synchronous service methods (`connect`, `disconnect`, `read`, `write`, poll config methods) from these callbacks. Marshal work to another thread/UI loop. diff --git a/src/pyclickplc/modbus_service.py b/src/pyclickplc/modbus_service.py index 2d97e60..1457e3f 100644 --- a/src/pyclickplc/modbus_service.py +++ b/src/pyclickplc/modbus_service.py @@ -196,15 +196,11 @@ def __init__( self._poll_task: asyncio.Task[None] | None = None self._poll_config = _PollConfig(addresses=(), plan=(), enabled=True) + self._thread_lock = threading.Lock() self._loop_ready = threading.Event() self._loop: asyncio.AbstractEventLoop | None = None - self._thread = threading.Thread( - target=self._run_loop, name="pyclickplc-modbus-service", daemon=True - ) - self._thread.start() - - if not self._loop_ready.wait(timeout=5): - raise RuntimeError("ModbusService event loop thread failed to start") + self._thread: threading.Thread | None = None + self._start_loop_thread() # Lifecycle -------------------------------------------------------------- @@ -219,7 +215,18 @@ def connect( self._submit_wait(self._connect_async(host, port, device_id=device_id, timeout=timeout)) def disconnect(self) -> None: - self._submit_wait(self._disconnect_async()) + thread = self._thread + if thread is None: + return + + try: + self._submit_wait(self._disconnect_async()) + finally: + if threading.current_thread() is not thread: + self._stop_loop_thread() + + def close(self) -> None: + self.disconnect() # Poll configuration ----------------------------------------------------- @@ -279,14 +286,88 @@ def _run_loop(self) -> None: if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) loop.close() + with self._thread_lock: + if self._thread is threading.current_thread(): + self._loop = None + + def _start_loop_thread(self) -> None: + with self._thread_lock: + if self._thread is not None and self._thread.is_alive(): + return + self._loop_ready = threading.Event() + self._loop = None + self._thread = threading.Thread( + target=self._run_loop, name="pyclickplc-modbus-service", daemon=True + ) + self._thread.start() + + if not self._loop_ready.wait(timeout=5): + raise RuntimeError("ModbusService event loop thread failed to start") + + def _ensure_loop_thread(self) -> None: + with self._thread_lock: + thread = self._thread + loop = self._loop + ready = self._loop_ready + + if thread is None or not thread.is_alive() or loop is None or loop.is_closed(): + self._start_loop_thread() + return + + if not ready.is_set() and not ready.wait(timeout=5): + raise RuntimeError("ModbusService event loop thread failed to start") + + def _stop_loop_thread(self) -> None: + with self._thread_lock: + thread = self._thread + loop = self._loop + + if thread is None: + return + if threading.current_thread() is thread: + raise RuntimeError( + "Synchronous ModbusService methods cannot be called from service callbacks" + ) + + if loop is not None and loop.is_running(): + loop.call_soon_threadsafe(loop.stop) + + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError("ModbusService event loop thread failed to stop") + + with self._thread_lock: + if self._thread is thread: + self._thread = None + if self._loop is loop: + self._loop = None def _submit_wait(self, coro: Coroutine[Any, Any, T]) -> T: + try: + self._ensure_loop_thread() + except Exception: + coro.close() + raise + loop = self._loop - if loop is None: + thread = self._thread + if loop is None or thread is None: + coro.close() raise RuntimeError("Service event loop is not available") + if threading.current_thread() is thread: + coro.close() + raise RuntimeError( + "Synchronous ModbusService methods cannot be called from service callbacks" + ) future = asyncio.run_coroutine_threadsafe(coro, loop) return future.result() + def __del__(self) -> None: + try: + self._stop_loop_thread() + except Exception: + return + # Async implementation --------------------------------------------------- async def _connect_async( diff --git a/tests/test_modbus_service.py b/tests/test_modbus_service.py index be75305..174ff9e 100644 --- a/tests/test_modbus_service.py +++ b/tests/test_modbus_service.py @@ -147,6 +147,14 @@ def _wait_for(predicate, *, timeout: float = 1.0) -> None: raise AssertionError("Condition not met before timeout") +def _service_threads() -> list[threading.Thread]: + return [ + t + for t in threading.enumerate() + if t.name == "pyclickplc-modbus-service" and t.is_alive() + ] + + class TestLifecycleState: def test_connect_disconnect_state_transitions(self, service: ModbusService): states: list[ConnectionState] = [] @@ -191,6 +199,30 @@ def test_connect_failure_emits_error(self, monkeypatch: pytest.MonkeyPatch): assert states[1] == ConnectionState.ERROR assert isinstance(errors[1], OSError) + def test_disconnect_stops_background_thread(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + before = len(_service_threads()) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + _wait_for(lambda: len(_service_threads()) == before + 1) + svc.disconnect() + _wait_for(lambda: len(_service_threads()) == before) + finally: + svc.disconnect() + + def test_reconnect_after_disconnect_restarts_loop(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + svc.disconnect() + svc.connect("localhost", 15020) + finally: + svc.disconnect() + + assert len(_FakeClickClient.instances) == 2 + class TestPollConfiguration: def test_set_poll_addresses_replaces_and_clear_stops(self, service: ModbusService): @@ -378,3 +410,29 @@ def test_disconnect_during_active_poll_exits_cleanly(self, service: ModbusServic service.disconnect() # second disconnect should be a no-op service.disconnect() + + def test_sync_calls_inside_callbacks_fail_fast(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + callback_errors: list[Exception] = [] + callback_called = threading.Event() + holder: dict[str, ModbusService] = {} + + def on_values(_values: ModbusResponse) -> None: + try: + holder["svc"].read(["DS1"]) + except Exception as exc: # pragma: no cover - callback behavior + callback_errors.append(exc) + finally: + callback_called.set() + + svc = ModbusService(poll_interval_s=0.03, on_values=on_values) + holder["svc"] = svc + try: + svc.connect("localhost", 15020) + svc.set_poll_addresses(["DS1"]) + _wait_for(callback_called.is_set) + finally: + svc.disconnect() + + assert len(callback_errors) == 1 + assert isinstance(callback_errors[0], RuntimeError) From 16fb6d1a79f3d5ebe27896c1028da4b7537d7528 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:16:04 -0500 Subject: [PATCH 35/55] feat(server): add ClickServer TUI and deterministic client disconnect behavior - Add ClickServer runtime/client APIs: - is_running() - list_clients() - disconnect_client() - disconnect_all_clients() - Add ServerClientInfo dataclass for client id + peer display. - Add run_server_tui(...) helper with commands: help, status, clients, disconnect , disconnect all, shutdown/exit/quit. - Export new APIs from package root and document TUI usage in README + server guide. - Change ClickClient defaults to disable implicit auto-reconnect (reconnect_delay=0.0, reconnect_delay_max=0.0), with explicit opt-in params. - Add regression/integration tests covering: - server runtime controls - TUI command loop behavior - disconnect_all keeping client disconnected (no silent reconnect) --- README.md | 24 +++++ docs/guides/server.md | 25 ++++++ src/pyclickplc/__init__.py | 5 +- src/pyclickplc/client.py | 10 ++- src/pyclickplc/server.py | 56 +++++++++++- src/pyclickplc/server_tui.py | 107 +++++++++++++++++++++++ tests/test_client.py | 8 ++ tests/test_integration.py | 29 +++++++ tests/test_server.py | 96 ++++++++++++++++++++ tests/test_server_tui.py | 164 +++++++++++++++++++++++++++++++++++ 10 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 src/pyclickplc/server_tui.py create mode 100644 tests/test_server_tui.py diff --git a/README.md b/README.md index 503bc5e..e0cceae 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,30 @@ asyncio.run(main()) - `set(address, value)` - `bulk_set({address: value, ...})` +Interactive server TUI helper: + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider, run_server_tui + +async def main(): + provider = MemoryDataProvider() + provider.set("DS1", 42) + + server = ClickServer(provider, host="127.0.0.1", port=5020) + await run_server_tui(server) + +asyncio.run(main()) +``` + +TUI commands: +- `help` +- `status` +- `clients` +- `disconnect ` +- `disconnect all` +- `shutdown` (`exit` / `quit`) + ## Nickname CSV Files Read and write CLICK software nickname CSV files. diff --git a/docs/guides/server.md b/docs/guides/server.md index 7c8a06f..7c479e0 100644 --- a/docs/guides/server.md +++ b/docs/guides/server.md @@ -27,3 +27,28 @@ asyncio.run(main()) - `set(address, value)` - `bulk_set({...})` +## Interactive TUI + +Use `run_server_tui` for a basic interactive terminal interface: + +```python +import asyncio +from pyclickplc import ClickServer, MemoryDataProvider, run_server_tui + +async def main(): + provider = MemoryDataProvider() + server = ClickServer(provider, host="127.0.0.1", port=5020) + await run_server_tui(server) + +asyncio.run(main()) +``` + +Supported commands: + +- `help` +- `status` +- `clients` +- `disconnect ` +- `disconnect all` +- `shutdown` (`exit` / `quit`) + diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 9986d54..2de6390 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -46,7 +46,8 @@ ) from .modbus_service import ConnectionState, ModbusService, WriteResult from .nicknames import read_csv, write_csv -from .server import ClickServer, MemoryDataProvider +from .server import ClickServer, MemoryDataProvider, ServerClientInfo +from .server_tui import run_server_tui from .validation import validate_comment, validate_initial_value, validate_nickname __all__ = [ @@ -82,6 +83,8 @@ "WriteResult", "ClickServer", "MemoryDataProvider", + "ServerClientInfo", + "run_server_tui", "DataviewFile", "DataviewRow", "check_cdv_file", diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 8da4c30..b27b28e 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -574,6 +574,8 @@ def __init__( tag_filepath: str = "", timeout: int = 1, device_id: int = 1, + reconnect_delay: float = 0.0, + reconnect_delay_max: float = 0.0, ) -> None: # Backwards compatibility for legacy "host:port" first argument. if ":" in host and port == 502: @@ -585,7 +587,13 @@ def __init__( if not (0 <= device_id <= 247): raise ValueError("device_id must be in [0, 247]") - self._client = AsyncModbusTcpClient(host, port=port, timeout=timeout) + self._client = AsyncModbusTcpClient( + host, + port=port, + timeout=timeout, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + ) self._device_id = device_id self._accessors: dict[str, AddressAccessor[PlcValue]] = {} self.tags: dict[str, dict[str, str]] = {} diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py index a53a1ae..d179107 100644 --- a/src/pyclickplc/server.py +++ b/src/pyclickplc/server.py @@ -6,7 +6,8 @@ from __future__ import annotations -from typing import Protocol, runtime_checkable +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable from pymodbus.constants import ExcCodes from pymodbus.datastore import ModbusBaseDeviceContext, ModbusServerContext @@ -93,6 +94,14 @@ def bulk_set(self, values: dict[str, PlcValue]) -> None: self.set(address, value) +@dataclass(frozen=True) +class ServerClientInfo: + """Connected client metadata exposed by ClickServer.""" + + client_id: str + peer: str + + # ============================================================================== # _ClickDeviceContext # ============================================================================== @@ -357,6 +366,51 @@ def __init__( self.port = port self._server: ModbusTcpServer | None = None + @staticmethod + def _format_peer(connection: Any) -> str: + """Best-effort peer formatting from a pymodbus active connection.""" + transport = getattr(connection, "transport", None) + if transport is None: + return "unknown" + peer = transport.get_extra_info("peername") + if isinstance(peer, tuple) and len(peer) >= 2: + return f"{peer[0]}:{peer[1]}" + if peer: + return str(peer) + return "unknown" + + def is_running(self) -> bool: + """Return True when the underlying listener transport is active.""" + return bool(self._server is not None and self._server.is_active()) + + def list_clients(self) -> list[ServerClientInfo]: + """Return the currently connected Modbus TCP clients.""" + if self._server is None: + return [] + clients: list[ServerClientInfo] = [] + for client_id, connection in self._server.active_connections.items(): + clients.append(ServerClientInfo(client_id=client_id, peer=self._format_peer(connection))) + return clients + + def disconnect_client(self, client_id: str) -> bool: + """Disconnect a single client by pymodbus connection id.""" + if self._server is None: + return False + connection = self._server.active_connections.get(client_id) + if connection is None: + return False + connection.close() + return True + + def disconnect_all_clients(self) -> int: + """Disconnect all connected clients and return how many were closed.""" + if self._server is None: + return 0 + active = list(self._server.active_connections.values()) + for connection in active: + connection.close() + return len(active) + async def start(self) -> None: """Start the Modbus TCP server.""" context = _ClickDeviceContext(self.provider) diff --git a/src/pyclickplc/server_tui.py b/src/pyclickplc/server_tui.py new file mode 100644 index 0000000..3a0c2aa --- /dev/null +++ b/src/pyclickplc/server_tui.py @@ -0,0 +1,107 @@ +"""Minimal interactive terminal UI for ClickServer.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +from .server import ClickServer + + +def _print_help(output_fn: Callable[[str], None]) -> None: + output_fn("Commands:") + output_fn(" help Show this command list") + output_fn(" status Show server status") + output_fn(" clients List connected clients") + output_fn(" disconnect Disconnect one client") + output_fn(" disconnect all Disconnect all clients") + output_fn(" shutdown Stop server and exit (aliases: exit, quit)") + + +async def _read_line(prompt: str, input_fn: Callable[[str], str] | None) -> str: + if input_fn is None: + return await asyncio.to_thread(input, prompt) + return input_fn(prompt) + + +async def run_server_tui( + server: ClickServer, + *, + prompt: str = "clickserver> ", + input_fn: Callable[[str], str] | None = None, + output_fn: Callable[[str], None] | None = None, +) -> None: + """Run a basic interactive command loop for a ClickServer.""" + output = output_fn or print + + if not server.is_running(): + await server.start() + + output(f"Serving connection at {server.host}:{server.port}") + output("Type 'help' for commands.") + + cancelled = False + try: + while True: + try: + raw = await _read_line(prompt, input_fn) + except EOFError: + output("EOF received, shutting down server.") + break + except KeyboardInterrupt: + output("Interrupted, shutting down server.") + break + + line = raw.strip() + if not line: + continue + + parts = line.split() + cmd = parts[0].lower() + + if cmd in {"shutdown", "exit", "quit"}: + output("Shutting down server.") + break + + if cmd == "help": + _print_help(output) + continue + + if cmd == "status": + clients = server.list_clients() + output(f"Serving connection at {server.host}:{server.port}") + output(f"Running: {server.is_running()} | Clients: {len(clients)}") + continue + + if cmd == "clients": + clients = server.list_clients() + if not clients: + output("No clients connected.") + else: + for client in clients: + output(f"{client.client_id} {client.peer}") + continue + + if cmd == "disconnect": + if len(parts) != 2: + output("Usage: disconnect ") + continue + target = parts[1] + if target.lower() == "all": + count = server.disconnect_all_clients() + output(f"Disconnected {count} client(s).") + elif server.disconnect_client(target): + output(f"Disconnected client '{target}'.") + else: + output(f"Client '{target}' not found.") + continue + + output(f"Unknown command: {cmd}. Type 'help' for commands.") + except asyncio.CancelledError: + cancelled = True + output("TUI cancelled, shutting down server.") + finally: + await server.stop() + + if cancelled: + raise asyncio.CancelledError diff --git a/tests/test_client.py b/tests/test_client.py index e827447..b67c5d1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -84,6 +84,8 @@ async def test_construction(self): plc = ClickClient("192.168.1.100") assert plc._client.comm_params.host == "192.168.1.100" assert plc._client.comm_params.port == 502 + assert plc._client.comm_params.reconnect_delay == 0.0 + assert plc._client.comm_params.reconnect_delay_max == 0.0 assert plc.addr is not None assert plc.tag is not None assert plc.tags == {} @@ -105,6 +107,12 @@ async def test_construction_with_device_id(self): plc = ClickClient("192.168.1.100", 5020, device_id=7) assert plc._device_id == 7 + @pytest.mark.asyncio + async def test_construction_with_reconnect_settings(self): + plc = ClickClient("192.168.1.100", reconnect_delay=0.5, reconnect_delay_max=2.0) + assert plc._client.comm_params.reconnect_delay == 0.5 + assert plc._client.comm_params.reconnect_delay_max == 2.0 + @pytest.mark.asyncio async def test_getattr_df(self): plc = _make_plc() diff --git a/tests/test_integration.py b/tests/test_integration.py index 757a4f8..64f3abe 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,6 +10,7 @@ import math import pytest +from pymodbus.exceptions import ConnectionException from pyclickplc.client import ClickClient from pyclickplc.server import ClickServer, MemoryDataProvider @@ -227,3 +228,31 @@ async def test_x_expansion_slot(self, plc_fixture): provider.set("X101", True) result = await plc.x.read(101) assert result == {"X101": True} + + +# ============================================================================== +# Connection lifecycle +# ============================================================================== + + +class TestConnectionLifecycle: + @pytest.mark.asyncio + async def test_disconnect_all_drops_clickclient_session(self): + provider = MemoryDataProvider() + server = ClickServer(provider, host="127.0.0.1", port=TEST_PORT + 1) + await server.start() + try: + async with ClickClient("127.0.0.1", TEST_PORT + 1) as plc: + await asyncio.sleep(0.05) + assert len(server.list_clients()) == 1 + + closed = server.disconnect_all_clients() + assert closed == 1 + + await asyncio.sleep(0.2) + assert server.list_clients() == [] + + with pytest.raises(ConnectionException, match="Not connected"): + await plc.ds.read(1) + finally: + await server.stop() diff --git a/tests/test_server.py b/tests/test_server.py index 1b864ae..eba40df 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import MagicMock + import pytest from pyclickplc.banks import DataType @@ -511,3 +513,97 @@ def test_custom_host_port(self): s = ClickServer(p, host="0.0.0.0", port=5020) assert s.host == "0.0.0.0" assert s.port == 5020 + + +# ============================================================================== +# ClickServer runtime controls +# ============================================================================== + + +class _FakeServer: + def __init__(self, *, active: bool = True) -> None: + self._active = active + self.active_connections: dict[str, object] = {} + + def is_active(self) -> bool: + return self._active + + +class _FakeTransport: + def __init__(self, peername: object = None) -> None: + self._peername = peername + + def get_extra_info(self, key: str) -> object: + if key == "peername": + return self._peername + return None + + +class _FakeConnection: + def __init__(self, peername: object = None) -> None: + self.transport = _FakeTransport(peername) + self.close = MagicMock() + + +class TestClickServerRuntimeControls: + def test_is_running_false_before_start(self): + server = ClickServer(MemoryDataProvider()) + assert server.is_running() is False + + def test_is_running_true_when_server_active(self): + server = ClickServer(MemoryDataProvider()) + server._server = _FakeServer(active=True) # type: ignore[assignment] + assert server.is_running() is True + + def test_is_running_false_when_server_inactive(self): + server = ClickServer(MemoryDataProvider()) + server._server = _FakeServer(active=False) # type: ignore[assignment] + assert server.is_running() is False + + def test_list_clients_empty_when_not_started(self): + server = ClickServer(MemoryDataProvider()) + assert server.list_clients() == [] + + def test_list_clients_includes_peer(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + fake.active_connections["abc"] = _FakeConnection(("127.0.0.1", 5020)) + fake.active_connections["def"] = _FakeConnection(None) + server._server = fake # type: ignore[assignment] + + clients = server.list_clients() + assert len(clients) == 2 + assert clients[0].client_id == "abc" + assert clients[0].peer == "127.0.0.1:5020" + assert clients[1].client_id == "def" + assert clients[1].peer == "unknown" + + def test_disconnect_client_unknown_returns_false(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + server._server = fake # type: ignore[assignment] + assert server.disconnect_client("missing") is False + + def test_disconnect_client_known_returns_true(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + connection = _FakeConnection(("127.0.0.1", 1234)) + fake.active_connections["known"] = connection + server._server = fake # type: ignore[assignment] + + assert server.disconnect_client("known") is True + connection.close.assert_called_once_with() + + def test_disconnect_all_clients(self): + server = ClickServer(MemoryDataProvider()) + fake = _FakeServer(active=True) + c1 = _FakeConnection(("127.0.0.1", 1111)) + c2 = _FakeConnection(("127.0.0.1", 2222)) + fake.active_connections["a"] = c1 + fake.active_connections["b"] = c2 + server._server = fake # type: ignore[assignment] + + count = server.disconnect_all_clients() + assert count == 2 + c1.close.assert_called_once_with() + c2.close.assert_called_once_with() diff --git a/tests/test_server_tui.py b/tests/test_server_tui.py new file mode 100644 index 0000000..3ad15e7 --- /dev/null +++ b/tests/test_server_tui.py @@ -0,0 +1,164 @@ +"""Tests for pyclickplc.server_tui.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pyclickplc.server import ServerClientInfo +from pyclickplc.server_tui import run_server_tui + + +@dataclass +class _FakeServer: + host: str = "127.0.0.1" + port: int = 5020 + running: bool = False + clients: list[ServerClientInfo] | None = None + disconnect_all_count: int = 0 + disconnect_ok: bool = False + + def __post_init__(self) -> None: + if self.clients is None: + self.clients = [] + self.start_calls = 0 + self.stop_calls = 0 + self.disconnect_all_calls = 0 + self.disconnect_client_calls: list[str] = [] + + def is_running(self) -> bool: + return self.running + + async def start(self) -> None: + self.start_calls += 1 + self.running = True + + async def stop(self) -> None: + self.stop_calls += 1 + self.running = False + + def list_clients(self) -> list[ServerClientInfo]: + return list(self.clients or []) + + def disconnect_all_clients(self) -> int: + self.disconnect_all_calls += 1 + return self.disconnect_all_count + + def disconnect_client(self, client_id: str) -> bool: + self.disconnect_client_calls.append(client_id) + return self.disconnect_ok + + +def _input_from(values: list[str]): + queue = list(values) + + def _input(_: str) -> str: + if not queue: + raise EOFError + return queue.pop(0) + + return _input + + +async def test_run_server_tui_status_help_shutdown(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui( + server, input_fn=_input_from(["status", "help", "shutdown"]), output_fn=output.append + ) + + assert server.start_calls == 1 + assert server.stop_calls == 1 + assert any("Serving connection at 127.0.0.1:5020" in line for line in output) + assert any("Running: True | Clients: 0" in line for line in output) + assert any("Commands:" in line for line in output) + + +async def test_run_server_tui_clients_empty(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui(server, input_fn=_input_from(["clients", "shutdown"]), output_fn=output.append) + + assert "No clients connected." in output + + +async def test_run_server_tui_disconnect_all(): + server = _FakeServer(disconnect_all_count=3) + output: list[str] = [] + + await run_server_tui( + server, input_fn=_input_from(["disconnect all", "shutdown"]), output_fn=output.append + ) + + assert server.disconnect_all_calls == 1 + assert "Disconnected 3 client(s)." in output + + +async def test_run_server_tui_disconnect_single_success(): + server = _FakeServer(disconnect_ok=True) + output: list[str] = [] + + await run_server_tui( + server, input_fn=_input_from(["disconnect abc", "shutdown"]), output_fn=output.append + ) + + assert server.disconnect_client_calls == ["abc"] + assert "Disconnected client 'abc'." in output + + +async def test_run_server_tui_disconnect_single_not_found(): + server = _FakeServer(disconnect_ok=False) + output: list[str] = [] + + await run_server_tui( + server, input_fn=_input_from(["disconnect missing", "shutdown"]), output_fn=output.append + ) + + assert server.disconnect_client_calls == ["missing"] + assert "Client 'missing' not found." in output + + +async def test_run_server_tui_unknown_command(): + server = _FakeServer() + output: list[str] = [] + + await run_server_tui(server, input_fn=_input_from(["badcmd", "shutdown"]), output_fn=output.append) + + assert any("Unknown command: badcmd." in line for line in output) + + +async def test_run_server_tui_eof_triggers_stop(): + server = _FakeServer() + output: list[str] = [] + + def _eof(_: str) -> str: + raise EOFError + + await run_server_tui(server, input_fn=_eof, output_fn=output.append) + + assert server.stop_calls == 1 + assert "EOF received, shutting down server." in output + + +async def test_run_server_tui_keyboard_interrupt_triggers_stop(): + server = _FakeServer() + output: list[str] = [] + + def _interrupt(_: str) -> str: + raise KeyboardInterrupt + + await run_server_tui(server, input_fn=_interrupt, output_fn=output.append) + + assert server.stop_calls == 1 + assert "Interrupted, shutting down server." in output + + +async def test_run_server_tui_does_not_restart_running_server(): + server = _FakeServer(running=True) + output: list[str] = [] + + await run_server_tui(server, input_fn=_input_from(["shutdown"]), output_fn=output.append) + + assert server.start_calls == 0 + assert server.stop_calls == 1 From f7e0936b77d7f8200293cfb43685c80dfcd85109 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:59:50 -0500 Subject: [PATCH 36/55] feat(modbus-service): add reconnect config and poll-failure state transitions - add ReconnectConfig and ModbusService(reconnect=...) for friendly auto-reconnect setup - pass reconnect_delay/reconnect_delay_max through to ClickClient (default remains disabled) - emit on_state(ERROR, err) when poll reads start failing, not every failed cycle - emit CONNECTED after poll read recovery so later failures trigger ERROR again - add/extend tests and docs for reconnect wiring and poll failure behavior --- README.md | 8 ++- docs/guides/modbus_service.md | 9 +++- src/pyclickplc/__init__.py | 3 +- src/pyclickplc/modbus_service.py | 49 +++++++++++++++-- tests/test_modbus_service.py | 91 +++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e0cceae..82aedf9 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,16 @@ All `read()` methods return `ModbusResponse`, a mapping keyed by canonical upper `ModbusService` is a synchronous wrapper intended for UI/event-driven callers. It owns a background asyncio loop and provides polling plus bulk writes. ```python -from pyclickplc import ModbusService +from pyclickplc import ModbusService, ReconnectConfig def on_values(values): print(values) # ModbusResponse keyed by canonical addresses -svc = ModbusService(poll_interval_s=0.5, on_values=on_values) +svc = ModbusService( + poll_interval_s=0.5, + reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), # optional + on_values=on_values, +) svc.connect("192.168.1.10", 502, device_id=1, timeout=1) svc.set_poll_addresses(["ds1", "df1", "y1"]) diff --git a/docs/guides/modbus_service.md b/docs/guides/modbus_service.md index 3f2dde6..53863fa 100644 --- a/docs/guides/modbus_service.md +++ b/docs/guides/modbus_service.md @@ -3,14 +3,18 @@ `ModbusService` provides a synchronous API on top of `ClickClient` for UI and service callers that do not want to manage `asyncio` directly. ```python -from pyclickplc import ModbusService +from pyclickplc import ModbusService, ReconnectConfig def on_values(values): # Runs on the service thread. # GUI apps should marshal this callback to the UI thread. print(values) -svc = ModbusService(poll_interval_s=0.5, on_values=on_values) +svc = ModbusService( + poll_interval_s=0.5, + reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), # optional + on_values=on_values, +) svc.connect("192.168.1.10", 502, device_id=1, timeout=1) svc.set_poll_addresses(["DS1", "DF1", "Y1"]) @@ -29,6 +33,7 @@ svc.close() # same as disconnect() - `write(...)` accepts either a mapping or iterable of `(address, value)` pairs and returns per-address results. - `disconnect()` (or `close()`) fully stops the background service loop/thread. - The next sync call (`connect`, `read`, `write`, etc.) will start the loop again. +- `reconnect=ReconnectConfig(...)` controls ClickClient auto-reconnect backoff. ## Error Semantics diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 2de6390..450be64 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -44,7 +44,7 @@ plc_to_modbus, unpack_value, ) -from .modbus_service import ConnectionState, ModbusService, WriteResult +from .modbus_service import ConnectionState, ModbusService, ReconnectConfig, WriteResult from .nicknames import read_csv, write_csv from .server import ClickServer, MemoryDataProvider, ServerClientInfo from .server_tui import run_server_tui @@ -79,6 +79,7 @@ "pack_value", "unpack_value", "ModbusService", + "ReconnectConfig", "ConnectionState", "WriteResult", "ClickServer", diff --git a/src/pyclickplc/modbus_service.py b/src/pyclickplc/modbus_service.py index 1457e3f..7b692af 100644 --- a/src/pyclickplc/modbus_service.py +++ b/src/pyclickplc/modbus_service.py @@ -76,6 +76,22 @@ class _WriteBatch: items: tuple[_WriteItem, ...] +@dataclass(frozen=True) +class ReconnectConfig: + """ClickClient reconnect behavior used by ModbusService.""" + + delay_s: float = 0.5 + max_delay_s: float = 5.0 + + def __post_init__(self) -> None: + if self.delay_s < 0: + raise ValueError("reconnect delay_s must be >= 0") + if self.max_delay_s < 0: + raise ValueError("reconnect max_delay_s must be >= 0") + if self.max_delay_s < self.delay_s: + raise ValueError("reconnect max_delay_s must be >= delay_s") + + def _default_for_bank(bank: str) -> PlcValue: data_type = BANKS[bank].data_type if data_type == DataType.BIT: @@ -181,6 +197,7 @@ class ModbusService: def __init__( self, poll_interval_s: float = 1.5, + reconnect: ReconnectConfig | None = None, on_state: Callable[[ConnectionState, Exception | None], None] | None = None, on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, ) -> None: @@ -188,12 +205,14 @@ def __init__( raise ValueError("poll_interval_s must be > 0") self._poll_interval_s = poll_interval_s + self._reconnect = reconnect self._on_state = on_state self._on_values = on_values self._state = ConnectionState.DISCONNECTED self._client: ClickClient | None = None self._poll_task: asyncio.Task[None] | None = None + self._poll_reads_failing = False self._poll_config = _PollConfig(addresses=(), plan=(), enabled=True) self._thread_lock = threading.Lock() @@ -381,14 +400,25 @@ async def _connect_async( if self._client is not None: await self._disconnect_async() + self._poll_reads_failing = False self._emit_state(ConnectionState.CONNECTING, None) candidate: ClickClient | None = None try: - candidate = ClickClient(host, port, timeout=timeout, device_id=device_id) + reconnect_delay = self._reconnect.delay_s if self._reconnect is not None else 0.0 + reconnect_delay_max = self._reconnect.max_delay_s if self._reconnect is not None else 0.0 + candidate = ClickClient( + host, + port, + timeout=timeout, + device_id=device_id, + reconnect_delay=reconnect_delay, + reconnect_delay_max=reconnect_delay_max, + ) await candidate.__aenter__() if not candidate._client.connected: # pyright: ignore[reportPrivateUsage] raise OSError(f"Failed to connect to {host}:{port}") self._client = candidate + self._poll_reads_failing = False self._ensure_poll_task() self._emit_state(ConnectionState.CONNECTED, None) except Exception as exc: @@ -411,6 +441,7 @@ async def _disconnect_async(self) -> None: client = self._client self._client = None + self._poll_reads_failing = False if client is not None: await client.__aexit__(None, None, None) @@ -439,14 +470,26 @@ async def _poll_loop(self) -> None: poll_config = self._poll_config if not poll_config.enabled or not poll_config.addresses: + if self._poll_reads_failing: + self._poll_reads_failing = False + self._emit_state(ConnectionState.CONNECTED, None) continue try: data = await self._read_plan_async(list(poll_config.plan)) - except OSError as exc: - self._emit_state(ConnectionState.ERROR, exc) + except asyncio.CancelledError: + raise + except Exception as exc: + error = exc if isinstance(exc, OSError) else OSError(str(exc)) + if not self._poll_reads_failing: + self._poll_reads_failing = True + self._emit_state(ConnectionState.ERROR, error) continue + if self._poll_reads_failing: + self._poll_reads_failing = False + self._emit_state(ConnectionState.CONNECTED, None) + ordered: dict[str, PlcValue] = {} for address in poll_config.addresses: ordered[address] = data[address] diff --git a/tests/test_modbus_service.py b/tests/test_modbus_service.py index 174ff9e..9aa02b6 100644 --- a/tests/test_modbus_service.py +++ b/tests/test_modbus_service.py @@ -14,7 +14,7 @@ from pyclickplc.addresses import format_address_display from pyclickplc.banks import BANKS, DataType from pyclickplc.client import ModbusResponse -from pyclickplc.modbus_service import ConnectionState, ModbusService +from pyclickplc.modbus_service import ConnectionState, ModbusService, ReconnectConfig def _default_for_data_type(data_type: DataType) -> bool | int | float | str: @@ -82,12 +82,16 @@ def __init__( tag_filepath: str = "", timeout: int = 1, device_id: int = 1, + reconnect_delay: float = 0.0, + reconnect_delay_max: float = 0.0, ) -> None: del tag_filepath del timeout del device_id self.host = host self.port = port + self.reconnect_delay = reconnect_delay + self.reconnect_delay_max = reconnect_delay_max self._client = _FakeConn() self.data: dict[str, bool | int | float | str] = {} self.read_calls: list[tuple[str, int, int | None]] = [] @@ -156,6 +160,14 @@ def _service_threads() -> list[threading.Thread]: class TestLifecycleState: + def test_reconnect_config_validation(self): + with pytest.raises(ValueError): + ReconnectConfig(delay_s=-0.1) + with pytest.raises(ValueError): + ReconnectConfig(max_delay_s=-0.1) + with pytest.raises(ValueError): + ReconnectConfig(delay_s=2.0, max_delay_s=1.0) + def test_connect_disconnect_state_transitions(self, service: ModbusService): states: list[ConnectionState] = [] errors: list[Exception | None] = [] @@ -199,6 +211,83 @@ def test_connect_failure_emits_error(self, monkeypatch: pytest.MonkeyPatch): assert states[1] == ConnectionState.ERROR assert isinstance(errors[1], OSError) + def test_connect_reconnect_config_passes_through(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService( + poll_interval_s=0.03, + reconnect=ReconnectConfig(delay_s=0.25, max_delay_s=2.0), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + assert fake.reconnect_delay == 0.25 + assert fake.reconnect_delay_max == 2.0 + finally: + svc.disconnect() + + def test_connect_without_reconnect_config_disables_reconnect( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + svc = ModbusService(poll_interval_s=0.03) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + assert fake.reconnect_delay == 0.0 + assert fake.reconnect_delay_max == 0.0 + finally: + svc.disconnect() + + def test_poll_failure_emits_error_once_when_failure_starts( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + states: list[ConnectionState] = [] + errors: list[Exception | None] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: (states.append(s), errors.append(e)), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + fake.read_error_bank = "DS" + svc.set_poll_addresses(["DS1"]) + + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 1) + time.sleep(0.1) + + error_indexes = [i for i, state in enumerate(states) if state == ConnectionState.ERROR] + assert len(error_indexes) == 1 + assert isinstance(errors[error_indexes[0]], OSError) + finally: + svc.disconnect() + + def test_poll_failure_recovery_allows_future_failure_transition( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) + states: list[ConnectionState] = [] + svc = ModbusService( + poll_interval_s=0.03, + on_state=lambda s, e: states.append(s), + ) + try: + svc.connect("localhost", 15020) + fake = _FakeClickClient.instances[-1] + svc.set_poll_addresses(["DS1"]) + + fake.read_error_bank = "DS" + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 1) + + fake.read_error_bank = None + _wait_for(lambda: states.count(ConnectionState.CONNECTED) >= 2) + + fake.read_error_bank = "DS" + _wait_for(lambda: states.count(ConnectionState.ERROR) >= 2) + finally: + svc.disconnect() + def test_disconnect_stops_background_thread(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("pyclickplc.modbus_service.ClickClient", _FakeClickClient) before = len(_service_threads()) From e13155d2332c8dc1a830a8176f14bcbee13384c7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:24:31 -0500 Subject: [PATCH 37/55] refactor!: remove Capabilities (pyrung only used code) --- README.md | 16 -- src/pyclickplc/__init__.py | 15 -- src/pyclickplc/capabilities.py | 282 --------------------------------- tests/test_capabilities.py | 157 ------------------ 4 files changed, 470 deletions(-) delete mode 100644 src/pyclickplc/capabilities.py delete mode 100644 tests/test_capabilities.py diff --git a/README.md b/README.md index 82aedf9..5f907d0 100644 --- a/README.md +++ b/README.md @@ -182,22 +182,6 @@ normalized = normalize_address("x1") # "X001" Full API reference is available via MkDocs (including advanced modules). -## Hardware Capability Profile - -`ClickHardwareProfile` provides table-driven ladder portability rules: -- bank/address writability (`is_writable`) -- fixed instruction-role compatibility (`valid_for_role`) -- copy-family bank compatibility (`copy_compatible`) -- compare compatibility (`compare_compatible`, `compare_constant_compatible`) - -```python -from pyclickplc import CLICK_HARDWARE_PROFILE - -CLICK_HARDWARE_PROFILE.is_writable("SC", 50) # True -CLICK_HARDWARE_PROFILE.valid_for_role("T", "timer_done_bit") # True -CLICK_HARDWARE_PROFILE.copy_compatible("single", "X", "Y") # True -``` - ## Development ```bash diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 450be64..7365e81 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -11,21 +11,6 @@ BankConfig, DataType, ) -from .capabilities import ( - CLICK_HARDWARE_PROFILE, - COMPARE_COMPATIBILITY, - COMPARE_CONSTANT_COMPATIBILITY, - COPY_COMPATIBILITY, - INSTRUCTION_ROLE_COMPATIBILITY, - LADDER_BANK_CAPABILITIES, - LADDER_WRITABLE_SC, - LADDER_WRITABLE_SD, - BankCapability, - ClickHardwareProfile, - CompareConstantKind, - CopyOperation, - InstructionRole, -) from .client import ClickClient, ModbusResponse from .dataview import ( DataviewFile, diff --git a/src/pyclickplc/capabilities.py b/src/pyclickplc/capabilities.py deleted file mode 100644 index 7b84bc8..0000000 --- a/src/pyclickplc/capabilities.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Click hardware capability profile for ladder-portability validation. - -This module is table-driven and encodes the static compatibility rules used by -pyrung Click validation. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -from .banks import BANKS - -InstructionRole = Literal[ - "timer_done_bit", - "timer_accumulator", - "timer_setpoint", - "counter_done_bit", - "counter_accumulator", - "counter_setpoint", - "copy_pointer", -] - -CopyOperation = Literal[ - "single", - "block", - "fill", - "pack_bits", - "pack_words", - "unpack_bits", - "unpack_words", -] - -CompareConstantKind = Literal["int1", "int2", "float", "hex", "text"] - - -@dataclass(frozen=True) -class BankCapability: - """Per-bank ladder validation write capability.""" - - writable: bool - writable_subset: frozenset[int] | None = None - - -# SC/SD writable subsets for ladder validation (different from Modbus writability). -LADDER_WRITABLE_SC: frozenset[int] = frozenset( - {50, 51, 53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121} -) - -LADDER_WRITABLE_SD: frozenset[int] = frozenset( - { - 29, - 31, - 32, - 34, - 35, - 36, - 40, - 41, - 42, - 50, - 51, - 60, - 61, - 106, - 107, - 108, - 112, - 113, - 114, - 140, - 141, - 142, - 143, - 144, - 145, - 146, - 147, - 214, - 215, - } -) - -LADDER_BANK_CAPABILITIES: dict[str, BankCapability] = { - "X": BankCapability(writable=False), - "Y": BankCapability(writable=True), - "C": BankCapability(writable=True), - "T": BankCapability(writable=False), - "CT": BankCapability(writable=False), - "SC": BankCapability(writable=False, writable_subset=LADDER_WRITABLE_SC), - "DS": BankCapability(writable=True), - "DD": BankCapability(writable=True), - "DH": BankCapability(writable=True), - "DF": BankCapability(writable=True), - "XD": BankCapability(writable=False), - "YD": BankCapability(writable=False), - "TD": BankCapability(writable=True), - "CTD": BankCapability(writable=True), - "SD": BankCapability(writable=False, writable_subset=LADDER_WRITABLE_SD), - "TXT": BankCapability(writable=True), -} - -INSTRUCTION_ROLE_COMPATIBILITY: dict[InstructionRole, frozenset[str]] = { - "timer_done_bit": frozenset({"T"}), - "timer_accumulator": frozenset({"TD"}), - "timer_setpoint": frozenset({"DS"}), - "counter_done_bit": frozenset({"CT"}), - "counter_accumulator": frozenset({"CTD"}), - "counter_setpoint": frozenset({"DS", "DD"}), - "copy_pointer": frozenset({"DS"}), -} - -_BIT_SOURCES: frozenset[str] = frozenset({"X", "Y", "C", "T", "CT", "SC"}) -_BIT_DESTS: frozenset[str] = frozenset({"Y", "C"}) - -_REGISTER_SOURCES: frozenset[str] = frozenset( - {"DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD", "TXT"} -) -_SINGLE_REGISTER_DESTS: frozenset[str] = frozenset( - {"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD", "TXT"} -) - -_BLOCK_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD"}) -_BLOCK_TXT_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF"}) - -_FILL_REGISTER_DESTS: frozenset[str] = frozenset({"DS", "DD", "DH", "DF", "YD", "TD", "CTD", "SD"}) - -_PACK_BITS_16_SOURCES: frozenset[str] = frozenset({"X", "Y", "T", "CT", "SC"}) -_PACK_BITS_16_DESTS: frozenset[str] = frozenset({"DS", "DH"}) -_PACK_BITS_32_SOURCES: frozenset[str] = frozenset({"C"}) -_PACK_BITS_32_DESTS: frozenset[str] = frozenset({"DD", "DF"}) - -PACK_WORDS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( - (source, dest) for source in ("DS", "DH") for dest in ("DD", "DF") -) - -UNPACK_BITS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( - (source, dest) for source in ("DS", "DH", "DD", "DF") for dest in ("Y", "C") -) - -UNPACK_WORDS_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( - (source, dest) for source in ("DD", "DF") for dest in ("DS", "DH") -) - -COPY_COMPATIBILITY: dict[CopyOperation, frozenset[tuple[str, str]]] = { - "single": frozenset( - [(source, dest) for source in _BIT_SOURCES for dest in _BIT_DESTS] - + [(source, dest) for source in _REGISTER_SOURCES for dest in _SINGLE_REGISTER_DESTS] - ), - "block": frozenset( - [(source, dest) for source in _BIT_SOURCES for dest in _BIT_DESTS] - + [ - (source, dest) - for source in ("DS", "DD", "DH", "DF", "SD") - for dest in _BLOCK_REGISTER_DESTS - ] - + [("TXT", dest) for dest in _BLOCK_TXT_DESTS] - ), - "fill": frozenset( - [ - (source, dest) - for source in ("DS", "DD", "DH", "DF", "XD", "YD", "TD", "CTD", "SD") - for dest in _FILL_REGISTER_DESTS - ] - + [("TXT", "TXT")] - ), - "pack_bits": frozenset( - [(source, dest) for source in _PACK_BITS_16_SOURCES for dest in _PACK_BITS_16_DESTS] - + [(source, dest) for source in _PACK_BITS_32_SOURCES for dest in _PACK_BITS_16_DESTS] - + [(source, dest) for source in _PACK_BITS_32_SOURCES for dest in _PACK_BITS_32_DESTS] - ), - "pack_words": PACK_WORDS_COMPATIBILITY, - "unpack_bits": UNPACK_BITS_COMPATIBILITY, - "unpack_words": UNPACK_WORDS_COMPATIBILITY, -} - -_HEX_COMPARE_BANKS: frozenset[str] = frozenset({"XD", "YD", "DH"}) -_NUMERIC_COMPARE_BANKS: frozenset[str] = frozenset({"TD", "CTD", "DS", "DD", "DF", "SD"}) -_TEXT_COMPARE_BANKS: frozenset[str] = frozenset({"TXT"}) - -COMPARE_COMPATIBILITY: frozenset[tuple[str, str]] = frozenset( - [(left, right) for left in _HEX_COMPARE_BANKS for right in _HEX_COMPARE_BANKS] - + [(left, right) for left in _NUMERIC_COMPARE_BANKS for right in _NUMERIC_COMPARE_BANKS] - + [("TXT", "TXT")] -) - - -def _constant_kinds(*kinds: CompareConstantKind) -> frozenset[CompareConstantKind]: - return frozenset(kinds) - - -COMPARE_CONSTANT_COMPATIBILITY: dict[str, frozenset[CompareConstantKind]] = { - "XD": _constant_kinds("hex"), - "YD": _constant_kinds("hex"), - "DH": _constant_kinds("hex"), - "TD": _constant_kinds("int1", "int2", "float"), - "CTD": _constant_kinds("int1", "int2", "float"), - "DS": _constant_kinds("int1", "int2", "float"), - "DD": _constant_kinds("int1", "int2", "float"), - "DF": _constant_kinds("int1", "int2", "float"), - "SD": _constant_kinds("int1", "int2", "float"), - "TXT": _constant_kinds("text"), -} - - -class ClickHardwareProfile: - """Capability lookup API for Click portability validation.""" - - def is_writable(self, memory_type: str, address: int | None = None) -> bool: - if memory_type not in BANKS: - raise KeyError(f"Unknown bank: {memory_type!r}") - - capability = LADDER_BANK_CAPABILITIES[memory_type] - if capability.writable_subset is None: - return capability.writable - - if address is None: - return False - return address in capability.writable_subset - - def valid_for_role(self, memory_type: str, role: InstructionRole) -> bool: - if memory_type not in BANKS: - raise KeyError(f"Unknown bank: {memory_type!r}") - allowed = INSTRUCTION_ROLE_COMPATIBILITY.get(role) - if allowed is None: - raise KeyError(f"Unknown instruction role: {role!r}") - return memory_type in allowed - - def copy_compatible( - self, - operation: CopyOperation, - source_type: str, - dest_type: str, - ) -> bool: - if source_type not in BANKS: - raise KeyError(f"Unknown source bank: {source_type!r}") - if dest_type not in BANKS: - raise KeyError(f"Unknown destination bank: {dest_type!r}") - compatibility = COPY_COMPATIBILITY.get(operation) - if compatibility is None: - raise KeyError(f"Unknown copy operation: {operation!r}") - return (source_type, dest_type) in compatibility - - def compare_compatible(self, left_bank: str, right_bank: str) -> bool: - if left_bank not in BANKS: - raise KeyError(f"Unknown left bank: {left_bank!r}") - if right_bank not in BANKS: - raise KeyError(f"Unknown right bank: {right_bank!r}") - return (left_bank, right_bank) in COMPARE_COMPATIBILITY - - def compare_constant_compatible(self, bank: str, const_kind: CompareConstantKind) -> bool: - if bank not in BANKS: - raise KeyError(f"Unknown bank: {bank!r}") - compatibility = COMPARE_CONSTANT_COMPATIBILITY.get(bank) - if compatibility is None: - return False - if const_kind not in {"int1", "int2", "float", "hex", "text"}: - raise KeyError(f"Unknown constant kind: {const_kind!r}") - return const_kind in compatibility - - -CLICK_HARDWARE_PROFILE = ClickHardwareProfile() - -assert set(LADDER_BANK_CAPABILITIES) == set(BANKS), ( - "LADDER_BANK_CAPABILITIES keys must match BANKS keys" -) - -__all__ = [ - "InstructionRole", - "CopyOperation", - "CompareConstantKind", - "BankCapability", - "LADDER_WRITABLE_SC", - "LADDER_WRITABLE_SD", - "LADDER_BANK_CAPABILITIES", - "INSTRUCTION_ROLE_COMPATIBILITY", - "COPY_COMPATIBILITY", - "COMPARE_COMPATIBILITY", - "COMPARE_CONSTANT_COMPATIBILITY", - "ClickHardwareProfile", - "CLICK_HARDWARE_PROFILE", -] diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py deleted file mode 100644 index 9c4dee1..0000000 --- a/tests/test_capabilities.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for table-driven hardware capabilities.""" - -from __future__ import annotations - -import pytest - -from pyclickplc import ( - CLICK_HARDWARE_PROFILE, - COMPARE_COMPATIBILITY, - COMPARE_CONSTANT_COMPATIBILITY, - COPY_COMPATIBILITY, - INSTRUCTION_ROLE_COMPATIBILITY, - LADDER_WRITABLE_SC, - LADDER_WRITABLE_SD, - ClickHardwareProfile, - CompareConstantKind, - CopyOperation, - InstructionRole, -) - - -def test_profile_export_available(): - assert isinstance(CLICK_HARDWARE_PROFILE, ClickHardwareProfile) - - -@pytest.mark.parametrize( - ("memory_type", "address", "expected"), - [ - ("X", 1, False), - ("Y", 1, True), - ("C", 1, True), - ("T", 1, False), - ("CT", 1, False), - ("DS", 1, True), - ("DD", 1, True), - ("DH", 1, True), - ("DF", 1, True), - ("XD", 1, False), - ("YD", 1, False), - ("TD", 1, True), - ("CTD", 1, True), - ("TXT", 1, True), - ], -) -def test_is_writable_baseline(memory_type: str, address: int, expected: bool): - assert CLICK_HARDWARE_PROFILE.is_writable(memory_type, address) is expected - - -def test_is_writable_sc_subset(): - for address in LADDER_WRITABLE_SC: - assert CLICK_HARDWARE_PROFILE.is_writable("SC", address) is True - assert CLICK_HARDWARE_PROFILE.is_writable("SC", 1) is False - assert CLICK_HARDWARE_PROFILE.is_writable("SC", None) is False - - -def test_is_writable_sd_subset(): - for address in LADDER_WRITABLE_SD: - assert CLICK_HARDWARE_PROFILE.is_writable("SD", address) is True - assert CLICK_HARDWARE_PROFILE.is_writable("SD", 1) is False - assert CLICK_HARDWARE_PROFILE.is_writable("SD", None) is False - - -@pytest.mark.parametrize( - ("role", "ok_bank", "bad_bank"), - [ - ("timer_done_bit", "T", "C"), - ("timer_accumulator", "TD", "DS"), - ("timer_setpoint", "DS", "DD"), - ("counter_done_bit", "CT", "C"), - ("counter_accumulator", "CTD", "DD"), - ("counter_setpoint", "DD", "TD"), - ("copy_pointer", "DS", "DD"), - ], -) -def test_role_compatibility(role: InstructionRole, ok_bank: str, bad_bank: str): - assert CLICK_HARDWARE_PROFILE.valid_for_role(ok_bank, role) is True - assert CLICK_HARDWARE_PROFILE.valid_for_role(bad_bank, role) is False - - -@pytest.mark.parametrize( - ("operation", "source", "dest", "expected"), - [ - ("single", "X", "Y", True), - ("single", "DS", "TXT", True), - ("single", "DS", "C", False), - ("block", "TXT", "DS", True), - ("block", "TXT", "TXT", False), - ("fill", "TXT", "TXT", True), - ("fill", "TXT", "DS", False), - ("pack_bits", "X", "DS", True), - ("pack_bits", "C", "DF", True), - ("pack_bits", "X", "DD", False), - ("pack_words", "DS", "DD", True), - ("pack_words", "DD", "DF", False), - ("unpack_bits", "DD", "Y", True), - ("unpack_bits", "DD", "DS", False), - ("unpack_words", "DF", "DH", True), - ("unpack_words", "DS", "DH", False), - ], -) -def test_copy_compatibility(operation: CopyOperation, source: str, dest: str, expected: bool): - assert CLICK_HARDWARE_PROFILE.copy_compatible(operation, source, dest) is expected - - -@pytest.mark.parametrize( - ("left", "right", "expected"), - [ - ("DS", "DD", True), - ("DH", "XD", True), - ("TXT", "TXT", True), - ("TXT", "DD", False), - ("DH", "DD", False), - ], -) -def test_compare_compatibility(left: str, right: str, expected: bool): - assert CLICK_HARDWARE_PROFILE.compare_compatible(left, right) is expected - - -@pytest.mark.parametrize( - ("bank", "const_kind", "expected"), - [ - ("DS", "int1", True), - ("DS", "float", True), - ("DH", "hex", True), - ("TXT", "text", True), - ("DH", "int1", False), - ("TXT", "hex", False), - ], -) -def test_compare_constant_compatibility(bank: str, const_kind: CompareConstantKind, expected: bool): - assert CLICK_HARDWARE_PROFILE.compare_constant_compatible(bank, const_kind) is expected - - -def test_lookup_tables_exported(): - assert "single" in COPY_COMPATIBILITY - assert "timer_done_bit" in INSTRUCTION_ROLE_COMPATIBILITY - assert ("DS", "DD") in COMPARE_COMPATIBILITY - assert "DS" in COMPARE_CONSTANT_COMPATIBILITY - - -@pytest.mark.parametrize( - "call", - [ - lambda: CLICK_HARDWARE_PROFILE.is_writable("ZZ", 1), - lambda: CLICK_HARDWARE_PROFILE.valid_for_role("ZZ", "timer_done_bit"), - lambda: CLICK_HARDWARE_PROFILE.valid_for_role("T", "bad_role"), # type: ignore[arg-type] - lambda: CLICK_HARDWARE_PROFILE.copy_compatible("bad_op", "DS", "DD"), # type: ignore[arg-type] - lambda: CLICK_HARDWARE_PROFILE.copy_compatible("single", "ZZ", "DD"), - lambda: CLICK_HARDWARE_PROFILE.copy_compatible("single", "DS", "ZZ"), - lambda: CLICK_HARDWARE_PROFILE.compare_compatible("ZZ", "DS"), - lambda: CLICK_HARDWARE_PROFILE.compare_constant_compatible("ZZ", "int1"), - lambda: CLICK_HARDWARE_PROFILE.compare_constant_compatible("DS", "bad_kind"), # type: ignore[arg-type] - ], -) -def test_unknown_inputs_raise_key_error(call): - with pytest.raises(KeyError): - call() From 427381a417bf6928a32d6611e5d181fa0f1f8fde Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:26:07 -0500 Subject: [PATCH 38/55] feat: normalize address/tag access across CSV and client APIs" -m "Add shared AddressNormalizerMixin and apply it to ModbusResponse and MemoryDataProvider for consistent canonical address handling. Introduce AddressRecordMap as the read_csv() return type (dict[int, AddressRecord]-compatible) with: - records.addr[...] normalized address lookup - records.tag[...] case-insensitive nickname lookup - lazy index rebuild with mutation invalidation Enforce nickname.lower() collision rejection during CSV load while preserving existing int-key access and write_csv content-only behavior. Refactor ClickClient tags to programmatic input: - remove tag_filepath - add tags: Mapping[str, AddressRecord] | None - build tags from AddressRecord.nickname - auto-skip empty nicknames - reject case-insensitive collisions - resolve tag read/write names case-insensitively Hard-rename DataviewRow -> DataViewRecord across code, tests, and exports. Update README/docs/changelog and extend tests for new lookup/tag behavior, collision handling, and normalization parity. Validation: - uv run pytest -q (703 passed) - uv run ruff check . - uv run ty check" --- CHANGELOG.md | 11 +++ README.md | 17 +++- docs/guides/client.md | 2 +- docs/guides/files.md | 2 + src/pyclickplc/__init__.py | 8 +- src/pyclickplc/addresses.py | 20 ++++ src/pyclickplc/client.py | 79 +++++++++------ src/pyclickplc/dataview.py | 27 +++--- src/pyclickplc/nicknames.py | 182 ++++++++++++++++++++++++++++++++++- src/pyclickplc/server.py | 8 +- tests/test_client.py | 55 ++++++++++- tests/test_dataview.py | 29 +++--- tests/test_modbus_service.py | 4 +- tests/test_nicknames.py | 67 ++++++++++++- tests/test_server.py | 5 + tests/test_server_tui.py | 39 ++++++-- 16 files changed, 467 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e5216..15d4615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-21 + +### Breaking Changes +- `ClickClient` now accepts `tags: Mapping[str, AddressRecord] | None` and no longer accepts `tag_filepath`. +- Renamed `DataviewRow` to `DataViewRecord` across API exports and dataview helpers. + +### Notes +- `read_csv` now returns `AddressRecordMap`, which is `dict[int, AddressRecord]` compatible and adds + `records.addr[...]` (normalized address lookup) and `records.tag[...]` (case-insensitive nickname lookup). +- CSV nickname loading now rejects case-insensitive collisions (`nickname.lower()` conflicts). + ## 2026-02-13 ### Breaking Changes diff --git a/README.md b/README.md index 5f907d0..9b9c3b6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Requires Python 3.11+. The Modbus client and server depend on [pymodbus](https:/ ```python import asyncio -from pyclickplc import ClickClient +from pyclickplc import AddressRecord, ClickClient async def main(): async with ClickClient("192.168.1.10", 502) as plc: @@ -30,10 +30,13 @@ async def main(): await plc.addr.write("df1", 3.14) by_addr = await plc.addr.read("df1") - # Tag interface (requires tag_filepath on client construction) - async with ClickClient("192.168.1.10", 502, tag_filepath="nicknames.csv") as tagged: + # Tag interface (programmatic tags) + tags = { + "temp_source": AddressRecord(memory_type="DF", address=1, nickname="MyTag"), + } + async with ClickClient("192.168.1.10", 502, tags=tags) as tagged: await tagged.tag.write("MyTag", 42) - tag_value = await tagged.tag.read("MyTag") + tag_value = await tagged.tag.read("mytag") # case-insensitive all_tag_values = await tagged.tag.read() tag_defs = tagged.tag.read_all() # synchronous tag metadata @@ -137,11 +140,15 @@ Read and write CLICK software nickname CSV files. ```python from pyclickplc import read_csv, write_csv -# Read — returns dict[addr_key, AddressRecord] +# Read — returns AddressRecordMap (dict[int, AddressRecord] compatible) records = read_csv("nicknames.csv") for key, record in records.items(): print(record.display_address, record.nickname, record.comment) +# Address/nickname lookup views +ds1 = records.addr["ds1"] +tag = records.tag["mytag"] # case-insensitive nickname lookup + # Write — only records with content are written count = write_csv("output.csv", records) ``` diff --git a/docs/guides/client.md b/docs/guides/client.md index b045b15..fb6e60f 100644 --- a/docs/guides/client.md +++ b/docs/guides/client.md @@ -22,5 +22,5 @@ asyncio.run(main()) - Bank accessors: `plc.ds`, `plc.df`, `plc.y`, etc. - String addresses: `plc.addr.read("DS1")`, `plc.addr.write("C1", True)` -- Tags (with `tag_filepath`): `plc.tag.read("Name")`, `plc.tag.write("Name", value)` +- Tags (with `tags=`): `plc.tag.read("name")`, `plc.tag.write("name", value)` (case-insensitive) diff --git a/docs/guides/files.md b/docs/guides/files.md index 5269df2..c10874c 100644 --- a/docs/guides/files.md +++ b/docs/guides/files.md @@ -6,6 +6,8 @@ from pyclickplc import read_csv, write_csv records = read_csv("nicknames.csv") +motor = records.addr["ds1"] +tag = records.tag["mytag"] count = write_csv("output.csv", records) ``` diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 7365e81..f29bf89 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -14,7 +14,7 @@ from .client import ClickClient, ModbusResponse from .dataview import ( DataviewFile, - DataviewRow, + DataViewRecord, check_cdv_file, get_data_type_for_address, read_cdv, @@ -30,7 +30,7 @@ unpack_value, ) from .modbus_service import ConnectionState, ModbusService, ReconnectConfig, WriteResult -from .nicknames import read_csv, write_csv +from .nicknames import AddressRecordMap, read_csv, write_csv from .server import ClickServer, MemoryDataProvider, ServerClientInfo from .server_tui import run_server_tui from .validation import validate_comment, validate_initial_value, validate_nickname @@ -72,7 +72,7 @@ "ServerClientInfo", "run_server_tui", "DataviewFile", - "DataviewRow", + "DataViewRecord", "check_cdv_file", "get_data_type_for_address", "validate_new_value", @@ -80,8 +80,10 @@ "write_cdv", "verify_cdv", "read_csv", + "AddressRecordMap", "write_csv", "validate_nickname", "validate_comment", "validate_initial_value", ] + diff --git a/src/pyclickplc/addresses.py b/src/pyclickplc/addresses.py index ec13c5f..7f6e97c 100644 --- a/src/pyclickplc/addresses.py +++ b/src/pyclickplc/addresses.py @@ -185,6 +185,26 @@ def normalize_address(address: str) -> str | None: return format_address_display(memory_type, mdb_address) +class AddressNormalizerMixin: + """Shared helpers for address normalization and parsing.""" + + @staticmethod + def _normalize_address(address: str) -> str | None: + return normalize_address(address) + + @classmethod + def _normalize_address_strict(cls, address: str) -> str: + normalized = cls._normalize_address(address) + if normalized is None: + raise ValueError(f"Invalid address format: {address!r}") + return normalized + + @classmethod + def _parse_address_strict(cls, address: str) -> tuple[str, int]: + normalized = cls._normalize_address_strict(address) + return parse_address(normalized) + + # ============================================================================== # AddressRecord Data Model # ============================================================================== diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index b27b28e..bbc78d3 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -10,7 +10,7 @@ from pymodbus.client import AsyncModbusTcpClient -from .addresses import format_address_display, normalize_address, parse_address +from .addresses import AddressNormalizerMixin, AddressRecord, format_address_display, parse_address from .banks import BANKS from .modbus import ( MODBUS_MAPPINGS, @@ -18,7 +18,7 @@ plc_to_modbus, unpack_value, ) -from .nicknames import DATA_TYPE_CODE_TO_STR, read_csv +from .nicknames import DATA_TYPE_CODE_TO_STR from .validation import assert_runtime_value PlcValue = bool | int | float | str @@ -56,7 +56,7 @@ # ============================================================================== -class ModbusResponse(Mapping[str, TValue_co], Generic[TValue_co]): +class ModbusResponse(AddressNormalizerMixin, Mapping[str, TValue_co], Generic[TValue_co]): """Immutable mapping with normalized PLC address keys. Keys are stored in canonical uppercase form (``DS1``, ``X001``). @@ -72,7 +72,7 @@ def __init__(self, data: dict[str, TValue_co]) -> None: # -- Mapping interface -------------------------------------------------- def __getitem__(self, key: str) -> TValue_co: - normalized = normalize_address(key) + normalized = self._normalize_address(key) if normalized is not None and normalized in self._data: return self._data[normalized] raise KeyError(key) @@ -80,7 +80,7 @@ def __getitem__(self, key: str) -> TValue_co: def __contains__(self, key: object) -> bool: if not isinstance(key, str): return False - normalized = normalize_address(key) + normalized = self._normalize_address(key) return normalized is not None and normalized in self._data def __iter__(self) -> Iterator[str]: @@ -99,7 +99,7 @@ def __eq__(self, other: object) -> bool: return False normalized: dict[str, object] = {} for k, v in other.items(): - nk = normalize_address(k) if isinstance(k, str) else None + nk = self._normalize_address(k) if isinstance(k, str) else None if nk is None: return False normalized[nk] = v @@ -139,17 +139,28 @@ def __repr__(self) -> str: # ============================================================================== -def _load_tags(filepath: str) -> dict[str, dict[str, str]]: - """Load tags from nicknames CSV using nicknames.read_csv.""" - records = read_csv(filepath) +def _build_tags_from_records(records: Mapping[str, AddressRecord]) -> dict[str, dict[str, str]]: + """Build ClickClient tag definitions from AddressRecords.""" tags: dict[str, dict[str, str]] = {} - for r in records.values(): - if r.nickname and not r.nickname.startswith("_"): - tags[r.nickname] = { - "address": r.display_address, - "type": DATA_TYPE_CODE_TO_STR.get(r.data_type, ""), - "comment": r.comment, - } + seen: dict[str, tuple[str, str]] = {} + for record in records.values(): + nickname = record.nickname.strip() + if nickname == "": + continue + key = nickname.lower() + if key in seen: + first_name, first_address = seen[key] + raise ValueError( + "Case-insensitive duplicate nickname in ClickClient tags: " + f"{first_name!r}@{first_address} conflicts with " + f"{nickname!r}@{record.display_address}" + ) + seen[key] = (nickname, record.display_address) + tags[nickname] = { + "address": record.display_address, + "type": DATA_TYPE_CODE_TO_STR.get(record.data_type, ""), + "comment": record.comment, + } # Sort by address return dict(sorted(tags.items(), key=lambda item: item[1]["address"])) @@ -518,20 +529,33 @@ class TagInterface: def __init__(self, plc: ClickClient) -> None: self._plc = plc + @staticmethod + def _resolve_tag_name(tags: Mapping[str, dict[str, str]], tag_name: str) -> str: + if tag_name in tags: + return tag_name + lowered = tag_name.lower() + matches = [name for name in tags if name.lower() == lowered] + if len(matches) == 1: + return matches[0] + available = list(tags.keys())[:5] + if len(matches) > 1: + raise KeyError( + f"Tag '{tag_name}' is ambiguous due to case-colliding names. Available: {available}" + ) + raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") + async def read(self, tag_name: str | None = None) -> dict[str, PlcValue] | PlcValue: """Read single tag or all tags.""" tags = self._plc.tags if tag_name is not None: - if tag_name not in tags: - available = list(tags.keys())[:5] - raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") - tag_info = tags[tag_name] + resolved_name = self._resolve_tag_name(tags, tag_name) + tag_info = tags[resolved_name] resp = await self._plc.addr.read(tag_info["address"]) # Single-address read → extract the lone value return next(iter(resp.values())) if not tags: - raise ValueError("No tags loaded. Provide a tag file or specify a tag name.") + raise ValueError("No tags loaded. Provide tags to ClickClient or specify a tag name.") all_tags: dict[str, PlcValue] = {} for name, info in tags.items(): @@ -546,10 +570,8 @@ async def write( ) -> None: """Write value by tag name.""" tags = self._plc.tags - if tag_name not in tags: - available = list(tags.keys())[:5] - raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") - tag_info = tags[tag_name] + resolved_name = self._resolve_tag_name(tags, tag_name) + tag_info = tags[resolved_name] await self._plc.addr.write(tag_info["address"], data) def read_all(self) -> dict[str, dict[str, str]]: @@ -571,7 +593,7 @@ def __init__( self, host: str, port: int = 502, - tag_filepath: str = "", + tags: Mapping[str, AddressRecord] | None = None, timeout: int = 1, device_id: int = 1, reconnect_delay: float = 0.0, @@ -600,8 +622,8 @@ def __init__( self.addr = AddressInterface(self) self.tag = TagInterface(self) - if tag_filepath: - self.tags = _load_tags(tag_filepath) + if tags is not None: + self.tags = _build_tags_from_records(tags) async def _call_modbus(self, method: Any, /, **kwargs: Any) -> Any: """Call pymodbus methods with device_id, falling back to legacy slave.""" @@ -720,3 +742,4 @@ async def _write_registers(self, address: int, values: list[int]) -> None: ) if result.isError(): raise OSError(f"Modbus write error at register {address}: {result}") + diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 6ca964f..cec7f60 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -1,6 +1,6 @@ """DataView model and CDV file I/O for CLICK PLC DataView files. -Provides the DataviewRow dataclass, CDV file read/write, +Provides the DataViewRecord dataclass, CDV file read/write, value conversion functions between CDV storage, native Python types, UI display strings, and CDV verification helpers. """ @@ -142,7 +142,7 @@ def is_address_writable(address: str) -> bool: @dataclass -class DataviewRow: +class DataViewRecord: """Represents a single row in a CLICK DataView. A dataview row contains an address to monitor and optionally a new value @@ -210,16 +210,16 @@ def clear(self) -> None: self.comment = "" -def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataviewRow]: +def create_empty_dataview(count: int = MAX_DATAVIEW_ROWS) -> list[DataViewRecord]: """Create a new empty dataview with the specified number of rows. Args: count: Number of rows to create (default MAX_DATAVIEW_ROWS). Returns: - List of empty DataviewRow objects. + List of empty DataViewRecord objects. """ - return [DataviewRow() for _ in range(count)] + return [DataViewRecord() for _ in range(count)] # --- Value Conversion Functions --- @@ -445,7 +445,7 @@ def _default_cdv_header(has_new_values: bool) -> str: return f"{-1 if has_new_values else 0},0,0" -def _rows_snapshot(rows: list[DataviewRow]) -> list[tuple[str, DataType | None, DataviewValue]]: +def _rows_snapshot(rows: list[DataViewRecord]) -> list[tuple[str, DataType | None, DataviewValue]]: return [(row.address, row.data_type, row.new_value) for row in rows] @@ -515,15 +515,15 @@ def _values_equal_for_data_type( return expected_norm == actual_norm -def _row_placeholder(rows: list[DataviewRow], index: int) -> DataviewRow: - return rows[index] if index < len(rows) else DataviewRow() +def _row_placeholder(rows: list[DataViewRecord], index: int) -> DataViewRecord: + return rows[index] if index < len(rows) else DataViewRecord() @dataclass class DataviewFile: """CDV file model with row data in native Python types.""" - rows: list[DataviewRow] = field(default_factory=create_empty_dataview) + rows: list[DataViewRecord] = field(default_factory=create_empty_dataview) has_new_values: bool = False header: str = "0,0,0" path: Path | None = None @@ -565,14 +565,14 @@ def try_parse_display(display_str: str, data_type: DataType | None) -> DisplayPa return DisplayParseResult(ok=True, value=native) @staticmethod - def validate_row_display(row: DataviewRow, display_str: str) -> tuple[bool, str]: + def validate_row_display(row: DataViewRecord, display_str: str) -> tuple[bool, str]: """Validate a user edit for a specific row.""" if not row.is_writable: return False, "Read-only address" return DataviewFile.validate_display(display_str, row.data_type) @staticmethod - def set_row_new_value_from_display(row: DataviewRow, display_str: str) -> None: + def set_row_new_value_from_display(row: DataViewRecord, display_str: str) -> None: """Strictly set a row's native new_value from a display string.""" ok, error = DataviewFile.validate_row_display(row, display_str) if not ok: @@ -688,7 +688,7 @@ def save(self, path: Path | str | None = None) -> None: lines: list[str] = [self.header] rows_to_save = list(self.rows[:MAX_DATAVIEW_ROWS]) while len(rows_to_save) < MAX_DATAVIEW_ROWS: - rows_to_save.append(DataviewRow()) + rows_to_save.append(DataViewRecord()) new_storage_tokens: list[str | None] = [] for row in rows_to_save: @@ -924,7 +924,7 @@ def check_cdv_file(path: Path | str) -> list[str]: def verify_cdv( path: Path | str, - rows: list[DataviewRow], + rows: list[DataViewRecord], has_new_values: bool | None = None, ) -> list[str]: """Verify in-memory rows against a CDV file using native value comparison.""" @@ -941,3 +941,4 @@ def verify_cdv( path=Path(path), ) return dataview.verify(path) + diff --git a/src/pyclickplc/nicknames.py b/src/pyclickplc/nicknames.py index f223fb1..171ffe6 100644 --- a/src/pyclickplc/nicknames.py +++ b/src/pyclickplc/nicknames.py @@ -7,9 +7,11 @@ from __future__ import annotations import csv +from collections.abc import Mapping from pathlib import Path +from typing import Any -from .addresses import AddressRecord, get_addr_key, parse_address +from .addresses import AddressNormalizerMixin, AddressRecord, get_addr_key, parse_address from .banks import BANKS, DEFAULT_RETENTIVE, MEMORY_TYPE_BASES, MEMORY_TYPE_TO_DATA_TYPE, DataType # CSV column names (matching CLICK software export format) @@ -37,7 +39,164 @@ } -def read_csv(path: str | Path) -> dict[int, AddressRecord]: +class AddressLookupView(AddressNormalizerMixin): + """Address-indexed view over an AddressRecordMap.""" + + def __init__(self, records: AddressRecordMap) -> None: + self._records = records + + def __getitem__(self, address: str) -> AddressRecord: + normalized = self._normalize_address(address) + if normalized is None: + raise KeyError(address) + self._records._ensure_indexes() + if normalized not in self._records._address_index: + raise KeyError(address) + return self._records._address_index[normalized] + + def __contains__(self, address: object) -> bool: + if not isinstance(address, str): + return False + normalized = self._normalize_address(address) + if normalized is None: + return False + self._records._ensure_indexes() + return normalized in self._records._address_index + + def get(self, address: str, default: Any = None) -> AddressRecord | Any: + try: + return self[address] + except KeyError: + return default + + +class TagLookupView: + """Case-insensitive nickname-indexed view over an AddressRecordMap.""" + + def __init__(self, records: AddressRecordMap) -> None: + self._records = records + + def __getitem__(self, nickname: str) -> AddressRecord: + self._records._ensure_indexes() + if self._records._tag_collision_error is not None: + raise ValueError(self._records._tag_collision_error) + lowered = nickname.lower() + if lowered not in self._records._tag_index: + raise KeyError(nickname) + return self._records._tag_index[lowered] + + def __contains__(self, nickname: object) -> bool: + if not isinstance(nickname, str): + return False + self._records._ensure_indexes() + if self._records._tag_collision_error is not None: + raise ValueError(self._records._tag_collision_error) + return nickname.lower() in self._records._tag_index + + def get(self, nickname: str, default: Any = None) -> AddressRecord | Any: + try: + return self[nickname] + except KeyError: + return default + + +class AddressRecordMap(dict[int, AddressRecord]): + """Address record mapping with helper lookup views.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._address_index: dict[str, AddressRecord] = {} + self._tag_index: dict[str, AddressRecord] = {} + self._tag_collision_error: str | None = None + self._index_dirty = True + self.addr = AddressLookupView(self) + self.tag = TagLookupView(self) + + def _mark_dirty(self) -> None: + self._index_dirty = True + + def _ensure_indexes(self) -> None: + if not self._index_dirty: + return + self._rebuild_indexes() + + def _rebuild_indexes(self) -> None: + address_index: dict[str, AddressRecord] = {} + tag_index: dict[str, AddressRecord] = {} + first_seen_tag: dict[str, tuple[str, str]] = {} + collisions: list[tuple[str, str, str, str, str]] = [] + + for record in self.values(): + address_index[record.display_address] = record + nickname = record.nickname.strip() + if nickname == "": + continue + key = nickname.lower() + if key in first_seen_tag: + first_nickname, first_address = first_seen_tag[key] + collisions.append( + (key, first_nickname, first_address, nickname, record.display_address) + ) + continue + first_seen_tag[key] = (nickname, record.display_address) + tag_index[key] = record + + if collisions: + parts = [] + for key, first_nickname, first_address, nickname, address in collisions[:5]: + parts.append( + f"{key!r}: {first_nickname!r}@{first_address} conflicts with {nickname!r}@{address}" + ) + extra = f" (+{len(collisions) - 5} more)" if len(collisions) > 5 else "" + self._tag_collision_error = ( + "Case-insensitive duplicate tag nickname(s) in AddressRecordMap: " + + "; ".join(parts) + + extra + ) + else: + self._tag_collision_error = None + + self._address_index = address_index + self._tag_index = tag_index + self._index_dirty = False + + # -- mutation hooks ----------------------------------------------------- + def __setitem__(self, key: int, value: AddressRecord) -> None: + super().__setitem__(key, value) + self._mark_dirty() + + def __delitem__(self, key: int) -> None: + super().__delitem__(key) + self._mark_dirty() + + def clear(self) -> None: + super().clear() + self._mark_dirty() + + def pop(self, key: int, default: Any = ...): + if default is ...: + value = super().pop(key) + else: + value = super().pop(key, default) + self._mark_dirty() + return value + + def popitem(self) -> tuple[int, AddressRecord]: + value = super().popitem() + self._mark_dirty() + return value + + def setdefault(self, key: int, default: Any = None) -> Any: + value = super().setdefault(key, default) + self._mark_dirty() + return value + + def update(self, *args: Any, **kwargs: Any) -> None: + super().update(*args, **kwargs) + self._mark_dirty() + + +def read_csv(path: str | Path) -> AddressRecordMap: """Read a user-format CSV file into AddressRecords. The user CSV has columns: Address, Data Type, Nickname, Initial Value, @@ -47,9 +206,10 @@ def read_csv(path: str | Path) -> dict[int, AddressRecord]: path: Path to the CSV file. Returns: - Dict mapping addr_key (int) to AddressRecord. + AddressRecordMap keyed by addr_key (int) with ``.addr`` and ``.tag`` views. """ - result: dict[int, AddressRecord] = {} + result = AddressRecordMap() + seen_nicknames: dict[str, tuple[str, str, int]] = {} with open(path, newline="", encoding="utf-8") as csvfile: reader = csv.DictReader(csvfile) @@ -82,6 +242,18 @@ def read_csv(path: str | Path) -> dict[int, AddressRecord]: comment = row.get("Address Comment", "").strip() initial_value = row.get("Initial Value", "").strip() + if nickname: + key = nickname.lower() + if key in seen_nicknames: + first_nickname, first_address, first_line = seen_nicknames[key] + raise ValueError( + "Case-insensitive duplicate nickname in CSV at " + f"line {reader.line_num}: {nickname!r} at {addr_str} " + f"conflicts with {first_nickname!r} at {first_address} " + f"(line {first_line})." + ) + seen_nicknames[key] = (nickname, addr_str, reader.line_num) + addr_key = get_addr_key(mem_type, mdb_address) record = AddressRecord( @@ -99,7 +271,7 @@ def read_csv(path: str | Path) -> dict[int, AddressRecord]: return result -def write_csv(path: str | Path, records: dict[int, AddressRecord]) -> int: +def write_csv(path: str | Path, records: Mapping[int, AddressRecord]) -> int: """Write AddressRecords to a user-format CSV file. Only records with content (nickname, comment, non-default initial value diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py index d179107..2046f13 100644 --- a/src/pyclickplc/server.py +++ b/src/pyclickplc/server.py @@ -13,7 +13,7 @@ from pymodbus.datastore import ModbusBaseDeviceContext, ModbusServerContext from pymodbus.server import ModbusTcpServer -from .addresses import format_address_display, parse_address +from .addresses import AddressNormalizerMixin, format_address_display, parse_address from .banks import BANKS, DataType from .modbus import ( MODBUS_MAPPINGS, @@ -54,7 +54,7 @@ def write(self, address: str, value: PlcValue) -> None: ... } -class MemoryDataProvider: +class MemoryDataProvider(AddressNormalizerMixin): """In-memory DataProvider for testing and simple use cases.""" def __init__(self) -> None: @@ -62,8 +62,8 @@ def __init__(self) -> None: def _normalize(self, address: str) -> tuple[str, str, int]: """Normalize address and return (normalized, bank, index).""" - bank, index = parse_address(address) - normalized = format_address_display(bank, index) + normalized = self._normalize_address_strict(address) + bank, index = parse_address(normalized) return normalized, bank, index def _default(self, bank: str) -> PlcValue: diff --git a/tests/test_client.py b/tests/test_client.py index b67c5d1..40f568b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,6 +10,7 @@ import pytest +from pyclickplc.addresses import AddressRecord from pyclickplc.banks import DataType from pyclickplc.client import ( AddressAccessor, @@ -25,9 +26,9 @@ # ============================================================================== -def _make_plc(tag_filepath: str = "") -> ClickClient: +def _make_plc() -> ClickClient: """Create a ClickClient without connecting.""" - plc = ClickClient("localhost", 5020, tag_filepath=tag_filepath) + plc = ClickClient("localhost", 5020) # Mock internal transport methods _set_read_coils(plc, [False]) _set_write_coils(plc) @@ -113,6 +114,40 @@ async def test_construction_with_reconnect_settings(self): assert plc._client.comm_params.reconnect_delay == 0.5 assert plc._client.comm_params.reconnect_delay_max == 2.0 + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags(self): + plc = ClickClient( + "192.168.1.100", + tags={ + "ignored": AddressRecord(memory_type="DF", address=1, nickname="Temp"), + "other": AddressRecord(memory_type="C", address=1, nickname="Valve"), + }, + ) + assert set(plc.tags.keys()) == {"Temp", "Valve"} + assert plc.tags["Temp"]["address"] == "DF1" + + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags_skips_empty_nickname(self): + plc = ClickClient( + "192.168.1.100", + tags={ + "first": AddressRecord(memory_type="DF", address=1, nickname=""), + "second": AddressRecord(memory_type="C", address=1, nickname="Valve"), + }, + ) + assert set(plc.tags.keys()) == {"Valve"} + + @pytest.mark.asyncio + async def test_construction_with_programmatic_tags_rejects_case_collisions(self): + with pytest.raises(ValueError, match="duplicate nickname"): + ClickClient( + "192.168.1.100", + tags={ + "a": AddressRecord(memory_type="DF", address=1, nickname="Pump"), + "b": AddressRecord(memory_type="DF", address=2, nickname="pump"), + }, + ) + @pytest.mark.asyncio async def test_getattr_df(self): plc = _make_plc() @@ -542,6 +577,16 @@ async def test_read_missing_tag_raises(self): with pytest.raises(KeyError, match="not found"): await plc.tag.read("NonExistent") + @pytest.mark.asyncio + async def test_read_tag_case_insensitive(self): + plc = self._plc_with_tags() + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + value = await plc.tag.read("temp") + import math + + assert math.isclose(_as_float(value), 25.0, rel_tol=1e-6) + @pytest.mark.asyncio async def test_read_all_tags(self): plc = self._plc_with_tags() @@ -571,6 +616,12 @@ async def test_write_missing_tag_raises(self): with pytest.raises(KeyError, match="not found"): await plc.tag.write("NonExistent", 42) + @pytest.mark.asyncio + async def test_write_tag_case_insensitive(self): + plc = self._plc_with_tags() + await plc.tag.write("temp", 30.0) + _get_write_registers_mock(plc).assert_called_once() + @pytest.mark.asyncio async def test_read_all_definitions(self): plc = self._plc_with_tags() diff --git a/tests/test_dataview.py b/tests/test_dataview.py index fa90cdf..f221411 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -8,7 +8,7 @@ WRITABLE_SC, WRITABLE_SD, DataviewFile, - DataviewRow, + DataViewRecord, DisplayParseResult, _CdvStorageCode, check_cdv_file, @@ -114,11 +114,11 @@ def test_invalid_address(self): assert is_address_writable("") is False -class TestDataviewRow: - """Tests for DataviewRow dataclass.""" +class TestDataViewRecord: + """Tests for DataViewRecord dataclass.""" def test_default_values(self): - row = DataviewRow() + row = DataViewRecord() assert row.address == "" assert row.data_type is None assert row.new_value is None @@ -126,7 +126,7 @@ def test_default_values(self): assert row.comment == "" def test_is_empty(self): - row = DataviewRow() + row = DataViewRecord() assert row.is_empty is True row.address = "X001" @@ -136,28 +136,28 @@ def test_is_empty(self): assert row.is_empty is True def test_is_writable(self): - row = DataviewRow(address="X001") + row = DataViewRecord(address="X001") assert row.is_writable is True row.address = "XD0" assert row.is_writable is False def test_memory_type(self): - row = DataviewRow(address="DS100") + row = DataViewRecord(address="DS100") assert row.memory_type == "DS" row.address = "" assert row.memory_type is None def test_address_number(self): - row = DataviewRow(address="DS100") + row = DataViewRecord(address="DS100") assert row.address_number == "100" row.address = "XD0u" assert row.address_number == "0u" def test_update_data_type(self): - row = DataviewRow(address="DS100") + row = DataViewRecord(address="DS100") assert row.update_data_type() is True assert row.data_type == DataType.INT @@ -165,7 +165,7 @@ def test_update_data_type(self): assert row.update_data_type() is False def test_clear(self): - row = DataviewRow( + row = DataViewRecord( address="X001", data_type=DataType.BIT, new_value=True, @@ -492,17 +492,17 @@ def test_try_parse_display(self): assert parsed_invalid.error == "Must be integer" def test_validate_row_display(self): - row = DataviewRow(address="XD0", data_type=DataType.HEX) + row = DataViewRecord(address="XD0", data_type=DataType.HEX) assert DataviewFile.validate_row_display(row, "0001") == (False, "Read-only address") - row = DataviewRow(address="DS1") + row = DataViewRecord(address="DS1") assert DataviewFile.validate_row_display(row, "100") == (False, "No address set") - row = DataviewRow(address="DS1", data_type=DataType.INT) + row = DataViewRecord(address="DS1", data_type=DataType.INT) assert DataviewFile.validate_row_display(row, "abc") == (False, "Must be integer") def test_set_row_new_value_from_display(self): - row = DataviewRow(address="DS1", data_type=DataType.INT) + row = DataViewRecord(address="DS1", data_type=DataType.INT) DataviewFile.set_row_new_value_from_display(row, "100") assert row.new_value == 100 @@ -698,3 +698,4 @@ def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): issues = check_cdv_file(cdv) assert len(issues) == 1 assert "not writable" in issues[0] + diff --git a/tests/test_modbus_service.py b/tests/test_modbus_service.py index 9aa02b6..4f60107 100644 --- a/tests/test_modbus_service.py +++ b/tests/test_modbus_service.py @@ -79,13 +79,13 @@ def __init__( self, host: str, port: int = 502, - tag_filepath: str = "", + tags: dict[str, object] | None = None, timeout: int = 1, device_id: int = 1, reconnect_delay: float = 0.0, reconnect_delay_max: float = 0.0, ) -> None: - del tag_filepath + del tags del timeout del device_id self.host = host diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index 04c0687..dc90fda 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -1,11 +1,14 @@ """Tests for pyclickplc.nicknames — CSV read/write for address data.""" -from pyclickplc.addresses import AddressRecord +import pytest + +from pyclickplc.addresses import AddressRecord, get_addr_key from pyclickplc.banks import DataType from pyclickplc.nicknames import ( CSV_COLUMNS, DATA_TYPE_CODE_TO_STR, DATA_TYPE_STR_TO_CODE, + AddressRecordMap, read_csv, write_csv, ) @@ -49,6 +52,7 @@ def test_read_basic(self, tmp_path): ) records = read_csv(csv_path) + assert isinstance(records, AddressRecordMap) assert len(records) == 2 # Find the X001 record @@ -65,6 +69,45 @@ def test_read_basic(self, tmp_path): assert ds_records[0].nickname == "Temp" assert ds_records[0].retentive is True + # Existing int-key access remains intact + assert records[get_addr_key("X", 1)].nickname == "Input1" + assert records[get_addr_key("DS", 1)].nickname == "Temp" + + def test_read_addr_lookup_normalized(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Input1",0,No,""\n' + 'DS1,INT,"Temp",0,Yes,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + assert records.addr["x1"].nickname == "Input1" + assert records.addr["X001"].nickname == "Input1" + assert records.addr["ds1"].nickname == "Temp" + + def test_read_tag_lookup_case_insensitive(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"MyTag",0,No,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + assert records.tag["MyTag"].display_address == "X001" + assert records.tag["mytag"].display_address == "X001" + + def test_read_tag_lookup_excludes_empty_nickname(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"",0,No,""\n', + encoding="utf-8", + ) + records = read_csv(csv_path) + with pytest.raises(KeyError): + _ = records.tag[""] + def test_read_empty_rows_skipped(self, tmp_path): csv_path = tmp_path / "test.csv" csv_path.write_text( @@ -89,6 +132,27 @@ def test_read_invalid_address_skipped(self, tmp_path): records = read_csv(csv_path) assert len(records) == 1 + def test_read_duplicate_nickname_case_insensitive_raises(self, tmp_path): + csv_path = tmp_path / "test.csv" + csv_path.write_text( + "Address,Data Type,Nickname,Initial Value,Retentive,Address Comment\n" + 'X001,BIT,"Pump",0,No,""\n' + 'X002,BIT,"pump",0,No,""\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="Case-insensitive duplicate nickname"): + read_csv(csv_path) + + def test_tag_lookup_rejects_case_collisions_after_mutation(self): + records = AddressRecordMap( + { + 1: AddressRecord(memory_type="X", address=1, nickname="Pump"), + 2: AddressRecord(memory_type="X", address=2, nickname="pump"), + } + ) + with pytest.raises(ValueError, match="duplicate tag nickname"): + _ = records.tag["pump"] + class TestWriteCsv: """Tests for write_csv function.""" @@ -177,3 +241,4 @@ def test_roundtrip(self, tmp_path): assert ds_records[0].nickname == "Speed" assert ds_records[0].comment == "Motor speed" assert ds_records[0].retentive is True + diff --git a/tests/test_server.py b/tests/test_server.py index eba40df..f678eab 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -85,6 +85,11 @@ def test_address_normalization_x(self): p.set("x1", True) assert p.get("X001") is True + def test_address_normalization_y(self): + p = MemoryDataProvider() + p.set("y1", True) + assert p.get("Y001") is True + def test_overwrite(self): p = MemoryDataProvider() p.set("DS1", 10) diff --git a/tests/test_server_tui.py b/tests/test_server_tui.py index 3ad15e7..e6584ec 100644 --- a/tests/test_server_tui.py +++ b/tests/test_server_tui.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast -from pyclickplc.server import ServerClientInfo +from pyclickplc.server import ClickServer, ServerClientInfo from pyclickplc.server_tui import run_server_tui @@ -64,7 +65,9 @@ async def test_run_server_tui_status_help_shutdown(): output: list[str] = [] await run_server_tui( - server, input_fn=_input_from(["status", "help", "shutdown"]), output_fn=output.append + cast(ClickServer, server), + input_fn=_input_from(["status", "help", "shutdown"]), + output_fn=output.append, ) assert server.start_calls == 1 @@ -78,7 +81,11 @@ async def test_run_server_tui_clients_empty(): server = _FakeServer() output: list[str] = [] - await run_server_tui(server, input_fn=_input_from(["clients", "shutdown"]), output_fn=output.append) + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["clients", "shutdown"]), + output_fn=output.append, + ) assert "No clients connected." in output @@ -88,7 +95,9 @@ async def test_run_server_tui_disconnect_all(): output: list[str] = [] await run_server_tui( - server, input_fn=_input_from(["disconnect all", "shutdown"]), output_fn=output.append + cast(ClickServer, server), + input_fn=_input_from(["disconnect all", "shutdown"]), + output_fn=output.append, ) assert server.disconnect_all_calls == 1 @@ -100,7 +109,9 @@ async def test_run_server_tui_disconnect_single_success(): output: list[str] = [] await run_server_tui( - server, input_fn=_input_from(["disconnect abc", "shutdown"]), output_fn=output.append + cast(ClickServer, server), + input_fn=_input_from(["disconnect abc", "shutdown"]), + output_fn=output.append, ) assert server.disconnect_client_calls == ["abc"] @@ -112,7 +123,9 @@ async def test_run_server_tui_disconnect_single_not_found(): output: list[str] = [] await run_server_tui( - server, input_fn=_input_from(["disconnect missing", "shutdown"]), output_fn=output.append + cast(ClickServer, server), + input_fn=_input_from(["disconnect missing", "shutdown"]), + output_fn=output.append, ) assert server.disconnect_client_calls == ["missing"] @@ -123,7 +136,11 @@ async def test_run_server_tui_unknown_command(): server = _FakeServer() output: list[str] = [] - await run_server_tui(server, input_fn=_input_from(["badcmd", "shutdown"]), output_fn=output.append) + await run_server_tui( + cast(ClickServer, server), + input_fn=_input_from(["badcmd", "shutdown"]), + output_fn=output.append, + ) assert any("Unknown command: badcmd." in line for line in output) @@ -135,7 +152,7 @@ async def test_run_server_tui_eof_triggers_stop(): def _eof(_: str) -> str: raise EOFError - await run_server_tui(server, input_fn=_eof, output_fn=output.append) + await run_server_tui(cast(ClickServer, server), input_fn=_eof, output_fn=output.append) assert server.stop_calls == 1 assert "EOF received, shutting down server." in output @@ -148,7 +165,7 @@ async def test_run_server_tui_keyboard_interrupt_triggers_stop(): def _interrupt(_: str) -> str: raise KeyboardInterrupt - await run_server_tui(server, input_fn=_interrupt, output_fn=output.append) + await run_server_tui(cast(ClickServer, server), input_fn=_interrupt, output_fn=output.append) assert server.stop_calls == 1 assert "Interrupted, shutting down server." in output @@ -158,7 +175,9 @@ async def test_run_server_tui_does_not_restart_running_server(): server = _FakeServer(running=True) output: list[str] = [] - await run_server_tui(server, input_fn=_input_from(["shutdown"]), output_fn=output.append) + await run_server_tui( + cast(ClickServer, server), input_fn=_input_from(["shutdown"]), output_fn=output.append + ) assert server.start_calls == 0 assert server.stop_calls == 1 From b6d0cd1f1ce28b55720c1a319764a857b80f0039 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:19:52 -0500 Subject: [PATCH 39/55] feat(client)!: make XD/YD display-indexed and add xdu/ydu aliases Align ClickClient XD/YD ergonomics with display indexing and explicit upper-byte aliases. - Make `plc.xd` / `plc.yd` display-indexed (`0..8`) instead of MDB-indexed (`0..16`) - Add `plc.xdu` / `plc.ydu` fixed aliases for `XD0u` / `YD0u` - Update `AddressInterface` XD/YD ranges to display-step semantics (e.g. `XD0-XD8`) - Reject XD/YD range endpoints with `u` suffix; keep single-address `XD0u` / `YD0u` support - Preserve low-level MDB semantics in address/modbus helper APIs - Add/adjust client tests and README examples for new behavior BREAKING CHANGE: `ClickClient.xd` and `ClickClient.yd` now use display indexing (`0..8`), so calls like `plc.xd[3]` now resolve to `XD3` (MDB 6), not MDB index 3. --- README.md | 8 +- src/pyclickplc/client.py | 146 ++++++++++++++++++++++++++++++-- tests/test_client.py | 178 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9b9c3b6..5e9a595 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,14 @@ async def main(): await plc.ds.write(1, 100) value = await plc.ds[1] # bare value result = await plc.ds.read(1, 3) # DS1..DS3 (inclusive range) + xd_word = await plc.xd[3] # XD3 (display-indexed, 0..8) + await plc.ydu.write(0x1234) # YD0u explicit upper-byte alias + xdu_word = await plc.xdu.read() # {"XD0u": ...} # Address interface await plc.addr.write("df1", 3.14) by_addr = await plc.addr.read("df1") + yd_display = await plc.addr.read("YD0-YD8") # display-step range for XD/YD # Tag interface (programmatic tags) tags = { @@ -43,7 +47,7 @@ async def main(): asyncio.run(main()) ``` -All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`). Lookups are normalized (`resp["ds1"]` resolves `"DS1"`). Use `await plc.ds[1]` for a bare value. +All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`). Lookups are normalized (`resp["ds1"]` resolves `"DS1"`). Use `await plc.ds[1]` for a bare value. `plc.xd`/`plc.yd` are display-indexed (`0..8`) with `plc.xdu`/`plc.ydu` aliases for `XD0u`/`YD0u`. ## ModbusService (Sync + Polling) @@ -187,6 +191,8 @@ display = format_address_display("XD", 1) # "XD0u" normalized = normalize_address("x1") # "X001" ``` +Note: address helper functions remain MDB-oriented for XD/YD internals (`parse_address("XD3")` returns MDB index `6`). + Full API reference is available via MkDocs (including advanced modules). ## Development diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index bbc78d3..a370069 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -10,7 +10,14 @@ from pymodbus.client import AsyncModbusTcpClient -from .addresses import AddressNormalizerMixin, AddressRecord, format_address_display, parse_address +from .addresses import ( + AddressNormalizerMixin, + AddressRecord, + format_address_display, + parse_address, + xd_yd_display_to_mdb, + xd_yd_mdb_to_display, +) from .banks import BANKS from .modbus import ( MODBUS_MAPPINGS, @@ -30,6 +37,8 @@ StrBankName: TypeAlias = Literal["TXT"] BoolBankAttr: TypeAlias = Literal["x", "X", "y", "Y", "c", "C", "t", "T", "ct", "CT", "sc", "SC"] +DisplayBankAttr: TypeAlias = Literal["xd", "XD", "yd", "YD"] +UpperByteAttr: TypeAlias = Literal["xdu", "XDU", "ydu", "YDU"] IntBankAttr: TypeAlias = Literal[ "ds", "DS", @@ -43,10 +52,6 @@ "CTD", "sd", "SD", - "xd", - "XD", - "yd", - "YD", ] FloatBankAttr: TypeAlias = Literal["df", "DF"] StrBankAttr: TypeAlias = Literal["txt", "TXT"] @@ -479,6 +484,93 @@ def __getitem__(self, key: int) -> Coroutine[Any, Any, TValue_co]: return self._read_single(key) +class DisplayAddressAccessor(Generic[TValue_co]): + """Display-indexed accessor for XD/YD banks. + + Uses display index range ``0..8`` and excludes hidden odd MDB slots. + """ + + def __init__(self, plc: ClickClient, bank: str) -> None: + if bank not in {"XD", "YD"}: + raise ValueError(f"DisplayAddressAccessor supports XD/YD only, got {bank!r}") + self._plc = plc + self._bank = bank + self._raw = cast(AddressAccessor[TValue_co], plc._get_accessor(bank)) + + def _validate_display_index(self, index: int) -> None: + if index < 0 or index > 8: + raise ValueError(f"{self._bank} must be in [0, 8]") + + @staticmethod + def _display_to_mdb(index: int) -> int: + return xd_yd_display_to_mdb(index, upper_byte=False) + + async def _read_single_display(self, index: int) -> TValue_co: + self._validate_display_index(index) + return await self._raw._read_single(self._display_to_mdb(index)) + + async def read(self, start: int, end: int | None = None) -> ModbusResponse[TValue_co]: + if end is None: + value = await self._read_single_display(start) + return ModbusResponse({f"{self._bank}{start}": value}) + + if end <= start: + raise ValueError("End address must be greater than start address.") + self._validate_display_index(start) + self._validate_display_index(end) + + result: dict[str, TValue_co] = {} + for idx in range(start, end + 1): + result[f"{self._bank}{idx}"] = await self._read_single_display(idx) + return ModbusResponse(result) + + async def write( + self, + start: int, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + if isinstance(data, list): + for offset, value in enumerate(data): + index = start + offset + self._validate_display_index(index) + await self._raw.write(self._display_to_mdb(index), value) + return + + self._validate_display_index(start) + await self._raw.write(self._display_to_mdb(start), data) + + def __getitem__(self, key: int) -> Coroutine[Any, Any, TValue_co]: + if isinstance(key, slice): + raise TypeError("Slicing is not supported. Use read(start, end) for range reads.") + return self._read_single_display(key) + + def __repr__(self) -> str: + return f"" + + +class FixedAddressAccessor(Generic[TValue_co]): + """Fixed-address alias accessor (e.g. XD0u/YD0u).""" + + def __init__(self, plc: ClickClient, bank: str, index: int) -> None: + self._plc = plc + self._bank = bank + self._index = index + self._name = format_address_display(bank, index) + self._raw = cast(AddressAccessor[TValue_co], plc._get_accessor(bank)) + + async def read(self) -> ModbusResponse[TValue_co]: + return await self._raw.read(self._index) + + async def write( + self, + data: bool | int | float | str | list[bool] | list[int] | list[float] | list[str], + ) -> None: + await self._raw.write(self._index, data) + + def __repr__(self) -> str: + return f"" + + # ============================================================================== # AddressInterface # ============================================================================== @@ -498,6 +590,21 @@ async def read(self, address: str) -> ModbusResponse[PlcValue]: bank2, end = parse_address(parts[1]) if bank1 != bank2: raise ValueError("Inter-bank ranges are unsupported.") + + if bank1 in {"XD", "YD"}: + start_display_name = format_address_display(bank1, start) + end_display_name = format_address_display(bank1, end) + if start_display_name.endswith("u") or end_display_name.endswith("u"): + raise ValueError( + f"{bank1} ranges cannot include upper-byte addresses; read {bank1}0u separately." + ) + start_display = xd_yd_mdb_to_display(start) + end_display = xd_yd_mdb_to_display(end) + if end_display <= start_display: + raise ValueError("End address must be greater than start address.") + accessor = self._plc._get_display_accessor(bank1) + return await accessor.read(start_display, end_display) + if end <= start: raise ValueError("End address must be greater than start address.") accessor = self._plc._get_accessor(bank1) @@ -618,7 +725,14 @@ def __init__( ) self._device_id = device_id self._accessors: dict[str, AddressAccessor[PlcValue]] = {} + self._display_accessors: dict[str, DisplayAddressAccessor[int]] = {} + self._upper_byte_accessors: dict[str, FixedAddressAccessor[int]] = { + "XD": FixedAddressAccessor(self, "XD", 1), + "YD": FixedAddressAccessor(self, "YD", 1), + } self.tags: dict[str, dict[str, str]] = {} + self.xdu = self._upper_byte_accessors["XD"] + self.ydu = self._upper_byte_accessors["YD"] self.addr = AddressInterface(self) self.tag = TagInterface(self) @@ -663,6 +777,12 @@ def _get_accessor(self, bank: str) -> AddressAccessor[PlcValue]: @overload def __getattr__(self, name: BoolBankAttr) -> AddressAccessor[bool]: ... + @overload + def __getattr__(self, name: DisplayBankAttr) -> DisplayAddressAccessor[int]: ... + + @overload + def __getattr__(self, name: UpperByteAttr) -> FixedAddressAccessor[int]: ... + @overload def __getattr__(self, name: IntBankAttr) -> AddressAccessor[int]: ... @@ -672,10 +792,24 @@ def __getattr__(self, name: FloatBankAttr) -> AddressAccessor[float]: ... @overload def __getattr__(self, name: StrBankAttr) -> AddressAccessor[str]: ... - def __getattr__(self, name: str) -> AddressAccessor[PlcValue]: + def _get_display_accessor(self, bank: str) -> DisplayAddressAccessor[int]: + bank_upper = bank.upper() + if bank_upper not in {"XD", "YD"}: + raise AttributeError(f"'{bank}' is not a supported display-indexed address type.") + if bank_upper not in self._display_accessors: + self._display_accessors[bank_upper] = DisplayAddressAccessor(self, bank_upper) + return self._display_accessors[bank_upper] + + def __getattr__( + self, name: str + ) -> AddressAccessor[PlcValue] | DisplayAddressAccessor[int] | FixedAddressAccessor[int]: if name.startswith("_"): raise AttributeError(name) upper = name.upper() + if upper in {"XDU", "YDU"}: + return self._upper_byte_accessors[upper[:2]] + if upper in {"XD", "YD"}: + return self._get_display_accessor(upper) if upper not in MODBUS_MAPPINGS: raise AttributeError(f"'{name}' is not a supported address type.") return self._get_accessor(upper) diff --git a/tests/test_client.py b/tests/test_client.py index 40f568b..3b558c1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,6 +16,8 @@ AddressAccessor, AddressInterface, ClickClient, + DisplayAddressAccessor, + FixedAddressAccessor, ModbusResponse, TagInterface, ) @@ -154,6 +156,24 @@ async def test_getattr_df(self): accessor = plc.df assert isinstance(accessor, AddressAccessor) + @pytest.mark.asyncio + async def test_getattr_xd_is_display_indexed_accessor(self): + plc = _make_plc() + assert isinstance(plc.xd, DisplayAddressAccessor) + + @pytest.mark.asyncio + async def test_getattr_yd_is_display_indexed_accessor(self): + plc = _make_plc() + assert isinstance(plc.yd, DisplayAddressAccessor) + + @pytest.mark.asyncio + async def test_upper_byte_aliases_are_available(self): + plc = _make_plc() + assert isinstance(plc.xdu, FixedAddressAccessor) + assert isinstance(plc.ydu, FixedAddressAccessor) + assert plc.XDU is plc.xdu + assert plc.YDU is plc.ydu + @pytest.mark.asyncio async def test_getattr_case_insensitive(self): plc = _make_plc() @@ -212,6 +232,16 @@ async def test_repr_x(self): plc = _make_plc() assert repr(plc.x) == "" + @pytest.mark.asyncio + async def test_repr_xd_display_accessor(self): + plc = _make_plc() + assert repr(plc.xd) == "" + + @pytest.mark.asyncio + async def test_repr_xdu_fixed_accessor(self): + plc = _make_plc() + assert repr(plc.xdu) == "" + # ============================================================================== # AddressAccessor — read single @@ -316,6 +346,91 @@ async def test_read_end_le_start_raises(self): await plc.df.read(10, 5) +# ============================================================================== +# DisplayAddressAccessor — XD/YD ergonomics +# ============================================================================== + + +class TestDisplayAddressAccessor: + @pytest.mark.asyncio + async def test_read_xd_display_single(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.xd.read(3) + assert result == {"XD3": 57350} + + @pytest.mark.asyncio + async def test_read_xd_display_range_has_no_hidden_slots(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.xd.read(0, 4) + assert list(result.keys()) == ["XD0", "XD1", "XD2", "XD3", "XD4"] + assert list(result.values()) == [57344, 57346, 57348, 57350, 57352] + + @pytest.mark.asyncio + async def test_write_yd_display_single(self): + plc = _make_plc() + await plc.yd.write(3, 0xABCD) + _get_write_registers_mock(plc).assert_called_once_with(57862, [0xABCD]) + + @pytest.mark.asyncio + async def test_write_yd_display_list(self): + plc = _make_plc() + await plc.yd.write(0, [1, 2, 3]) + assert _get_write_registers_mock(plc).call_count == 3 + assert _get_write_registers_mock(plc).call_args_list[0].args == (57856, [1]) + assert _get_write_registers_mock(plc).call_args_list[1].args == (57858, [2]) + assert _get_write_registers_mock(plc).call_args_list[2].args == (57860, [3]) + + @pytest.mark.asyncio + async def test_write_xd_display_not_writable(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.xd.write(1, 0x1234) + + @pytest.mark.asyncio + async def test_read_xd_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.xd.read(9) + + +# ============================================================================== +# FixedAddressAccessor — XD0u/YD0u +# ============================================================================== + + +class TestFixedAddressAccessor: + @pytest.mark.asyncio + async def test_read_xdu(self): + plc = _make_plc() + _set_read_registers(plc, [0x1234]) + result = await plc.xdu.read() + assert result == {"XD0u": 0x1234} + + @pytest.mark.asyncio + async def test_write_xdu_not_writable(self): + plc = _make_plc() + with pytest.raises(ValueError, match="not writable"): + await plc.xdu.write(0x1234) + + @pytest.mark.asyncio + async def test_write_ydu(self): + plc = _make_plc() + await plc.ydu.write(0x1234) + _get_write_registers_mock(plc).assert_called_once_with(57857, [0x1234]) + + # ============================================================================== # AddressAccessor — write # ============================================================================== @@ -528,6 +643,51 @@ async def test_end_le_start_raises(self): with pytest.raises(ValueError, match="greater than start"): await plc.addr.read("df10-df5") + @pytest.mark.asyncio + async def test_xd_range_is_display_step(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.addr.read("XD0-XD4") + assert list(result.keys()) == ["XD0", "XD1", "XD2", "XD3", "XD4"] + assert list(result.values()) == [57344, 57346, 57348, 57350, 57352] + + @pytest.mark.asyncio + async def test_yd_range_is_display_step(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + result = await plc.addr.read("YD0-YD2") + assert list(result.keys()) == ["YD0", "YD1", "YD2"] + assert list(result.values()) == [57856, 57858, 57860] + + @pytest.mark.asyncio + async def test_xd_range_rejects_upper_byte_start(self): + plc = _make_plc() + with pytest.raises(ValueError, match="upper-byte"): + await plc.addr.read("XD0u-XD1") + + @pytest.mark.asyncio + async def test_xd_range_rejects_upper_byte_end(self): + plc = _make_plc() + with pytest.raises(ValueError, match="upper-byte"): + await plc.addr.read("XD0-XD0u") + + @pytest.mark.asyncio + async def test_single_xd0u_read_still_works(self): + plc = _make_plc() + _set_read_registers(plc, [99]) + result = await plc.addr.read("XD0u") + assert result == {"XD0u": 99} + @pytest.mark.asyncio async def test_invalid_address_raises(self): plc = _make_plc() @@ -826,6 +986,18 @@ async def test_getitem_bool(self): value = await plc.c[1] assert value is True + @pytest.mark.asyncio + async def test_getitem_xd_is_display_indexed(self): + plc = _make_plc() + + async def fake_read_registers(address: int, count: int, bank: str) -> list[int]: + del bank + return [address + i for i in range(count)] + + object.__setattr__(plc, "_read_registers", AsyncMock(side_effect=fake_read_registers)) + value = await plc.xd[3] + assert value == 57350 + @pytest.mark.asyncio async def test_getitem_slice_raises(self): plc = _make_plc() @@ -837,3 +1009,9 @@ async def test_getitem_out_of_range_raises(self): plc = _make_plc() with pytest.raises(ValueError): await plc.df[0] + + @pytest.mark.asyncio + async def test_getitem_xd_out_of_range_raises(self): + plc = _make_plc() + with pytest.raises(ValueError): + await plc.xd[9] From 4f82420ca91ded296aab74eb18c60c5f05acf892 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:21:57 -0500 Subject: [PATCH 40/55] feat(validation): add is_system flag to validate_nickname Allow leading underscores for PLC system-generated nicknames (SC, SD, X banks). Adds SYSTEM_NICKNAME_TYPES constant so consumers like ClickNick can use it directly instead of maintaining their own workarounds. Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 8 +++++++- src/pyclickplc/validation.py | 11 +++++++++-- tests/test_validation.py | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index f29bf89..4f91f50 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -33,7 +33,12 @@ from .nicknames import AddressRecordMap, read_csv, write_csv from .server import ClickServer, MemoryDataProvider, ServerClientInfo from .server_tui import run_server_tui -from .validation import validate_comment, validate_initial_value, validate_nickname +from .validation import ( + SYSTEM_NICKNAME_TYPES, + validate_comment, + validate_initial_value, + validate_nickname, +) __all__ = [ "BankConfig", @@ -82,6 +87,7 @@ "read_csv", "AddressRecordMap", "write_csv", + "SYSTEM_NICKNAME_TYPES", "validate_nickname", "validate_comment", "validate_initial_value", diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py index 999b05c..67fba51 100644 --- a/src/pyclickplc/validation.py +++ b/src/pyclickplc/validation.py @@ -19,6 +19,11 @@ NICKNAME_MAX_LENGTH = 24 COMMENT_MAX_LENGTH = 128 +# Memory types whose nicknames may start with _ (PLC system-generated). +# SC/SD have system-preset names; X gets auto-generated feedback signals +# (e.g. _IO1_Module_Error) when analog input cards are installed. +SYSTEM_NICKNAME_TYPES: frozenset[str] = frozenset({"SC", "SD", "X"}) + # Characters forbidden in nicknames # Note: Space is allowed, hyphen (-) and period (.) are forbidden FORBIDDEN_CHARS: frozenset[str] = frozenset("%\"<>!#$&'()*+-./:;=?@[\\]^`{|}~") @@ -63,13 +68,15 @@ # ============================================================================== -def validate_nickname(nickname: str) -> tuple[bool, str]: +def validate_nickname(nickname: str, *, is_system: bool = False) -> tuple[bool, str]: """Validate nickname format (length, characters, reserved words). Does NOT check uniqueness -- that is application-specific. Args: nickname: The nickname to validate + is_system: If True, allow leading underscores (PLC system-generated names + for SC, SD, and X banks). Returns: Tuple of (is_valid, error_message) - error_message is "" if valid @@ -80,7 +87,7 @@ def validate_nickname(nickname: str) -> tuple[bool, str]: if len(nickname) > NICKNAME_MAX_LENGTH: return False, f"Too long ({len(nickname)}/24)" - if nickname.startswith("_"): + if nickname.startswith("_") and not is_system: return False, "Cannot start with _" if nickname.lower() in RESERVED_NICKNAMES: diff --git a/tests/test_validation.py b/tests/test_validation.py index 16c2b44..c9464c2 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -7,6 +7,7 @@ from pyclickplc.banks import DataType from pyclickplc.validation import ( FORBIDDEN_CHARS, + SYSTEM_NICKNAME_TYPES, assert_runtime_value, validate_comment, validate_initial_value, @@ -40,6 +41,24 @@ def test_starts_with_underscore(self): assert valid is False assert "Cannot start with _" in error + def test_system_allows_underscore(self): + assert validate_nickname("_IO1_Module_Error", is_system=True) == (True, "") + assert validate_nickname("_SystemName", is_system=True) == (True, "") + + def test_system_still_enforces_other_rules(self): + # Length still enforced for system nicknames + valid, error = validate_nickname("_" + "a" * 24, is_system=True) + assert valid is False + assert "Too long" in error + + # Reserved keywords still enforced + valid, error = validate_nickname("log", is_system=True) + assert valid is False + assert "Reserved" in error + + def test_system_nickname_types_constant(self): + assert SYSTEM_NICKNAME_TYPES == {"SC", "SD", "X"} + def test_reserved_keywords_case_insensitive(self): for keyword in ("log", "LOG", "Log", "sin", "SIN", "and", "or", "pi"): valid, error = validate_nickname(keyword) From bf05b0c2906895bafab0ac12fdba789c1e0fa419 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:21:57 -0500 Subject: [PATCH 41/55] feat(validation): add is_system flag to validate_nickname Allow leading underscores and forbidden characters for PLC system-generated nicknames (SC, SD, X banks). Length and reserved keyword checks still apply. Adds SYSTEM_NICKNAME_TYPES constant so consumers like ClickNick can use it directly instead of maintaining their own workarounds. Co-Authored-By: Claude Opus 4.6 --- src/pyclickplc/__init__.py | 8 +++++++- src/pyclickplc/validation.py | 25 +++++++++++++++++-------- tests/test_validation.py | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index f29bf89..4f91f50 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -33,7 +33,12 @@ from .nicknames import AddressRecordMap, read_csv, write_csv from .server import ClickServer, MemoryDataProvider, ServerClientInfo from .server_tui import run_server_tui -from .validation import validate_comment, validate_initial_value, validate_nickname +from .validation import ( + SYSTEM_NICKNAME_TYPES, + validate_comment, + validate_initial_value, + validate_nickname, +) __all__ = [ "BankConfig", @@ -82,6 +87,7 @@ "read_csv", "AddressRecordMap", "write_csv", + "SYSTEM_NICKNAME_TYPES", "validate_nickname", "validate_comment", "validate_initial_value", diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py index 999b05c..7036d84 100644 --- a/src/pyclickplc/validation.py +++ b/src/pyclickplc/validation.py @@ -19,6 +19,11 @@ NICKNAME_MAX_LENGTH = 24 COMMENT_MAX_LENGTH = 128 +# Memory types whose nicknames may start with _ (PLC system-generated). +# SC/SD have system-preset names; X gets auto-generated feedback signals +# (e.g. _IO1_Module_Error) when analog input cards are installed. +SYSTEM_NICKNAME_TYPES: frozenset[str] = frozenset({"SC", "SD", "X"}) + # Characters forbidden in nicknames # Note: Space is allowed, hyphen (-) and period (.) are forbidden FORBIDDEN_CHARS: frozenset[str] = frozenset("%\"<>!#$&'()*+-./:;=?@[\\]^`{|}~") @@ -63,13 +68,16 @@ # ============================================================================== -def validate_nickname(nickname: str) -> tuple[bool, str]: +def validate_nickname(nickname: str, *, is_system: bool = False) -> tuple[bool, str]: """Validate nickname format (length, characters, reserved words). Does NOT check uniqueness -- that is application-specific. Args: nickname: The nickname to validate + is_system: If True, skip leading underscore and forbidden character checks + (PLC system-generated names for SC, SD, and X banks). Length and + reserved keyword checks still apply. Returns: Tuple of (is_valid, error_message) - error_message is "" if valid @@ -80,17 +88,18 @@ def validate_nickname(nickname: str) -> tuple[bool, str]: if len(nickname) > NICKNAME_MAX_LENGTH: return False, f"Too long ({len(nickname)}/24)" - if nickname.startswith("_"): - return False, "Cannot start with _" + if not is_system: + if nickname.startswith("_"): + return False, "Cannot start with _" + + invalid_chars = set(nickname) & FORBIDDEN_CHARS + if invalid_chars: + chars_display = "".join(sorted(invalid_chars)[:3]) + return False, f"Invalid: {chars_display}" if nickname.lower() in RESERVED_NICKNAMES: return False, "Reserved keyword" - invalid_chars = set(nickname) & FORBIDDEN_CHARS - if invalid_chars: - chars_display = "".join(sorted(invalid_chars)[:3]) - return False, f"Invalid: {chars_display}" - return True, "" diff --git a/tests/test_validation.py b/tests/test_validation.py index 16c2b44..958283f 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -7,6 +7,7 @@ from pyclickplc.banks import DataType from pyclickplc.validation import ( FORBIDDEN_CHARS, + SYSTEM_NICKNAME_TYPES, assert_runtime_value, validate_comment, validate_initial_value, @@ -40,6 +41,30 @@ def test_starts_with_underscore(self): assert valid is False assert "Cannot start with _" in error + def test_system_allows_underscore(self): + assert validate_nickname("_IO1_Module_Error", is_system=True) == (True, "") + assert validate_nickname("_SystemName", is_system=True) == (True, "") + + def test_system_allows_forbidden_chars(self): + # SC nicknames contain / + assert validate_nickname("Comm/Port_1", is_system=True) == (True, "") + # SD nicknames contain ( and ) + assert validate_nickname("_Fixed_Scan_Time(ms)", is_system=True) == (True, "") + + def test_system_still_enforces_length_and_reserved(self): + # Length still enforced for system nicknames + valid, error = validate_nickname("_" + "a" * 24, is_system=True) + assert valid is False + assert "Too long" in error + + # Reserved keywords still enforced + valid, error = validate_nickname("log", is_system=True) + assert valid is False + assert "Reserved" in error + + def test_system_nickname_types_constant(self): + assert SYSTEM_NICKNAME_TYPES == {"SC", "SD", "X"} + def test_reserved_keywords_case_insensitive(self): for keyword in ("log", "LOG", "Log", "sin", "SIN", "and", "or", "pi"): valid, error = validate_nickname(keyword) From 73aeb56e02cefa7b6fbd158c9a56f85717e975bf Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:01:21 -0500 Subject: [PATCH 42/55] docs: clean up API surface, fix ghost exports, add constructor docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 12 non-existent capability exports from __all__, drop phantom capabilities module and internal modules (banks, modbus, blocks) from mkdocs nav, add __init__ docstrings to ClickClient/ClickServer/ModbusService, and fix uv install → uv add in README. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- mkdocs.yml | 11 +++-------- src/pyclickplc/__init__.py | 14 -------------- src/pyclickplc/client.py | 12 +++++++++++- src/pyclickplc/dataview.py | 16 ++++++++++------ src/pyclickplc/modbus_service.py | 12 +++++++++++- src/pyclickplc/server.py | 11 ++++++++++- tests/test_dataview.py | 1 - tests/test_modbus_service.py | 4 +--- tests/test_nicknames.py | 1 - 10 files changed, 47 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5e9a595..c168ada 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address hel ## Installation ```bash -uv install pyclickplc +uv add pyclickplc pip install pyclickplc ``` diff --git a/mkdocs.yml b/mkdocs.yml index b5251df..6d70deb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,17 +15,12 @@ nav: - API Reference: - Overview: reference/index.md - Modules: - - pyclickplc: reference/api/pyclickplc.md - - addresses: reference/api/addresses.md - - banks: reference/api/banks.md - - capabilities: reference/api/capabilities.md - - blocks: reference/api/blocks.md - client: reference/api/client.md - - dataview: reference/api/dataview.md - - modbus: reference/api/modbus.md - modbus_service: reference/api/modbus_service.md - - nicknames: reference/api/nicknames.md - server: reference/api/server.md + - addresses: reference/api/addresses.md + - nicknames: reference/api/nicknames.md + - dataview: reference/api/dataview.md - validation: reference/api/validation.md plugins: diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 4f91f50..5dc7d4d 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -50,19 +50,6 @@ "normalize_address", "ClickClient", "ModbusResponse", - "InstructionRole", - "CopyOperation", - "CompareConstantKind", - "BankCapability", - "ClickHardwareProfile", - "CLICK_HARDWARE_PROFILE", - "LADDER_WRITABLE_SC", - "LADDER_WRITABLE_SD", - "LADDER_BANK_CAPABILITIES", - "INSTRUCTION_ROLE_COMPATIBILITY", - "COPY_COMPATIBILITY", - "COMPARE_COMPATIBILITY", - "COMPARE_CONSTANT_COMPATIBILITY", "ModbusMapping", "plc_to_modbus", "modbus_to_plc", @@ -92,4 +79,3 @@ "validate_comment", "validate_initial_value", ] - diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index a370069..60813e7 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -706,6 +706,17 @@ def __init__( reconnect_delay: float = 0.0, reconnect_delay_max: float = 0.0, ) -> None: + """Create a ClickClient. + + Args: + host: PLC hostname or IP. Legacy ``"host:port"`` format is accepted. + port: Modbus TCP port (default 502). + tags: Optional tag name → AddressRecord mapping for the tag interface. + timeout: Connection timeout in seconds. + device_id: Modbus device/unit ID (0–247). + reconnect_delay: Initial reconnect delay in seconds (0 = no reconnect). + reconnect_delay_max: Maximum reconnect backoff in seconds. + """ # Backwards compatibility for legacy "host:port" first argument. if ":" in host and port == 502: host, port_str = host.rsplit(":", 1) @@ -876,4 +887,3 @@ async def _write_registers(self, address: int, values: list[int]) -> None: ) if result.isError(): raise OSError(f"Modbus write error at register {address}: {result}") - diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index cec7f60..3976b6e 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -697,8 +697,14 @@ def save(self, path: Path | str | None = None) -> None: new_storage_tokens.append(None) continue - data_type = row.data_type if row.data_type is not None else get_data_type_for_address(row.address) - cdv_code = _CdvStorageCode.INT if data_type is None else _DATA_TYPE_TO_CDV_CODE[data_type] + data_type = ( + row.data_type + if row.data_type is not None + else get_data_type_for_address(row.address) + ) + cdv_code = ( + _CdvStorageCode.INT if data_type is None else _DATA_TYPE_TO_CDV_CODE[data_type] + ) if row.new_value is not None: if data_type is None: @@ -772,6 +778,7 @@ def read_cdv(path: Path | str) -> DataviewFile: """Read a CDV file into a DataviewFile model.""" return DataviewFile.load(path) + def write_cdv(path: Path | str, dataview: DataviewFile) -> None: """Write a DataviewFile to a CDV path.""" dataview.save(path) @@ -876,9 +883,7 @@ def check_cdv_file(path: Path | str) -> list[str]: row_num = i + 1 raw_new_value = ( - dataview._row_storage_tokens[i] - if i < len(dataview._row_storage_tokens) - else None + dataview._row_storage_tokens[i] if i < len(dataview._row_storage_tokens) else None ) try: @@ -941,4 +946,3 @@ def verify_cdv( path=Path(path), ) return dataview.verify(path) - diff --git a/src/pyclickplc/modbus_service.py b/src/pyclickplc/modbus_service.py index 7b692af..ea1cb84 100644 --- a/src/pyclickplc/modbus_service.py +++ b/src/pyclickplc/modbus_service.py @@ -201,6 +201,14 @@ def __init__( on_state: Callable[[ConnectionState, Exception | None], None] | None = None, on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, ) -> None: + """Create a ModbusService. + + Args: + poll_interval_s: Seconds between poll cycles (must be > 0). + reconnect: Optional reconnect backoff configuration. + on_state: Callback fired on connection state changes. + on_values: Callback fired with polled values each cycle. + """ if poll_interval_s <= 0: raise ValueError("poll_interval_s must be > 0") @@ -405,7 +413,9 @@ async def _connect_async( candidate: ClickClient | None = None try: reconnect_delay = self._reconnect.delay_s if self._reconnect is not None else 0.0 - reconnect_delay_max = self._reconnect.max_delay_s if self._reconnect is not None else 0.0 + reconnect_delay_max = ( + self._reconnect.max_delay_s if self._reconnect is not None else 0.0 + ) candidate = ClickClient( host, port, diff --git a/src/pyclickplc/server.py b/src/pyclickplc/server.py index 2046f13..2814c78 100644 --- a/src/pyclickplc/server.py +++ b/src/pyclickplc/server.py @@ -361,6 +361,13 @@ def __init__( host: str = "localhost", port: int = 502, ) -> None: + """Create a ClickServer. + + Args: + provider: Backend that stores and retrieves PLC values. + host: Interface to bind (default ``"localhost"``). + port: Modbus TCP port (default 502). + """ self.provider = provider self.host = host self.port = port @@ -389,7 +396,9 @@ def list_clients(self) -> list[ServerClientInfo]: return [] clients: list[ServerClientInfo] = [] for client_id, connection in self._server.active_connections.items(): - clients.append(ServerClientInfo(client_id=client_id, peer=self._format_peer(connection))) + clients.append( + ServerClientInfo(client_id=client_id, peer=self._format_peer(connection)) + ) return clients def disconnect_client(self, client_id: str) -> bool: diff --git a/tests/test_dataview.py b/tests/test_dataview.py index f221411..76ca2cd 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -698,4 +698,3 @@ def test_check_cdv_file_non_writable_with_new_value(self, tmp_path): issues = check_cdv_file(cdv) assert len(issues) == 1 assert "not writable" in issues[0] - diff --git a/tests/test_modbus_service.py b/tests/test_modbus_service.py index 4f60107..9bb0b8f 100644 --- a/tests/test_modbus_service.py +++ b/tests/test_modbus_service.py @@ -153,9 +153,7 @@ def _wait_for(predicate, *, timeout: float = 1.0) -> None: def _service_threads() -> list[threading.Thread]: return [ - t - for t in threading.enumerate() - if t.name == "pyclickplc-modbus-service" and t.is_alive() + t for t in threading.enumerate() if t.name == "pyclickplc-modbus-service" and t.is_alive() ] diff --git a/tests/test_nicknames.py b/tests/test_nicknames.py index dc90fda..f209bc1 100644 --- a/tests/test_nicknames.py +++ b/tests/test_nicknames.py @@ -241,4 +241,3 @@ def test_roundtrip(self, tmp_path): assert ds_records[0].nickname == "Speed" assert ds_records[0].comment == "Motor speed" assert ds_records[0].retentive is True - From e4df14f37ebb6a4702fd94eaf10419cfc5c02e9e Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:25:11 -0500 Subject: [PATCH 43/55] prep for initial release: fix metadata, refactor tag.read_all - Bump classifier to Beta, add Python 3.14, remove pyrung entry point - Clear changelog for initial release - tag.read() now requires a tag name; tag.read_all(include_system=False) reads all tags, skipping SC/SD system banks by default - README: split tag example, clarify ModbusResponse, drop unpublished docs link Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 20 -------------------- README.md | 37 +++++++++++++++++++++++-------------- pyproject.toml | 10 ++++------ src/pyclickplc/client.py | 29 ++++++++++++++++++----------- tests/test_client.py | 38 +++++++++++++++++++++++++++----------- 5 files changed, 72 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d4615..825c32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1 @@ # Changelog - -## 2026-02-21 - -### Breaking Changes -- `ClickClient` now accepts `tags: Mapping[str, AddressRecord] | None` and no longer accepts `tag_filepath`. -- Renamed `DataviewRow` to `DataViewRecord` across API exports and dataview helpers. - -### Notes -- `read_csv` now returns `AddressRecordMap`, which is `dict[int, AddressRecord]` compatible and adds - `records.addr[...]` (normalized address lookup) and `records.tag[...]` (case-insensitive nickname lookup). -- CSV nickname loading now rejects case-insensitive collisions (`nickname.lower()` conflicts). - -## 2026-02-13 - -### Breaking Changes -- Removed `read_mdb_csv` from `pyclickplc` public API (`pyclickplc` and `pyclickplc.nicknames`). -- Removed `export_cdv`, `get_dataview_folder`, and `list_cdv_files` from `pyclickplc` public API (`pyclickplc` and `pyclickplc.dataview`). - -### Notes -- `load_cdv` and `save_cdv` remain in `pyclickplc.dataview`. diff --git a/README.md b/README.md index c168ada..e6319cc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address hel ```bash uv add pyclickplc +# or pip install pyclickplc ``` @@ -34,20 +35,32 @@ async def main(): by_addr = await plc.addr.read("df1") yd_display = await plc.addr.read("YD0-YD8") # display-step range for XD/YD - # Tag interface (programmatic tags) - tags = { - "temp_source": AddressRecord(memory_type="DF", address=1, nickname="MyTag"), - } - async with ClickClient("192.168.1.10", 502, tags=tags) as tagged: - await tagged.tag.write("MyTag", 42) - tag_value = await tagged.tag.read("mytag") # case-insensitive - all_tag_values = await tagged.tag.read() - tag_defs = tagged.tag.read_all() # synchronous tag metadata +asyncio.run(main()) +``` + +Tags let you reference addresses by nickname: + +```python +from pyclickplc import AddressRecord, ClickClient + +tags = { + "temp_source": AddressRecord(memory_type="DF", address=1, nickname="MyTag"), +} + +async def main(): + async with ClickClient("192.168.1.10", 502, tags=tags) as plc: + await plc.tag.write("MyTag", 42) + tag_value = await plc.tag.read("mytag") # case-insensitive + all_tag_values = await plc.tag.read_all() # excludes SC/SD by default asyncio.run(main()) ``` -All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`). Lookups are normalized (`resp["ds1"]` resolves `"DS1"`). Use `await plc.ds[1]` for a bare value. `plc.xd`/`plc.yd` are display-indexed (`0..8`) with `plc.xdu`/`plc.ydu` aliases for `XD0u`/`YD0u`. +All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`): + +- Lookups are normalized: `resp["ds1"]` resolves `"DS1"` +- `await plc.ds[1]` returns a bare value instead of a mapping +- `plc.xd`/`plc.yd` are display-indexed (`0..8`); `plc.xdu`/`plc.ydu` are aliases for `XD0u`/`YD0u` ## ModbusService (Sync + Polling) @@ -191,10 +204,6 @@ display = format_address_display("XD", 1) # "XD0u" normalized = normalize_address("x1") # "X001" ``` -Note: address helper functions remain MDB-oriented for XD/YD internals (`parse_address("XD3")` returns MDB index `6`). - -Full API reference is available via MkDocs (including advanced modules). - ## Development ```bash diff --git a/pyproject.toml b/pyproject.toml index a1b0011..6bd64ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,7 @@ dynamic = ["version"] # Adjust as needed: classifiers = [ # Adjust as needed: - "Development Status :: 1 - Planning", - #"Development Status :: 4 - Beta", - # "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", @@ -30,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", # Include this to avoid accidentally publishing to PyPI: # "Private :: Do Not Upload", @@ -62,9 +61,8 @@ docs = [ "mkdocs-gen-files>=0.5.0", ] -[project.scripts] -# Add script entry points here: -pyrung = "pyclickplc:main" +# [project.scripts] +# pyrung entry point lives in the pyrung package, not here. # ---- Build system ---- diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 60813e7..82fb075 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -651,21 +651,31 @@ def _resolve_tag_name(tags: Mapping[str, dict[str, str]], tag_name: str) -> str: ) raise KeyError(f"Tag '{tag_name}' not found. Available: {available}") - async def read(self, tag_name: str | None = None) -> dict[str, PlcValue] | PlcValue: - """Read single tag or all tags.""" + async def read(self, tag_name: str) -> PlcValue: + """Read a single tag value by name (case-insensitive).""" tags = self._plc.tags - if tag_name is not None: - resolved_name = self._resolve_tag_name(tags, tag_name) - tag_info = tags[resolved_name] - resp = await self._plc.addr.read(tag_info["address"]) - # Single-address read → extract the lone value - return next(iter(resp.values())) + resolved_name = self._resolve_tag_name(tags, tag_name) + tag_info = tags[resolved_name] + resp = await self._plc.addr.read(tag_info["address"]) + return next(iter(resp.values())) + + async def read_all(self, *, include_system: bool = False) -> dict[str, PlcValue]: + """Read all tag values. + Args: + include_system: If False (default), skip SC/SD system banks. + """ + tags = self._plc.tags if not tags: raise ValueError("No tags loaded. Provide tags to ClickClient or specify a tag name.") + system_banks = frozenset({"SC", "SD"}) all_tags: dict[str, PlcValue] = {} for name, info in tags.items(): + if not include_system: + bank, _ = parse_address(info["address"]) + if bank in system_banks: + continue resp = await self._plc.addr.read(info["address"]) all_tags[name] = next(iter(resp.values())) return all_tags @@ -681,9 +691,6 @@ async def write( tag_info = tags[resolved_name] await self._plc.addr.write(tag_info["address"], data) - def read_all(self) -> dict[str, dict[str, str]]: - """Return a copy of all tag definitions (synchronous).""" - return dict(self._plc.tags) # ============================================================================== diff --git a/tests/test_client.py b/tests/test_client.py index 3b558c1..9962bb2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -753,7 +753,7 @@ async def test_read_all_tags(self): regs = pack_value(25.0, DataType.FLOAT) _set_read_registers(plc, regs) _set_read_coils(plc, [True]) - result = await plc.tag.read() + result = await plc.tag.read_all() assert isinstance(result, dict) assert "Temp" in result assert "Valve" in result @@ -762,7 +762,32 @@ async def test_read_all_tags(self): async def test_read_all_no_tags_raises(self): plc = _make_plc() with pytest.raises(ValueError, match="No tags loaded"): - await plc.tag.read() + await plc.tag.read_all() + + @pytest.mark.asyncio + async def test_read_all_excludes_system_by_default(self): + plc = self._plc_with_tags() + plc.tags["SysFlag"] = {"address": "SC1", "type": "BIT", "comment": "System"} + plc.tags["SysData"] = {"address": "SD1", "type": "INT", "comment": "System"} + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) + result = await plc.tag.read_all() + assert "Temp" in result + assert "Valve" in result + assert "SysFlag" not in result + assert "SysData" not in result + + @pytest.mark.asyncio + async def test_read_all_includes_system_when_requested(self): + plc = self._plc_with_tags() + plc.tags["SysFlag"] = {"address": "SC1", "type": "BIT", "comment": "System"} + regs = pack_value(25.0, DataType.FLOAT) + _set_read_registers(plc, regs) + _set_read_coils(plc, [True]) + result = await plc.tag.read_all(include_system=True) + assert "Temp" in result + assert "SysFlag" in result @pytest.mark.asyncio async def test_write_tag(self): @@ -782,15 +807,6 @@ async def test_write_tag_case_insensitive(self): await plc.tag.write("temp", 30.0) _get_write_registers_mock(plc).assert_called_once() - @pytest.mark.asyncio - async def test_read_all_definitions(self): - plc = self._plc_with_tags() - result = plc.tag.read_all() - assert "Temp" in result - assert result["Temp"]["address"] == "DF1" - # Should be a copy - result["New"] = {"address": "DS1"} - assert "New" not in plc.tags # ============================================================================== From 0f90cd236a1611ae1a52ecf90deb1ce630992d77 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:37:23 -0500 Subject: [PATCH 44/55] chore: remove non-needed spec and scratchpad --- scratchpad/client-server-min-max-plan.md | 94 ---- scratchpad/dataview-unify-plan.md | 161 ------ scratchpad/modbus-service-plan.md | 153 ------ spec/ARCHITECTURE.md | 445 ---------------- spec/CLICKDEVICE_SPEC.md | 633 ----------------------- spec/CLICKSERVER_SPEC.md | 628 ---------------------- 6 files changed, 2114 deletions(-) delete mode 100644 scratchpad/client-server-min-max-plan.md delete mode 100644 scratchpad/dataview-unify-plan.md delete mode 100644 scratchpad/modbus-service-plan.md delete mode 100644 spec/ARCHITECTURE.md delete mode 100644 spec/CLICKDEVICE_SPEC.md delete mode 100644 spec/CLICKSERVER_SPEC.md diff --git a/scratchpad/client-server-min-max-plan.md b/scratchpad/client-server-min-max-plan.md deleted file mode 100644 index 9ddc3c7..0000000 --- a/scratchpad/client-server-min-max-plan.md +++ /dev/null @@ -1,94 +0,0 @@ -# Client + Server Min/Max Validation Plan - -## Goal - -Enforce one runtime write contract across pyclickplc: - -- Reject invalid values on client writes. -- Reject invalid values in `MemoryDataProvider.write()` / `set()`. -- Do not add a permissive or compatibility mode. - -This project is unreleased, so we optimize for a clean contract instead of migration paths. - -## Decision Summary - -- `HEX` uses IEC WORD semantics: unsigned 16-bit `0..65535` (`0x0000..0xFFFF`). -- Numeric bank validation is explicit (not only implicit `struct.pack` failures). -- Bool-as-int is rejected for numeric banks (`DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD`, `DF`). -- `DF` rejects non-finite values (`NaN`, `+Inf`, `-Inf`) and values not representable in float32. -- `TXT` allows blank (`""`) or a single ASCII character (including space `" "`). - -## Implementation Plan - -1. Add shared runtime value validation helper -- File: `src/pyclickplc/validation.py` -- Add an assertion-style API for runtime write validation keyed by `DataType`. -- Keep existing nickname/comment/initial-value validation intact. - -2. Use shared validation in client write path -- File: `src/pyclickplc/client.py` -- Replace `_validate_type()` with `_validate_value()` (type + range + format). -- Keep existing index and writability checks unchanged. -- Raise deterministic `ValueError` messages for invalid values. - -3. Validate in `MemoryDataProvider` -- File: `src/pyclickplc/server.py` -- In `write()`, normalize address, resolve bank/type, validate value, then store. -- `set()` and `bulk_set()` inherit strict validation through `write()`. -- No constructor flags for permissive behavior. - -4. Keep Modbus server writability behavior unchanged -- `SC` and `SD` writability remains enforced by server request handling. -- Provider validation is value-centric, not Modbus function-code authorization. - -## Runtime Validation Matrix - -- `BIT`: `type(value) is bool` -- `INT` (`DS`, `TD`, `SD`): `type(value) is int`, range `-32768..32767` -- `INT2` (`DD`, `CTD`): `type(value) is int`, range `-2147483648..2147483647` -- `HEX` (`DH`, `XD`, `YD`): `type(value) is int`, range `0..65535` -- `FLOAT` (`DF`): numeric (`int`/`float`, not bool), finite, packable as float32 -- `TXT`: `type(value) is str`, and either: - - blank string `""`, or - - length `1` ASCII char (`ord <= 127`) - -## Tests To Add/Update - -1. Client write rejections -- `plc.ds.write(1, 32768)` -> `ValueError` -- `plc.dd.write(1, 2147483648)` -> `ValueError` -- `plc.dh.write(1, -1)` -> `ValueError` -- `plc.ds.write(1, True)` -> `ValueError` -- `plc.df.write(1, float("nan"))` -> `ValueError` -- `plc.df.write(1, float("inf"))` -> `ValueError` -- `plc.txt.write(1, "AB")` -> `ValueError` -- `plc.txt.write(1, "\\u00E9")` -> `ValueError` -- `plc.txt.write(1, "")` -> succeeds (writes NUL) - -2. MemoryDataProvider rejections -- `set("DS1", 999999999999)` -> `ValueError` -- `set("DF1", "abc")` -> `ValueError` -- `set("TXT1", "AB")` -> `ValueError` -- `set("DH1", -1)` -> `ValueError` - -3. Regression coverage -- Existing valid read/write tests remain green. -- Existing Modbus mapping/pack/unpack tests remain green. - -## Spec/Doc Updates - -- `spec/CLICKSERVER_SPEC.md` - - Add strict runtime value validation semantics for `MemoryDataProvider`. - - Clarify that DataProvider exceptions include validation errors and map to `SlaveDeviceFailure`. - -- `spec/CLICKDEVICE_SPEC.md` - - Add explicit runtime value-range rules for write APIs. - - Document WORD range for `HEX` (`0..65535`). - - Add strict invalid-value scenarios. - -## Acceptance Criteria - -- Invalid runtime write values fail consistently in both client API and `MemoryDataProvider`. -- Errors are `ValueError` with actionable messages. -- No permissive mode exists in core API. -- Spec docs clearly describe runtime validation behavior and limits. diff --git a/scratchpad/dataview-unify-plan.md b/scratchpad/dataview-unify-plan.md deleted file mode 100644 index 69ae470..0000000 --- a/scratchpad/dataview-unify-plan.md +++ /dev/null @@ -1,161 +0,0 @@ -# Dataview: Unify on DataType + Add New Value UI Support - -## Context - -ClickNick needs pyclickplc's dataview module to support live DataView editing with: -- **New Value** column: user-typed display strings, validated before acceptance -- **Live** column: values read via ClickClient, formatted for display -- Both columns use `datatype_to_display()` for rendering - -Currently `DataviewRow.type_code` stores CDV file-format integers (`_CdvStorageCode`: 768, 0, 256...), -which leak a file-format detail into the data model. The `DataType` enum in `banks.py` is the canonical -type system. This refactor unifies on `DataType` and adds the convenience API clicknick needs. - ---- - -## Part 1: Replace `type_code` with `DataType` - -### 1a. Add private CDV code ↔ DataType bridge dicts - -In `dataview.py`, keep `_CdvStorageCode` but add two private mappings used only by `load_cdv`/`save_cdv`: - -```python -_CDV_CODE_TO_DATA_TYPE: dict[int, DataType] = { - _CdvStorageCode.BIT: DataType.BIT, - _CdvStorageCode.INT: DataType.INT, - _CdvStorageCode.INT2: DataType.INT2, - _CdvStorageCode.HEX: DataType.HEX, - _CdvStorageCode.FLOAT: DataType.FLOAT, - _CdvStorageCode.TXT: DataType.TXT, -} -_DATA_TYPE_TO_CDV_CODE: dict[DataType, int] = {v: k for k, v in _CDV_CODE_TO_DATA_TYPE.items()} -``` - -### 1b. `DataviewRow.type_code: int` → `DataviewRow.data_type: DataType | None` - -- Field becomes `data_type: DataType | None = None` (None for empty rows) -- Remove `update_type_code()` method → replace with `update_data_type()` that uses `get_data_type_for_address()` -- `clear()` sets `data_type = None` - -### 1c. Rename `get_type_code_for_address()` → `get_data_type_for_address()` - -Returns `DataType | None`. Implementation: `parse_address()` → memory_type → `MEMORY_TYPE_TO_DATA_TYPE[memory_type]` (from `banks.py`, already exists). - -Remove `MEMORY_TYPE_TO_CODE` dict (replaced by `banks.MEMORY_TYPE_TO_DATA_TYPE`). -Remove `CODE_TO_MEMORY_TYPES` dict (unused outside of this mapping). - -### 1d. Conversion functions dispatch on `DataType` - -All four functions change signature from `type_code: int` to `data_type: DataType`: -- `storage_to_datatype(value, data_type)` — dispatch on `DataType.BIT`, `DataType.INT`, etc. -- `datatype_to_storage(value, data_type)` -- `datatype_to_display(value, data_type)` -- `display_to_datatype(value, data_type)` - -The switch statements change from `_CdvStorageCode.BIT` → `DataType.BIT` etc. - -### 1e. `_validate_cdv_new_value` dispatches on `DataType` - -Signature: `_validate_cdv_new_value(new_value, data_type, address, filename, row_num)`. - -### 1f. `load_cdv` / `save_cdv` handle CDV code conversion at the boundary - -- `load_cdv`: reads CDV integer → `_CDV_CODE_TO_DATA_TYPE[code]` → stores `DataType` on row -- `save_cdv`: reads `row.data_type` → `_DATA_TYPE_TO_CDV_CODE[data_type]` → writes CDV integer - -### 1g. `check_cdv_file` uses `DataType` - -Calls `get_data_type_for_address()` instead of `get_type_code_for_address()`. - ---- - -## Part 2: Add New Value Convenience API - -### 2a. `DataviewRow.new_value_display` property - -```python -@property -def new_value_display(self) -> str: - if not self.new_value or self.data_type is None: - return "" - native = storage_to_datatype(self.new_value, self.data_type) - return datatype_to_display(native, self.data_type) -``` - -### 2b. `DataviewRow.set_new_value_from_display(display_str)` method - -```python -def set_new_value_from_display(self, display_str: str) -> bool: - if not display_str: - self.new_value = "" - return True - if self.data_type is None: - return False - native = display_to_datatype(display_str, self.data_type) - if native is None: - return False - self.new_value = datatype_to_storage(native, self.data_type) - return True -``` - -### 2c. Standalone `validate_new_value()` function - -```python -def validate_new_value(display_str: str, data_type: DataType) -> tuple[bool, str]: - """Validate a user-entered display string for the New Value column.""" - if not display_str: - return True, "" - return validate_initial_value(display_str, data_type) -``` - -### 2d. `DataviewRow.validate_new_value()` method - -```python -def validate_new_value(self, display_str: str) -> tuple[bool, str]: - if not self.is_writable: - return False, "Read-only address" - if self.data_type is None: - return False, "No address set" - return validate_new_value(display_str, self.data_type) -``` - ---- - -## Part 3: Exports & Tests - -### 3a. `__init__.py` — add exports - -New public API: -- `get_data_type_for_address` -- `validate_new_value` -- `storage_to_datatype`, `datatype_to_storage`, `datatype_to_display`, `display_to_datatype` - -### 3b. `test_dataview.py` — update all tests - -- `TypeCode.BIT` → `DataType.BIT` etc. throughout -- `.type_code` → `.data_type` on all DataviewRow assertions -- Add new test classes: - - `TestValidateNewValue` — standalone function - - `TestDataviewRowValidateNewValue` — method on row - - `TestNewValueDisplay` — property - - `TestSetNewValueFromDisplay` — method - ---- - -## Files Modified - -| File | Summary | -|------|---------| -| `src/pyclickplc/dataview.py` | Core refactor + new API | -| `src/pyclickplc/__init__.py` | New exports | -| `tests/test_dataview.py` | Update all tests + new test classes | - ---- - -## Verification - -1. `make lint` — ruff + ty pass -2. `make test` — all tests pass (except 2 pre-existing T-bank failures in test_modbus.py) -3. Spot-check: `DataviewRow(address="DS1")` → `.update_data_type()` → `.data_type == DataType.INT` -4. Round-trip: `set_new_value_from_display("100")` → `.new_value_display == "100"` -5. Validation: `row.validate_new_value("abc")` → `(False, "Must be integer")` diff --git a/scratchpad/modbus-service-plan.md b/scratchpad/modbus-service-plan.md deleted file mode 100644 index eb5d9ff..0000000 --- a/scratchpad/modbus-service-plan.md +++ /dev/null @@ -1,153 +0,0 @@ -# ModbusService Plan (pyclickplc) - -## Goal - -Add a `pyclickplc`-owned `ModbusService` that gives synchronous/UI callers a simple API for: - -- live polling of a dynamic address set -- bulk writes of address/value pairs -- automatic batching and Modbus-efficient execution - -This service must match existing `pyclickplc` ergonomics and error semantics used by `ClickClient` and `ClickServer`. - -## Ergonomics Contract - -Use the same conventions already established by `ClickClient`: - -- Address inputs accept normal display strings and are canonicalized (`normalize_address`). -- Invalid addresses / invalid values / non-writable writes fail with `ValueError`. -- Modbus transport/protocol failures fail with `OSError`. -- Read results are normalized mappings keyed by canonical uppercase addresses. -- API names stay action-oriented and explicit (`read`, `write`, `connect`, `disconnect`). - -## Public API - -New module: `src/pyclickplc/modbus_service.py` - -```python -from collections.abc import Callable, Iterable, Mapping -from enum import Enum - -PlcValue = bool | int | float | str - -class ConnectionState(Enum): - DISCONNECTED = "disconnected" - CONNECTING = "connecting" - CONNECTED = "connected" - ERROR = "error" - -class WriteResult(TypedDict): - address: str - ok: bool - error: str | None - -class ModbusService: - def __init__( - self, - poll_interval_s: float = 1.5, - on_state: Callable[[ConnectionState, Exception | None], None] | None = None, - on_values: Callable[[ModbusResponse[PlcValue]], None] | None = None, - ) -> None: ... - - # Lifecycle - def connect(self, host: str, port: int = 502, *, device_id: int = 1, timeout: int = 1) -> None: ... - def disconnect(self) -> None: ... - - # Poll configuration (replace semantics) - def set_poll_addresses(self, addresses: Iterable[str]) -> None: ... - def clear_poll_addresses(self) -> None: ... - def stop_polling(self) -> None: ... - - # Sync convenience operations - def read(self, addresses: Iterable[str]) -> ModbusResponse[PlcValue]: ... - def write(self, values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]]) -> list[WriteResult]: ... -``` - -Notes: - -- `set_poll_addresses(...)` means "replace current poll set", not incremental subscribe. -- `write(...)` accepts either mapping or iterable for ergonomic parity with existing APIs. -- `write(...)` returns per-address outcomes to support partial success reporting in UIs. - -## Internal Design - -1. Thread + event loop bridge -- One background daemon thread owns one asyncio event loop. -- A readiness event gates scheduling work until loop is initialized. -- `connect()`/`disconnect()`/`set_poll_addresses()`/`read()`/`write()` schedule coroutines with thread-safe submission and wait only when required (`read`, `write`). - -2. Client ownership -- Service owns one `ClickClient` instance while connected. -- Use async context lifecycle semantics equivalent to `ClickClient.__aenter__/__aexit__`. - -3. Polling model -- Poll loop runs only when connected and poll set is non-empty. -- Each cycle reads current poll set, performs bank-batched reads, emits one merged `ModbusResponse`. -- `set_poll_addresses(...)` is atomic replacement and takes effect next poll cycle. - -4. Batch planning -- Parse/normalize addresses once per plan update. -- Group by bank. -- Build contiguous spans where efficient and legal. -- Respect sparse bank behavior (`X`/`Y`) and width-2 banks (`DD`/`DF`/`CTD`). -- Respect Modbus limits per call (max coil/register count). - -5. Write execution -- Normalize/validate each address and value using existing `ClickClient` semantics. -- Coalesce consecutive writes by bank/type where safe; fallback to per-address writes when needed. -- Return `WriteResult` per requested address in original input order. - -6. Callback delivery -- `on_state` and `on_values` are invoked from the service thread. -- GUI consumers must marshal to UI thread (`widget.after(...)` in Tk). - -## Integration Points - -1. Exports -- Add `ModbusService`, `ConnectionState`, and `WriteResult` to `src/pyclickplc/__init__.py`. - -2. Docs -- Update `README.md` quickstart with one `ModbusService` polling/write example. -- Add `docs/guides/modbus_service.md`. -- Link from `docs/index.md` and include error semantics. - -## Tests - -New test file: `tests/test_modbus_service.py` - -1. Lifecycle/state -- DISCONNECTED -> CONNECTING -> CONNECTED -> DISCONNECTED. -- connection failure -> ERROR with callback payload. - -2. Poll configuration -- `set_poll_addresses(...)` replaces set. -- `clear_poll_addresses()` empties set. -- polling stops when no addresses. - -3. Read behavior -- `read([...])` returns `ModbusResponse` with canonical keys. -- invalid addresses raise `ValueError`. -- transport errors raise `OSError`. - -4. Write behavior -- accepts mapping and iterable inputs. -- non-writable address result marked failed (`ok=False`, error message). -- invalid value gives failed result and does not crash service. -- transport failure produces failed result with error text. - -5. Batching heuristics -- group-by-bank and contiguous range choices are deterministic. -- sparse bank handling does not bridge invalid gaps incorrectly. -- width-2 banks honor register width when building spans. - -6. Thread safety -- concurrent `set_poll_addresses(...)` during poll does not crash. -- `disconnect()` during active poll exits cleanly. - -## Acceptance Criteria - -- `ModbusService` API is stable and documented in `pyclickplc`. -- Read/write behavior matches `ClickClient` conventions (normalization + exceptions). -- Poll set replacement via `set_poll_addresses(...)` works without reconnecting. -- Write results provide per-address outcome for UI-level reporting. -- New tests pass under `make test`. diff --git a/spec/ARCHITECTURE.md b/spec/ARCHITECTURE.md deleted file mode 100644 index 06fbc03..0000000 --- a/spec/ARCHITECTURE.md +++ /dev/null @@ -1,445 +0,0 @@ -# pyclickplc Architecture Plan - -High-level plan for the `pyclickplc` package — shared CLICK PLC knowledge consumed by ClickNick (GUI editor), pyrung (simulation), and standalone Modbus client/server tooling. - ---- - -## What the Package Does - -| Capability | Consumer(s) | -|------------|-------------| -| PLC bank definitions & data types | All | -| Address parsing & formatting | All | -| Nickname CSV read/write | ClickNick, pyrung | -| BlockTag parsing & computation | ClickNick, pyrung | -| DataView CDV file I/O | ClickNick | -| Nickname/field validation | ClickNick | -| Modbus client (ClickClient) | pyrung, standalone | -| Modbus server (ClickServer) | pyrung (testing), standalone | - ---- - -## Module Layout - -``` -pyclickplc/ -├── __init__.py # Public API re-exports -├── banks.py # Bank definitions, DataType enum, address ranges -├── addresses.py # Address parsing, formatting, AddressRecord -├── validation.py # CLICK field validation rules -├── blocks.py # BlockTag parsing, BlockRange, MemoryBankMeta -├── nicknames.py # Nickname CSV read/write -├── dataview.py # DataView .cdv file I/O -├── modbus.py # Modbus mapping, register packing, sparse logic -├── client.py # ClickClient (Modbus TCP client) -└── server.py # ClickServer (Modbus TCP server) -``` - ---- - -## Module Responsibilities - -### `banks.py` — Foundation (zero dependencies) - -The single source of truth for "what memory banks exist in a CLICK PLC." - -From ClickNick extraction: -- `DataType` enum (`BIT`, `INT`, `INT2`, `FLOAT`, `HEX`, `TXT`) -- `ADDRESS_RANGES` — PLC address ranges per bank -- `MEMORY_TYPE_BASES` — unique key offsets per bank (for `addr_key`) -- `MEMORY_TYPE_TO_DATA_TYPE` — which DataType each bank uses -- `DATA_TYPE_DISPLAY` / `DATA_TYPE_HINTS` — display strings -- `DEFAULT_RETENTIVE` — retentive defaults per bank -- `INTERLEAVED_PAIRS` — T↔TD, CT↔CTD pairing -- `BIT_ONLY_TYPES` — banks that are coil-only - -New: -- `BankConfig` frozen dataclass combining range + data type + properties into one object -- Dict of all `BankConfig` instances keyed by bank name -- **Sparse addressing as PLC knowledge** — which X/Y addresses are valid is a hardware fact, not a protocol detail. `BankConfig` expresses valid sub-ranges for sparse banks so that *both* ClickNick (display filtering) and the Modbus layer (coil mapping) use the same source of truth. - -This module has **no** Modbus knowledge. It describes the PLC, not the protocol. - -### `addresses.py` — Address Parsing & Formatting - -Depends on: `banks.py` - -The one parser that everyone uses. Handles all PLC address string operations. - -From ClickNick extraction: -- `get_addr_key(memory_type, address)` → unique int key -- `parse_addr_key(addr_key)` → `(memory_type, address)` -- `format_address_display(memory_type, address)` → `"X001"`, `"DS100"` -- `parse_address(display_str)` → `(memory_type, mdb_address)` — strict, raises ValueError -- `normalize_address(address_str)` → canonical form -- XD/YD helpers (`is_xd_yd_upper_byte`, etc.) -- `AddressRecord` frozen dataclass (shared between ClickNick and pyrung) - -One unified `parse_address` returns MDB indices for all banks (including XD/YD). Used everywhere: Modbus layer, client, server, nicknames, dataview. - -### `validation.py` — CLICK Validation Rules - -Depends on: `banks.py` - -From ClickNick extraction: -- `NICKNAME_MAX_LENGTH`, `COMMENT_MAX_LENGTH` -- `FORBIDDEN_CHARS`, `RESERVED_NICKNAMES` -- Numeric range constants (`INT_MIN/MAX`, etc.) -- `validate_nickname(name)` → `(valid, error)` -- `validate_initial_value(value, data_type)` → `(valid, error)` -- `validate_comment(comment)` → `(valid, error)` - -### `blocks.py` — BlockTag System - -Depends on: `banks.py`, `addresses.py` - -From ClickNick extraction (post Phase 0 simplification): -- `BlockTag` dataclass -- `BlockRange` dataclass -- `MemoryBankMeta` dataclass -- Parsing: `parse_block_tag`, `format_block_tag`, `extract_block_name`, etc. -- Computation: `compute_all_block_ranges`, `find_paired_tag_index` -- Validation: `validate_block_span` -- `extract_bank_metas(records)` → discovered bank metadata - -### `nicknames.py` — CSV Read/Write - -Depends on: `banks.py`, `addresses.py`, `blocks.py`, `validation.py` - -From ClickNick extraction: -- `CSV_COLUMNS`, `ADDRESS_PATTERN` -- `DATA_TYPE_STR_TO_CODE` / `DATA_TYPE_CODE_TO_STR` -- `read_csv(path)` → `dict[int, AddressRecord]` -- `write_csv(path, records)` → count - -### `dataview.py` — DataView CDV File I/O - -Depends on: `banks.py`, `addresses.py` - -From ClickNick extraction (`cdv_file.py`): -- `read_cdv(path)` → list of DataView rows -- `write_cdv(path, rows)` → None -- CDV format details: UTF-16 LE CSV with specific column layout - -### `modbus.py` — Modbus Protocol Mapping - -Depends on: `banks.py`, `addresses.py` - -New code (from CLICKDEVICE_SPEC / CLICKSERVER_SPEC): -- `ModbusMapping` frozen dataclass — Modbus-specific properties per bank: - - `base: int` — Modbus base address - - `is_coil: bool` — coil vs register - - `width: int` — registers per value (2 for float/int32) - - `signed: bool` - - `writable: frozenset[int] | None` -- `MODBUS_MAPPINGS: dict[str, ModbusMapping]` — all bank mappings (including XD/YD once verified) -- Forward mapping: `plc_to_modbus(bank, index)` → Modbus address -- Reverse mapping: `modbus_to_plc(address, is_coil)` → `(bank, index)` or `None` -- Register packing: `pack_value(value, data_type)` → `list[int]` -- Register unpacking: `unpack_value(registers, data_type)` → value -- Sparse coil offset calculation (uses `valid_ranges` from `BankConfig` for the slot structure, adds the Modbus-specific offset math) -- Text register handling (packed ASCII with byte-swap) -- `STRUCT_FORMATS` constant - -Note: Sparse addressing *validity* (which addresses exist) is in `banks.py`. Sparse *Modbus offset calculation* (mapping valid addresses to sequential coil numbers) is here. - -### `client.py` — ClickClient - -Depends on: `modbus.py`, `nicknames.py` (optional, for tag loading) - -From CLICKDEVICE_SPEC: -- `ClickClient` class (async Modbus TCP client) -- `AddressAccessor` — `plc.df.read(1)` -- `AddressInterface` — `plc.addr.read('df1')` -- `TagInterface` — `plc.tag.read('MyTag')` -- Context manager, tag CSV loading - -### `server.py` — ClickServer - -Depends on: `modbus.py` - -From CLICKSERVER_SPEC: -- `DataProvider` protocol -- `MemoryDataProvider` reference implementation -- `ClickServer` class (async Modbus TCP server) -- Request handling (FC 01-06, 15-16) - ---- - -## Dependency Graph - -``` -banks.py ← no deps (foundation) - ↑ - ├── validation.py - ├── addresses.py - │ ↑ - │ ├── blocks.py - │ ├── dataview.py - │ ├── modbus.py - │ │ ↑ - │ │ ├── client.py ──→ nicknames.py - │ │ └── server.py - │ └── nicknames.py ──→ blocks.py, validation.py -``` - -No cycles. Clean layering. - ---- - -## Key Design Decision: DataType Reconciliation - -**Problem:** The extraction plan uses `DataType(IntEnum)` with values `BIT=0, INT=1, INT2=2, FLOAT=3, HEX=4, TXT=6`. The Modbus specs use string types `'bool', 'int16', 'int32', 'float', 'str'`. - -**Decision:** `DataType` enum is the single source of truth (it matches CLICK software's own type system). The Modbus layer derives protocol properties from it: - -```python -# banks.py -class DataType(IntEnum): - BIT = 0 # bool coil - INT = 1 # signed 16-bit register - INT2 = 2 # signed 32-bit (2 registers) - FLOAT = 3 # 32-bit float (2 registers) - HEX = 4 # unsigned 16-bit register - TXT = 6 # packed ASCII text - -# modbus.py derives from DataType: -MODBUS_WIDTH = { - DataType.BIT: 1, DataType.INT: 1, DataType.INT2: 2, - DataType.FLOAT: 2, DataType.HEX: 1, DataType.TXT: 1, -} -MODBUS_SIGNED = { - DataType.INT: True, DataType.INT2: True, - DataType.HEX: False, DataType.FLOAT: False, -} -STRUCT_FORMATS = { - DataType.INT: 'h', DataType.INT2: 'i', - DataType.FLOAT: 'f', DataType.HEX: 'H', -} -``` - -The Modbus spec's `data_types` class attribute and `TYPE_MAP` constant become derived from `DataType` rather than independent string-based maps. - ---- - -## Key Design Decision: Bank Configuration - -**Problem:** ClickNick has `ADDRESS_RANGES` (flat tuples). The Modbus spec has `AddressType` (dataclass with Modbus fields). Both describe the same banks differently. Sparse banks (X, Y) need richer representation than a flat min/max — both ClickNick (to display only valid addresses) and the Modbus layer (for coil mapping) need to know the valid sub-ranges. - -**Decision:** Two-layer config. `BankConfig` in `banks.py` for PLC-level properties. `ModbusMapping` in `modbus.py` for protocol-level properties. Linked by bank name. - -```python -# banks.py — PLC knowledge -@dataclass(frozen=True) -class BankConfig: - name: str # "DS", "DF", "X", etc. - min_addr: int # Usually 1 (0 for XD/YD) - max_addr: int # e.g., 4500 for DS - data_type: DataType - valid_ranges: tuple[tuple[int, int], ...] | None = None # Sparse banks only - interleaved_with: str | None = None # T↔TD, CT↔CTD - -BANKS: dict[str, BankConfig] = { ... } -``` - -For non-sparse banks, `valid_ranges` is `None` (all addresses in `[min_addr, max_addr]` are valid). -For sparse banks (X, Y), `valid_ranges` enumerates the hardware slots: - -```python -# X and Y share the same slot structure -_SPARSE_RANGES = ( - (1, 16), # CPU slot 1 - (21, 36), # CPU slot 2 - (101, 116), # Expansion slot 1 - (201, 216), # Expansion slot 2 - (301, 316), # ... - (401, 416), - (501, 516), - (601, 616), - (701, 716), - (801, 816), -) -``` - -This is the **one definition** that: -- ClickNick uses to show only valid X/Y rows (replacing the current full 1-816 range) -- `addresses.py` uses to validate sparse addresses -- `modbus.py` uses to compute coil offsets - -```python -# modbus.py — protocol mapping -@dataclass(frozen=True) -class ModbusMapping: - bank: str # References BankConfig.name - base: int # Modbus base address - is_coil: bool - width: int = 1 - signed: bool = True - writable: frozenset[int] | None = None - -MODBUS_MAPPINGS: dict[str, ModbusMapping] = { ... } -``` - -Note: `sparse` and `valid_ranges` live on `BankConfig`, not `ModbusMapping`. The Modbus layer reads them from the bank config. This avoids duplicating sparse knowledge across layers. - ---- - -## Key Design Decision: One Address Parser - -**Problem:** Address parsing previously had two functions with different return conventions. - -**Decision:** Single strict `parse_address()` in `addresses.py`, returning MDB indices for all banks: - -```python -def parse_address(address_str: str) -> tuple[str, int]: - """Parse 'DF1' → ('DF', 1), 'XD1' → ('XD', 2), 'XD0u' → ('XD', 1) - Raises ValueError on invalid input.""" -``` - -The Modbus layer calls `parse_address()` then looks up `ModbusMapping` by bank name. ClickNick calls `parse_address()` then looks up `BankConfig`. Same function, different downstream lookups. XD/YD use contiguous MDB indices (0-16), eliminating stride-2 special cases in the Modbus layer. - ---- - -## Key Design Decision: Optional Modbus - -**Problem:** ClickNick only needs nickname/blocktag functionality. It should not require pymodbus. - -**Decision:** pymodbus is an optional dependency. - -```toml -# pyproject.toml -[project.optional-dependencies] -modbus = ["pymodbus>=3.7"] -``` - -- `banks.py`, `addresses.py`, `blocks.py`, `nicknames.py`, `validation.py`, `dataview.py` — zero external dependencies (stdlib only) -- `client.py`, `server.py` — require pymodbus, import guarded -- Users: `pip install pyclickplc` for core, `pip install pyclickplc[modbus]` for everything - ---- - -## Key Design Decision: XD/YD Banks - -XD and YD are byte-grouped views of X/Y inputs/outputs exposed by the CLICK programming software. They have Modbus register addresses (not coils — they read/write 16-bit words that pack groups of I/O bits). - -**Decision:** Include XD/YD in both `BANKS` and `MODBUS_MAPPINGS`. XD is read-only; YD is read/write. The `addresses.py` XD/YD helpers move over as-is. - -> **TODO:** XD/YD Modbus details need to be confirmed by testing against real hardware. The `ModbusMapping` entries for XD/YD will be added once base addresses and behavior are verified. - ---- - -## Implementation Phases - -### Phase 1: Foundation - -**Modules:** `banks.py`, `addresses.py`, `validation.py` - -- Extract from ClickNick `constants.py` and `address_row.py` -- Create `BankConfig` dataclass -- Unify `DataType` enum as the canonical type system -- Port all address parsing/formatting functions -- Port all validation rules and functions -- Comprehensive tests - -**Unblocks:** Everything else. ClickNick can start importing immediately. - -### Phase 2: BlockTags - -**Module:** `blocks.py` - -- Requires ClickNick Phase 0 (unique block names) to be done first ✓ -- Extract from ClickNick `blocktag.py` (post-simplification) -- `BlockTag`, `BlockRange`, `MemoryBankMeta` -- All parsing and computation functions -- Tests - -### Phase 3: File I/O - -**Modules:** `nicknames.py`, `dataview.py` - -- Extract CSV read/write from ClickNick `data_source.py` -- Extract CDV read/write from ClickNick `cdv_file.py` -- Tests using existing test fixtures from ClickNick - -### Phase 4: Modbus Core - -**Module:** `modbus.py` - -- New code — `ModbusMapping` definitions for all 14 Modbus banks -- Forward mapping (PLC → Modbus address) -- Reverse mapping (Modbus → PLC address) -- Register packing/unpacking (struct-based) -- Sparse coil logic (X/Y forward and reverse) -- Text register handling -- Tests — extensive, since this is new code - -**Can run in parallel with Phases 2 & 3** (independent dependency chains). - -### Phase 5: Modbus Client - -**Module:** `client.py` - -- New code — `ClickClient` per CLICKDEVICE_SPEC -- `AddressAccessor`, `AddressInterface`, `TagInterface` -- Uses `modbus.py` for mapping/packing, `nicknames.py` for tag loading -- Tests with mocked Modbus client - -### Phase 6: Modbus Server - -**Module:** `server.py` - -- New code — `ClickServer` per CLICKSERVER_SPEC -- `DataProvider` protocol, `MemoryDataProvider` -- Request handling for all supported function codes -- Tests with mocked/real pymodbus server - -### Phase 7: Integration - -- Update ClickNick imports (mechanical find-and-replace) -- Integration tests: ClickClient ↔ ClickServer round-trips -- Wire into pyrung -- Delete moved code from ClickNick - ---- - -## Changes from Original Extraction Plan - -| Original Plan | Revised | -|---------------|---------| -| 5 modules (`banks`, `addresses`, `blocks`, `nicknames`, `validation`) | 9 modules (+ `dataview`, `modbus`, `client`, `server`) | -| `DataType` extracted as-is | `DataType` becomes canonical; Modbus types derived from it | -| `ADDRESS_RANGES` as flat tuples | `BankConfig` dataclass with `valid_ranges` for sparse banks | -| X/Y shown as full 1-816 range | Sparse `valid_ranges` defines exactly which addresses exist; ClickNick filters display accordingly | -| XD/YD excluded from Modbus | XD/YD included in `MODBUS_MAPPINGS` (XD read-only, YD read/write; details TBD pending hardware testing) | -| No Modbus knowledge | `modbus.py` adds protocol mapping layer | -| No mention of CDV files | `dataview.py` added | -| `AddressRecord` in `addresses.py` | Unchanged — still the shared data transfer object | -| pymodbus not mentioned | Optional dependency for client/server | - -The original extraction plan's module boundaries and phase ordering are preserved. The Modbus layer slots in alongside without disturbing the ClickNick extraction path. - ---- - -## Public API (`__init__.py`) - -```python -# Core -from pyclickplc.banks import BankConfig, BANKS, DataType -from pyclickplc.addresses import AddressRecord, parse_address, format_address_display -from pyclickplc.validation import validate_nickname, validate_initial_value - -# BlockTags -from pyclickplc.blocks import BlockTag, BlockRange, MemoryBankMeta - -# File I/O -from pyclickplc.nicknames import read_csv, write_csv -from pyclickplc.dataview import read_cdv, write_cdv - -# Modbus (import-guarded, requires pyclickplc[modbus]) -from pyclickplc.client import ClickClient -from pyclickplc.server import ClickServer, MemoryDataProvider, DataProvider - -- `load_nickname_file(path)` → `ClickProject` -- `ClickProject` dataclass (records + banks + standalone tags) ? -``` - diff --git a/spec/CLICKDEVICE_SPEC.md b/spec/CLICKDEVICE_SPEC.md deleted file mode 100644 index c12c35d..0000000 --- a/spec/CLICKDEVICE_SPEC.md +++ /dev/null @@ -1,633 +0,0 @@ -# clickclient Driver Specification - -A Python driver for AutomationDirect CLICK Plcs using Modbus TCP/IP. - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Dependencies](#dependencies) -3. [Data Structures](#data-structures) -4. [Address Types & Configuration](#address-types--configuration) -5. [ClickClient Class](#clickclient-class) -6. [AddressAccessor Class](#addressaccessor-class) -7. [AddressInterface Class](#addressinterface-class) -8. [TagInterface Class](#taginterface-class) -9. [Tag System](#tag-system) -10. [Validation Rules](#validation-rules) -11. [Test Scenarios](#test-scenarios) - ---- - -## Overview - -The driver provides asynchronous communication with AutomationDirect CLICK Plcs over Ethernet. It abstracts Modbus protocol details and PLC-specific quirks, offering a clean Pythonic interface with clear separation between raw address access and named tag access. - -### Key Features - -- Async/await pattern using asyncio -- Context manager support (`async with`) -- Clear separation: `plc.addr` for raw addresses, `plc.tag` for named tags -- Pythonic memory bank accessors: `plc.df.read(1, 10)` for ranges -- Optional tag file loading for named access - -### Interface Summary - -```python -async with ClickClient('192.168.1.100') as plc: - # Category accessors (recommended for raw addresses) - value = await plc.df.read(1) # Single value - values = await plc.df.read(1, 10) # Range (inclusive) - await plc.df.write(1, 3.14) # Write single - await plc.df.write(1, [1.0, 2.0]) # Write consecutive - - # Address interface (string-based) - value = await plc.addr.read('df1') - values = await plc.addr.read('df1-df10') - await plc.addr.write('df1', 3.14) - - # Tag interface (requires tag file) - value = await plc.tag.read('MyTagName') - values = await plc.tag.read() # All tags - await plc.tag.write('MyTagName', 3.14) -``` - ---- - -## Dependencies - -- Requires an `AsyncioModbusClient` base class from `clickclient.util` that provides: - - `read_coils(address: int, count: int) -> CoilResult` (CoilResult has `.bits: list[bool]`) - - `write_coils(address: int, data: list[bool]) -> None` - - `read_registers(address: int, count: int) -> list[int]` - - `write_registers(address: int, data: list[int]) -> None` - - Context manager support (`__aenter__`, `__aexit__`) - - Constructor: `__init__(self, address: str, timeout: int)` - ---- - -## Data Structures - -### AddressType (dataclass, frozen=True) - -Defines configuration for a PLC address type. - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `base` | `int` | required | Modbus base address | -| `max_addr` | `int` | required | Maximum valid PLC address number | -| `data_type` | `str` | required | One of: `'bool'`, `'int16'`, `'int32'`, `'float'`, `'str'` | -| `width` | `int` | `1` | Registers per value (2 for 32-bit types) | -| `signed` | `bool` | `True` | Whether numeric type is signed | -| `sparse` | `bool` | `False` | True for X/Y addresses with gaps in addressing | -| `writable` | `frozenset[int] \| None` | `None` | If set, only these specific addresses are writable | - ---- - -## Address Types & Configuration - -The following address types must be supported: - -### Boolean Types (Coils) - -| Category | Base | Max | Notes | -|----------|------|-----|-------| -| `x` | 0 | 836 | Inputs, sparse (CPU: `X001-X016`, `X021-X036`; Expansion: `*01-*16`) | -| `y` | 8192 | 836 | Outputs, sparse (CPU: `Y001-Y016`, `Y021-Y036`; Expansion: `*01-*16`) | -| `c` | 16384 | 2000 | Control relays | -| `t` | 45057 | 500 | Timer status bits | -| `ct` | 49152 | 250 | Counter status bits | -| `sc` | 61440 | 1000 | System control relays, **limited writable**: {53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121} | - -### Numeric Types (Registers) - -| Category | Base | Max | Type | Width | Signed | -|----------|------|-----|------|-------|--------| -| `ds` | 0 | 4500 | int16 | 1 | Yes | -| `dd` | 16384 | 1000 | int32 | 2 | Yes | -| `dh` | 24576 | 500 | int16 | 1 | No (unsigned) | -| `df` | 28672 | 500 | float | 2 | N/A | -| `td` | 45056 | 500 | int16 | 1 | Yes | -| `ctd` | 49152 | 250 | int32 | 2 | Yes | -| `sd` | 61440 | 1000 | int16 | 1 | No, **limited writable**: {29, 31, 32, 34, 35, 36, 40, 41, 42, 50, 51, 60, 61, 106, 107, 108, 112, 113, 114, 140, 141, 142, 143, 144, 145, 146, 147, 214, 215} | - -### Text Type - -| Category | Base | Max | Notes | -|----------|------|-----|-------| -| `txt` | 36864 | 1000 | Packed ASCII, 2 chars per register | - ---- - -## ClickClient Class - -Inherits from `AsyncioModbusClient`. - -### Class Attribute - -```python -data_types: ClassVar[dict[str, str]] # Maps bank -> data_type string -``` - -### Constructor - -```python -def __init__(self, address: str, tag_filepath: str = '', timeout: int = 1) -``` - -- `address`: PLC IP address or DNS name -- `tag_filepath`: Optional path to tags CSV file -- `timeout`: Communication timeout in seconds - -### Instance Attributes - -- `tags: dict` - Loaded tag definitions (empty dict if no file provided) -- `addr: AddressInterface` - Interface for raw address operations -- `tag: TagInterface` - Interface for tag-based operations - -### Pythonic Memory Bank Accessors - -#### `__getattr__(name: str) -> AddressAccessor` - -Returns an `AddressAccessor` for the given bank. - -```python -plc.df # Returns AddressAccessor for DF registers -plc.x # Returns AddressAccessor for X inputs -``` - -- Raises `AttributeError` for names starting with `_` -- Raises `AttributeError` for unknown banks -- Accessors are cached and reused - ---- - -## AddressAccessor Class - -Provides method-based access to a specific address banks. - -### Constructor - -```python -def __init__(self, plc: ClickClient, bank: str) -``` - -### Methods - -#### `async read(start: int, end: int | None = None) -> dict | bool | int | float | str` - -Read single value or range. - -```python -value = await plc.df.read(1) # Single value at DF1 -values = await plc.df.read(1, 10) # Range DF1 through DF10 (inclusive) -``` - -**Returns:** -- Single address (`end` is `None`): Returns the value directly -- Range: Returns `{address: value}` dict (e.g., `{'df1': 0.0, 'df2': 1.0, ...}`) - -#### `async write(start: int, data) -> None` - -Write single value or list of values. - -```python -await plc.df.write(1, 3.14) # Single value to DF1 -await plc.df.write(1, [1.0, 2.0]) # Writes DF1=1.0, DF2=2.0 -``` - -- Validates data types -- Validates writability for restricted addresses - -#### `__repr__() -> str` - -Returns `` - ---- - -## AddressInterface Class - -Provides string-based access to raw PLC addresses. - -### Constructor - -```python -def __init__(self, plc: ClickClient) -``` - -### Methods - -#### `async read(address: str) -> dict | bool | int | float | str` - -Read values by address string. - -```python -value = await plc.addr.read('df1') # Single value -values = await plc.addr.read('df1-df10') # Range as dict -``` - -**Returns:** -- Single address: Returns the value directly -- Range: Returns `{address: value}` dict (e.g., `{'df1': 0.0, 'df2': 1.0, ...}`) - -#### `async write(address: str, data) -> None` - -Write values by address string. - -```python -await plc.addr.write('df1', 3.14) # Single value -await plc.addr.write('df1', [1.0, 2.0]) # Writes DF1, DF2 -``` - -- `data` can be a single value or a list -- List writes consecutively starting at address -- Validates data type matches address type -- Validates writability for restricted addresses - ---- - -## TagInterface Class - -Provides access via tag nicknames. Requires tags to be loaded. - -### Constructor - -```python -def __init__(self, plc: ClickClient) -``` - -### Methods - -#### `async read(tag_name: str | None = None) -> dict | bool | int | float | str` - -Read values by tag name. - -```python -value = await plc.tag.read('MyTag') # Single tag value -values = await plc.tag.read() # All tags as dict -``` - -**Behavior:** -- If `tag_name` provided: returns the value for that tag -- If `tag_name` is `None`: returns all tagged values as `{tag_name: value}` -- Raises `KeyError` if tag not found -- Raises `ValueError` if called with `None` when no tags loaded - -#### `async write(tag_name: str, data) -> None` - -Write value by tag name. - -```python -await plc.tag.write('MyTag', 3.14) -await plc.tag.write('MyTag', [1.0, 2.0]) # Writes consecutive addresses -``` - -- Resolves tag to underlying address, then writes -- Validates data type and writability - -#### `read_all() -> dict` - -Returns a copy of all tag definitions (synchronous). - -```python -tags = plc.tag.read_all() -# {'MyTag': {'address': 'DF1', 'type': 'float'}, ...} -``` - ---- - -## Tag System - -### CSV File Format - -Tags are loaded from a CSV file exported from Click programming software. - -**Expected columns:** -- `Nickname` - Tag name (skip if empty or starts with `_`) -- `Address` - PLC address (e.g., `DF1`, `X101`) -- `Modbus Address` - Numeric Modbus address -- `Address Comment` - Optional comment - -**Note:** First line may have `## ` prefix that must be stripped. - -### Tag Data Structure - -```python -{ - 'TagName': { - 'address': str, # PLC address (e.g., 'DF1', 'X101') - 'type': str, # Data type string - 'comment': str, # Optional, only if present in CSV - } -} -``` - -Tags are sorted PLC address. - ---- - -## Validation Rules - -### Address Parsing - -Format: `BANK + NUMBER` or `BANK + NUMBER - BANK + NUMBER` - -- Bank is case-insensitive -- Inter-bank ranges not supported (e.g., `DF1-DD5` is invalid) -- End must be greater than start - -### Sparse Addressing (X, Y) - -X and Y addresses map to physical hardware slots with gaps in the Modbus address space. - -**X (inputs) addressing:** -- CPU slot 1: `X001-X016` → coils 0-15 -- CPU slot 2: `X021-X036` → coils 16-31 -- Expansion slots: `X101-X116`, `X201-X216`, ..., `X801-X816` → standard 16 addresses per hundred - -**Y (outputs) addressing:** -- CPU slot 1: `Y001-Y016` → coils 0-15 -- CPU slot 2: `Y021-Y036` → coils 16-31 -- Expansion slots: `Y101-Y116`, `Y201-Y216`, ..., `Y801-Y816` → standard 16 addresses per hundred - -**Valid addresses:** -- X: 001-016, 021-036, 101-116, 201-216, ..., 801-816 -- Y: 001-016, 021-036, 101-116, 201-216, ..., 801-816 - -**Invalid addresses:** Anything not listed above (e.g., X017-X020, X037-X100, Y017-Y020, Y037-Y100) - -> **Note:** While current CLICK CPU slots only have 8 inputs (X) and 6 outputs (Y), the full 16 addresses per slot are reserved in the Modbus space. -> -> **TODO:** Should we have a default toggle to only read X001-X008/X021-X028 and Y001-Y006/Y021-Y026? - -### Range Validation - -For non-sparse types: -- Start must be in `[1, max_addr]` -- End (if provided) must be `> start` and `<= max_addr` - -For sparse types (X, Y): -- Address must be in a valid range (see Sparse Addressing above) -- Validation must check CPU slots (000) differently from expansion slots (100+) - -### Data Type Validation - -| Type | Accepted Python Types | Notes | -|------|-----------------------|-------| -| `bool` | `bool` | only bool | -| `int16` | `int` | bool rejected | -| `int32` | `int` | bool rejected | -| `WORD` (`hex`) | `int` | bool rejected | -| `float32` | `int` or `float` | bool rejected | -| `str` | `str` | blank (`""`) or single ASCII char for TXT | - -### Value Range Validation - -- `int16` banks (`DS`, `TD`, `SD`): `-32768..32767` -- `int32` banks (`DD`, `CTD`): `-2147483648..2147483647` -- `WORD` banks (`DH`, `XD`, `YD`): `0..65535` (`0x0000..0xFFFF`) -- `float32` bank (`DF`): finite value representable as IEEE-754 float32 -- `TXT`: blank (`""`) or exactly 1 ASCII character (`0..127`) -- `TXT` space (`" "`) is valid - -Validation runs before writing and raises `ValueError` on invalid runtime values. - -### Writability Validation - -For `sc` and `sd` categories, only specific addresses are writable. Writing to non-writable addresses raises `ValueError`. - ---- - -## Internal Behavior Specifications - -### Modbus Address Calculation - -#### Coils (Boolean Types) - -**Standard coils:** -``` -coil_address = base + index - 1 -``` - -**Sparse coils (X):** -``` -if index <= 16: # CPU slot 1: X001-X016 - coil_address = base + index - 1 -elif index <= 36: # CPU slot 2: X021-X036 - coil_address = base + 16 + (index - 21) -else: # Expansion: X101+ - hundred = index // 100 - unit = index % 100 - coil_address = base + 32 * hundred + (unit - 1) -``` - -**Sparse coils (Y):** -``` -if index <= 16: # CPU slot 1: Y001-Y016 - coil_address = base + index - 1 -elif index <= 36: # CPU slot 2: Y021-Y036 - coil_address = base + 16 + (index - 21) -else: # Expansion: Y101+ - hundred = index // 100 - unit = index % 100 - coil_address = base + 32 * hundred + (unit - 1) -``` - -#### Registers (Numeric Types) - -``` -register_address = base + width * (index - 1) -count = width * number_of_values -``` - -### Register Packing/Unpacking - -**int16:** Direct 16-bit value (signed or unsigned based on config) - -**int32 and float:** Little-endian, split across 2 registers -- Pack: Use struct to convert to 4 bytes, then unpack as two 16-bit values -- Unpack: Pack as 16-bit values, then unpack as 32-bit type - -### Text Handling - -TXT registers pack 2 ASCII characters per register with byte-swapping quirk: -- Each register stores low byte first, high byte second -- Reading requires byte swapping within each register - -**Odd/even alignment:** Must handle cases where start or end address falls in the middle of a register. - -**Writing:** Must write complete registers; fetch adjacent byte if writing single character. - -### Sparse Coil Handling - -When reading X/Y ranges that span gaps or slot boundaries: -- Read the required coil range from Modbus -- Map coils back to PLC addresses, skipping invalid address ranges -- Gaps: 017-020, 037-100 (same for both X and Y) - -When writing X/Y ranges that span gaps: -- Insert `False` padding values for the gaps between valid ranges - ---- - -## Test Scenarios - -### Construction - -1. Create with IP address only -2. Create with IP and tag file -3. Create with custom timeout -4. Invalid tag file path raises appropriate error -5. `plc.addr` is an `AddressInterface` instance -6. `plc.tag` is a `TagInterface` instance - -### AddressAccessor - Reading - -#### Single Values -7. `plc.df.read(1)` reads single float -8. `plc.ds.read(1)` reads single int16 -9. `plc.dd.read(1)` reads single int32 -10. `plc.dh.read(1)` reads single unsigned int16 -11. `plc.x.read(101)` reads single bool (sparse) -12. `plc.c.read(1)` reads single bool -13. `plc.txt.read(1)` reads single char - -#### Ranges (Inclusive) -14. `plc.df.read(1, 10)` reads DF1 through DF10 (10 values) -15. `plc.c.read(1, 100)` reads C1 through C100 -16. `plc.x.read(101, 116)` reads within single sparse hundred -17. `plc.x.read(101, 216)` reads across sparse boundary - -#### Edge Cases -18. Read at max address for each type -19. `plc.df.read(500)` (max DF address) -20. `plc.df.read(500, 500)` single value via range syntax - -### AddressAccessor - Writing - -21. `plc.df.write(1, 3.14)` writes single float -22. `plc.ds.write(1, 42)` writes single int16 -23. `plc.c.write(1, True)` writes single bool -24. `plc.df.write(1, [1.0, 2.0, 3.0])` writes consecutive -25. `plc.x.write(101, [True, False, True])` writes sparse - -#### Restricted Addresses -26. `plc.sc.write(53, True)` succeeds (writable) -27. `plc.sc.write(1, True)` raises ValueError (not writable) -28. `plc.sd.write(29, 100)` succeeds (writable) -29. `plc.sd.write(1, 100)` raises ValueError (not writable) - -### AddressInterface - -30. `plc.addr.read('df1')` returns single value -31. `plc.addr.read('df1-df10')` returns dict with 10 entries -32. `plc.addr.read('DF1')` works (case-insensitive) -33. `plc.addr.write('df1', 3.14)` writes single -34. `plc.addr.write('df1', [1.0, 2.0])` writes consecutive -35. `plc.addr.read('invalid1')` raises ValueError - -### TagInterface - -36. `plc.tag.read('ExistingTag')` returns value -37. `plc.tag.read('NonexistentTag')` raises KeyError -38. `plc.tag.read()` returns all tagged values -39. `plc.tag.read()` with no tags loaded raises ValueError -40. `plc.tag.write('ExistingTag', value)` writes -41. `plc.tag.read_all()` returns copy of tag definitions - -### Memory Bank Accessor Attributes - -42. `plc.df` returns AddressAccessor -43. `plc.DF` returns same AddressAccessor (case-insensitive) -44. `plc._private` raises AttributeError -45. `plc.invalid_bank` raises AttributeError -46. `repr(plc.df)` returns `` - -### Validation Errors - -47. `plc.addr.read('df0')` raises ValueError (below min) -48. `plc.addr.read('df501')` raises ValueError (above max) -49. `plc.addr.read('df10-df5')` raises ValueError (end <= start) -50. `plc.addr.read('df1-dd10')` raises ValueError (inter-bank) -51. `plc.x.read(17)` raises ValueError (invalid sparse: in gap 017-020) -52. `plc.x.read(37)` raises ValueError (invalid sparse: in gap 037-100) -53. `plc.df.write(1, 'string')` raises ValueError (wrong type) -54. `plc.ds.write(1, 3.14)` raises ValueError (float for int16) - -Strict runtime value checks: - -- `plc.ds.write(1, 32768)` raises ValueError (int16 overflow) -- `plc.dd.write(1, 2147483648)` raises ValueError (int32 overflow) -- `plc.dh.write(1, -1)` raises ValueError (WORD underflow) -- `plc.dh.write(1, 65536)` raises ValueError (WORD overflow) -- `plc.ds.write(1, True)` raises ValueError (bool rejected for numeric) -- `plc.df.write(1, float('nan'))` raises ValueError (non-finite float) -- `plc.df.write(1, float('inf'))` raises ValueError (non-finite float) -- `plc.txt.write(1, 'AB')` raises ValueError (TXT must be single char) -- `plc.txt.write(1, '\\u00E9')` raises ValueError (TXT must be ASCII) -- `plc.txt.write(1, ' ')` succeeds (space TXT is allowed) -- `plc.txt.write(1, '')` succeeds (blank TXT is allowed) - -### Text Special Cases - -55. Read single char at odd position (`txt1`) -56. Read single char at even position (`txt2`) -57. Read range with odd start, odd end -58. Read range with even start, even end -59. Write string of odd length -60. Write string of even length - -### Sparse Coil Special Cases - -61. Read X in CPU slot 1 (`x001`, `x016`) -62. Read X in CPU slot 2 (`x021`, `x036`) -63. Read X in expansion slot (`x101`, `x116`) -64. Read X range within CPU slot 1 (`x001-x010`) -65. Read X range spanning CPU slots (`x010-x025`) -66. Read X range spanning to expansion (`x030-x105`) -67. Read Y in CPU slot 1 (`y001`, `y016`) -68. Read Y in CPU slot 2 (`y021`, `y036`) -69. Read Y in expansion slot (`y101`, `y116`) -70. Read Y range spanning CPU slots (`y010-y025`) -71. Write X values spanning CPU slot gap (`x014-x023`) -72. Write Y values spanning CPU slot gap (`y014-y023`) -73. Validate X rejects `x017`, `x020`, `x037`, `x100` -74. Validate Y rejects `y017`, `y020`, `y037`, `y100` - ---- - -## Error Messages - -Provide clear, actionable error messages: - -- `"'{bank}' is not a supported address type."` -- `"{BANK} address must be *01-*16."` (for sparse) -- `"{BANK} must be in [1, {max}]"` -- `"{BANK} end must be > start and <= {max}"` -- `"Inter-bank ranges are unsupported."` -- `"End address must be greater than start address."` -- `"{BANK}{index} value must be bool."` -- `"{BANK}{index} value must be int in [{min}, {max}]."` -- `"{BANK}{index} value must be a finite float32."` -- `"{BANK}{index} must be WORD (0..65535)."` -- `"{BANK}{index} TXT value must be blank or a single ASCII character."` -- `"{BANK}{index} is not writable."` -- `"Tag '{name}' not found. Available: [...]"` -- `"No tags loaded. Provide a tag file or specify a tag name."` - ---- - -## Constants - -### STRUCT_FORMATS - -```python -{'int16': 'h', 'int32': 'i', 'float': 'f'} -``` - -### TYPE_MAP - -```python -{'bool': bool, 'int16': int, 'int32': int, 'float': (int, float), 'str': str} -``` diff --git a/spec/CLICKSERVER_SPEC.md b/spec/CLICKSERVER_SPEC.md deleted file mode 100644 index 98771ea..0000000 --- a/spec/CLICKSERVER_SPEC.md +++ /dev/null @@ -1,628 +0,0 @@ -# ClickServer Specification - -A Modbus TCP server that simulates an AutomationDirect CLICK PLC. Incoming Modbus requests are reverse-mapped to PLC addresses and routed to a user-supplied DataProvider. - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Dependencies](#dependencies) -3. [Shared Core Module](#shared-core-module) -4. [DataProvider Protocol](#dataprovider-protocol) -5. [MemoryDataProvider](#memorydataprovider) -6. [ClickServer Class](#clickserver-class) -7. [Internal Behavior](#internal-behavior) -8. [Validation Rules](#validation-rules) -9. [Error Handling](#error-handling) -10. [Test Scenarios](#test-scenarios) - ---- - -## Overview - -### Key Features - -- Async server using pymodbus -- User-supplied DataProvider decouples storage from Modbus transport -- Full CLICK PLC Modbus address space (coils and registers) -- Context manager support (`async with`) -- Reference `MemoryDataProvider` included for testing and simple use cases - -### Interface Summary - -```python -from pyclickplc.server import ClickServer, MemoryDataProvider - -# Simple in-memory simulator -provider = MemoryDataProvider() -provider.set('DF1', 3.14) -provider.set('X001', True) - -async with ClickServer(provider, port=5020) as server: - await server.serve_forever() -``` - -```python -# Integration test with ClickClient -provider = MemoryDataProvider() -provider.set('DF1', 3.14) - -async with ClickServer(provider, port=5020) as server: - async with ClickClient('localhost:5020') as plc: - value = await plc.df.read(1) # Returns 3.14 - await plc.ds.write(1, 42) - - stored = provider.get('DS1') # Returns 42 -``` - -### Request Flow - -``` -Modbus Client ClickServer DataProvider - | | | - |--- Read registers 28672-28675 --> | - | |-- reverse map 28672 -> DF1 --> | - | |-- reverse map 28674 -> DF2 --> | - | | read('DF1') --> - | | <-- 3.14 ------| - | | read('DF2') --> - | | <-- 0.0 -------| - | |-- pack [3.14, 0.0] as 4 regs | - |<-- [reg, reg, reg, reg] ------| | -``` - ---- - -## Dependencies - -### Runtime - -- `pymodbus` — Modbus TCP server implementation -- `pyclickplc.core` — Shared address definitions, mapping, and packing logic - -### Shared Core (`pyclickplc.core`) - -The following components are shared between ClickClient and ClickServer. They are currently defined in the [ClickDevice Spec](CLICKDEVICE_SPEC.md) and must be extracted into a shared core module: - -- `AddressType` dataclass (frozen) -- All address type configurations (x, y, c, t, ct, sc, ds, dd, dh, df, td, ctd, sd, txt) -- Address string parsing: `parse_address(address: str) -> tuple[str, int]` -- Forward mapping: PLC address → Modbus address -- Register packing/unpacking (struct-based conversion for int16, int32, float) -- Text handling (packed ASCII with byte-swapping) -- Sparse addressing logic (X/Y coil slot mapping) -- Validation rules (range checks, sparse gap checks) -- Constants: `STRUCT_FORMATS`, `TYPE_MAP` - -The server adds one new core concern: **reverse mapping** (Modbus address → PLC address), which is the inverse of the forward mapping. - ---- - -## DataProvider Protocol - -The DataProvider is the user-supplied backend that stores and retrieves PLC values. The server translates Modbus requests into DataProvider calls. - -```python -class DataProvider(Protocol): - async def read(self, address: str) -> bool | int | float | str: - """Read a single PLC address. - - Args: - address: Uppercase PLC address string (e.g., 'DF1', 'X001', 'DS100') - - Returns: - Current value. Type must match the address bank: - - bool for X, Y, C, T, CT, SC - - int for DS, DD, DH, TD, CTD, SD, XD, YD - - float for DF - - str for TXT (blank or single character) - """ - ... - - async def write(self, address: str, value: bool | int | float | str) -> None: - """Write a value to a single PLC address. - - Args: - address: Uppercase PLC address string - value: Value to write. Type and range must match the address bank. - """ - ... -``` - -### Contract - -- The server calls `read()`/`write()` once per PLC address per Modbus request. A Modbus read of 10 consecutive DF registers results in 5 `read()` calls (DF is width-2). -- Address strings are always uppercase with no spaces: `'DF1'`, `'X001'`, `'DS100'`. -- The server validates writability (SC/SD restrictions) **before** calling `write()`. The DataProvider does not need to enforce writability. -- The server handles all Modbus packing/unpacking. The DataProvider only deals in native Python types. -- `MemoryDataProvider` enforces strict runtime value validation for `write()` and `set()`. -- If `read()` returns a value of the wrong type, behavior is undefined. - ---- - -## MemoryDataProvider - -Reference implementation that stores values in an in-memory dictionary. - -```python -class MemoryDataProvider: - def __init__(self) -> None: ... - - async def read(self, address: str) -> bool | int | float | str: ... - async def write(self, address: str, value: bool | int | float | str) -> None: ... - - # Synchronous convenience methods for setup and inspection - def set(self, address: str, value: bool | int | float | str) -> None: ... - def get(self, address: str) -> bool | int | float | str: ... - def bulk_set(self, values: dict[str, bool | int | float | str]) -> None: ... -``` - -### Behavior - -- Values stored in `dict[str, bool | int | float | str]` -- `read()` returns stored value, or a **type-appropriate default** if never written: - -| Bank Data Type | Default | -|----------------|---------| -| `bool` (X, Y, C, T, CT, SC) | `False` | -| `int16` (DS, TD, SD) | `0` | -| `int32` (DD, CTD) | `0` | -| `WORD` unsigned (DH, XD, YD) | `0` | -| `float` (DF) | `0.0` | -| `str` (TXT) | `'\x00'` | - -- `write()` validates value type/range for the target bank, then stores it -- `set()` / `get()` are synchronous wrappers for test setup and inspection -- `bulk_set()` calls `set()` for each entry -- Address strings are normalized to uppercase internally -- Determines the bank's data type from the shared core address type configuration - ---- - -## ClickServer Class - -### Constructor - -```python -def __init__(self, provider: DataProvider, host: str = 'localhost', port: int = 502) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `provider` | `DataProvider` | required | User-supplied value storage backend | -| `host` | `str` | `'localhost'` | Interface to bind (`'localhost'` for local-only, `'0.0.0.0'` for all) | -| `port` | `int` | `502` | TCP port (502 is Modbus default; use 5020+ for non-root testing) | - -### Instance Attributes - -- `provider: DataProvider` — The value storage backend -- `host: str` — Bound interface -- `port: int` — Bound port - -### Lifecycle Methods - -```python -async def start(self) -> None: - """Start the Modbus TCP server. Returns immediately; server runs in background.""" - -async def stop(self) -> None: - """Stop the server gracefully.""" - -async def serve_forever(self) -> None: - """Start the server and block until stop() is called or task is cancelled.""" -``` - -### Context Manager - -```python -async with ClickServer(provider, port=5020) as server: - # server.start() has been called - ... -# server.stop() is called on exit -``` - -- `__aenter__` calls `start()`, returns `self` -- `__aexit__` calls `stop()` - ---- - -## Internal Behavior - -### Reverse Mapping: Modbus Address → PLC Address - -The inverse of the driver's forward mapping. Given a raw Modbus coil or register number, determine the PLC bank and index. - -#### Coil Reverse Mapping - -Coil banks (from shared core): - -| Bank | Base | Address Space Size | -|------|------|--------------------| -| x | 0 | 832 (26 slots * 32) | -| y | 8192 | 832 | -| c | 16384 | 2000 | -| t | 45057 | 500 | -| ct | 49152 | 250 | -| sc | 61440 | 1000 | - -``` -For each coil bank in [x, y, c, t, ct, sc]: - end = base + address_space_size - if base <= coil_address < end: - offset = coil_address - base - if bank.sparse: - return reverse_sparse(bank, offset) - else: - index = offset + 1 - return f"{BANK}{index}" -return None # Unmapped -``` - -#### Sparse Coil Reverse Mapping (X, Y) - -``` -offset = coil_address - base - -if offset < 16: # CPU slot 1 - index = offset + 1 # *001-*016 - -elif offset < 32: # CPU slot 2 - index = 21 + (offset - 16) # *021-*036 - -else: # Expansion slots - hundred = offset // 32 # Slot number (1-8) - unit = (offset % 32) + 1 # Position within slot - if unit > 16: - return None # Gap — unmapped - index = hundred * 100 + unit # *101-*116, *201-*216, ... - -return f"{BANK}{index:03d}" -``` - -#### Register Reverse Mapping - -Register banks (from shared core): - -| Bank | Base | Max | Width | End Address | -|------|------|-----|-------|-------------| -| ds | 0 | 4500 | 1 | 4500 | -| dd | 16384 | 1000 | 2 | 18384 | -| dh | 24576 | 500 | 1 | 25076 | -| df | 28672 | 500 | 2 | 29672 | -| txt | 36864 | 1000 | 1 | 37864 | -| td | 45056 | 500 | 1 | 45556 | -| ctd | 49152 | 250 | 2 | 49652 | -| sd | 61440 | 1000 | 1 | 62440 | - -``` -For each register bank in [ds, dd, dh, df, txt, td, ctd, sd]: - end = base + width * max_addr - if base <= register_address < end: - offset = register_address - base - index = offset // width + 1 - reg_position = offset % width # 0 = first register, 1 = second (width-2 only) - return (bank, index, reg_position) -return None # Unmapped -``` - -> **Note:** `reg_position` is needed for FC 06 (write single register) on width-2 types. See [Register Write Handling](#register-write-handling). - -#### Unmapped Addresses - -Modbus addresses that do not map to any PLC bank return default values without calling the DataProvider: -- Unmapped coils → `False` -- Unmapped registers → `0` - -This matches real CLICK PLC behavior for undefined address space. - -### Supported Function Codes - -| FC | Name | Supported | -|----|------|-----------| -| 01 | Read Coils | Yes | -| 02 | Read Discrete Inputs | Yes (same as FC 01) | -| 03 | Read Holding Registers | Yes | -| 04 | Read Input Registers | Yes (same as FC 03) | -| 05 | Write Single Coil | Yes | -| 06 | Write Single Register | Yes | -| 15 | Write Multiple Coils | Yes | -| 16 | Write Multiple Registers | Yes | - -> **Note:** The CLICK PLC does not distinguish between holding/input registers or coils/discrete inputs. FC 01 and FC 02 behave identically, as do FC 03 and FC 04. - -### Coil Read Handling (FC 01/02) - -1. For each coil in `[address, address + count)`: - a. Reverse-map to PLC address - b. If mapped: call `provider.read(plc_address)` → `bool` - c. If unmapped (gap or undefined): return `False` -2. Return list of bool values - -### Coil Write Handling - -**FC 05 — Write Single Coil:** - -1. Reverse-map coil address to PLC address -2. If unmapped: raise Modbus `IllegalAddress` exception -3. Validate writability (SC restrictions) -4. Call `provider.write(plc_address, bool_value)` - -**FC 15 — Write Multiple Coils:** - -1. For each coil in the write range: - a. Reverse-map to PLC address - b. If unmapped (sparse gap): skip silently - c. If mapped but not writable: raise Modbus `IllegalAddress` exception - d. Call `provider.write(plc_address, value)` - -### Register Read Handling (FC 03/04) - -1. For each register in `[address, address + count)`: - a. Reverse-map to `(bank, index, reg_position)` - b. If unmapped: yield `0` - c. If mapped: collect into groups by `(bank, index)` -2. For each unique PLC address, call `provider.read(plc_address)` once -3. Pack each value into register(s) using shared core packing logic -4. Return concatenated register values in order - -**Optimization:** When reading a range of consecutive addresses within one bank, the server can determine the full set of PLC addresses up front and batch the reads. - -### Register Write Handling - -**FC 16 — Write Multiple Registers:** - -1. Determine which PLC addresses are covered by the write range -2. Group registers by PLC address -3. For each **complete** PLC address (all `width` registers present): - a. Validate writability (SD restrictions) - b. Unpack value from register(s) using shared core logic - c. Call `provider.write(plc_address, unpacked_value)` -4. For **partial** PLC addresses at boundaries (width-2 type where only 1 register is in the write range): - a. Read current value via `provider.read(plc_address)` - b. Replace the affected register half - c. Unpack the combined registers - d. Call `provider.write(plc_address, new_value)` - -**FC 06 — Write Single Register:** - -FC 06 writes exactly one 16-bit register. - -- **Width-1 types** (DS, DH, TXT, TD, SD): Unpack and write directly. -- **Width-2 types** (DD, DF, CTD): Read-modify-write: - 1. Call `provider.read(plc_address)` to get current value - 2. Pack current value into 2 registers - 3. Replace the register at `reg_position` with the new value - 4. Unpack the modified pair - 5. Call `provider.write(plc_address, new_value)` - -### pymodbus Integration - -The server uses pymodbus's `StartAsyncTcpServer` with a custom `ModbusSlaveContext`. The recommended implementation approach is a custom datastore (subclass of `ModbusBaseSlaveContext` or equivalent) that overrides value access to route through the reverse mapping and DataProvider. - ---- - -## Validation Rules - -### Writability - -The server enforces writability restrictions **before** calling `provider.write()`: - -- **SC coils:** Only addresses in `{53, 55, 60, 61, 65, 66, 67, 75, 76, 120, 121}` are writable -- **SD registers:** Only addresses in `{29, 31, 32, 34, 35, 36, 40, 41, 42, 50, 51, 60, 61, 106, 107, 108, 112, 113, 114, 140, 141, 142, 143, 144, 145, 146, 147, 214, 215}` are writable - -Writing to a non-writable SC or SD address raises a Modbus `IllegalAddress` exception. - -All other banks are fully writable within their address range. - -### Address Range - -The server accepts Modbus requests for any valid address in each bank's range. Requests beyond a bank's address space that don't fall in another bank return defaults (reads) or are rejected (writes). - -### Runtime Value Validation (MemoryDataProvider) - -`MemoryDataProvider` rejects invalid runtime values with `ValueError`. - -| Data Type | Banks | Required Value | -|-----------|-------|----------------| -| `bool` | X, Y, C, T, CT, SC | `bool` | -| `int16` signed | DS, TD, SD | `int` in `[-32768, 32767]` | -| `int32` signed | DD, CTD | `int` in `[-2147483648, 2147483647]` | -| `WORD` unsigned | DH, XD, YD | `int` in `[0, 65535]` | -| `float32` | DF | finite `int`/`float` representable as float32 | -| `text` | TXT | blank (`""`) or single ASCII `str` character | - -Additional rules: - -- Bool values are rejected for numeric banks (`bool` is not accepted as `int`). -- `NaN`, `+Inf`, and `-Inf` are invalid for `DF`. -- TXT values may be blank (`""`) or exactly one character with ASCII code `0..127`. -- TXT space (`" "`) is valid. - ---- - -## Error Handling - -### Modbus Exceptions - -The server returns standard Modbus exception responses: - -| Condition | Modbus Exception | -|-----------|-----------------| -| Write to unmapped address | `IllegalAddress` (0x02) | -| Write to non-writable SC/SD | `IllegalAddress` (0x02) | -| DataProvider raises exception | `SlaveDeviceFailure` (0x04) | - -### DataProvider Errors - -If the DataProvider raises an exception during `read()` or `write()`, the server: -1. Catches the exception -2. Returns a Modbus `SlaveDeviceFailure` exception to the client -3. Logs the error (if logging is configured) - -The server never crashes due to a DataProvider error. - -This includes `MemoryDataProvider` validation failures (`ValueError`). - ---- - -## Test Scenarios - -### Construction - -1. Create with MemoryDataProvider and default host/port -2. Create with custom host and port -3. Provider is stored as instance attribute - -### Reverse Mapping — Coils - -4. Coil `0` → `X001` -5. Coil `15` → `X016` -6. Coil `16` → `X021` -7. Coil `31` → `X036` -8. Coil `32` → `X101` -9. Coil `47` → `X116` -10. Coil `64` → `X201` -11. Coil `8192` → `Y001` -12. Coil `8208` → `Y021` -13. Coil `8224` → `Y101` -14. Coil `16384` → `C1` -15. Coil `16385` → `C2` -16. Coil `18383` → `C2000` -17. Coil `45057` → `T1` -18. Coil `49152` → `CT1` -19. Coil `61440` → `SC1` - -### Reverse Mapping — Sparse Gaps - -20. Coil `48` (X slot 1, unit 17) → unmapped -21. Coil `8240` (Y slot 1, unit 17) → unmapped -22. Verify all gap coils in CPU slot boundary (16-31 maps to *021-*036, not gaps) - -### Reverse Mapping — Registers - -23. Register `0` → `DS1` -24. Register `4499` → `DS4500` -25. Register `16384` → `DD1` -26. Register `16386` → `DD2` (width-2, second value) -27. Register `16385` → `DD1`, reg_position=1 (second register of DD1) -28. Register `24576` → `DH1` -29. Register `25075` → `DH500` -30. Register `28672` → `DF1` -31. Register `28674` → `DF2` -32. Register `36864` → `TXT1` -33. Register `45056` → `TD1` -34. Register `49152` → `CTD1` -35. Register `49154` → `CTD2` -36. Register `61440` → `SD1` - -### Reverse Mapping — Unmapped - -37. Coil `10000` (between Y and C) → unmapped, returns `False` -38. Register `5000` (between DS and DD) → unmapped, returns `0` - -### Read via Modbus — Coils - -39. Read single coil (C1) → `provider.read('C1')` called, bool returned -40. Read coil range (C1-C10) → `provider.read()` called 10 times -41. Read sparse coil (X001) → correct reverse mapping and read -42. Read sparse range spanning CPU slots (X010-X025) → correct gap handling -43. Read unmapped coil → `False` returned, provider not called - -### Read via Modbus — Registers - -44. Read single int16 register (DS1) → correct value -45. Read single unsigned int16 (DH1) → correct unsigned value -46. Read width-2 float (DF1) → 2 registers packed correctly -47. Read width-2 int32 (DD1) → 2 registers packed correctly -48. Read register range (DF1-DF5) → 10 registers, 5 provider calls -49. Read unmapped register → `0` returned, provider not called - -### Write via Modbus — Coils - -50. FC 05: Write single coil (C1=True) → `provider.write('C1', True)` called -51. FC 15: Write multiple coils (C1-C5) → 5 `provider.write()` calls -52. FC 05: Write unmapped coil → Modbus `IllegalAddress` -53. FC 05: Write non-writable SC (SC1) → Modbus `IllegalAddress` -54. FC 05: Write writable SC (SC53) → succeeds -55. FC 15: Write sparse coils spanning gap → gap addresses skipped - -### Write via Modbus — Registers - -56. FC 06: Write single int16 register (DS1=42) → `provider.write('DS1', 42)` called -57. FC 16: Write multiple registers for consecutive DS values -58. FC 16: Write float (DF1=3.14) → 2 registers unpacked to float, `provider.write('DF1', 3.14)` called -59. FC 16: Write int32 (DD1=100000) → 2 registers unpacked correctly -60. FC 06: Write single register of width-2 type (DF1, first half) → read-modify-write -61. FC 16: Partial write at boundary of width-2 type → read-modify-write for partial value -62. FC 06: Write unmapped register → Modbus `IllegalAddress` -63. FC 06: Write non-writable SD (SD1) → Modbus `IllegalAddress` -64. FC 06: Write writable SD (SD29) → succeeds - -### MemoryDataProvider - -65. Read unset bool address → `False` -66. Read unset int address → `0` -67. Read unset float address → `0.0` -68. Read unset text address → `'\x00'` -69. Write then read returns written value -70. `set()` then `get()` returns value (sync) -71. `set()` then `read()` returns value (async reads sync-set data) -72. `bulk_set()` sets multiple values -73. Address normalization: `set('df1', 1.0)` then `get('DF1')` returns `1.0` - -MemoryDataProvider value validation: - -- Reject out-of-range int16 (`set('DS1', 32768)`) -- Reject out-of-range int32 (`set('DD1', 2147483648)`) -- Reject out-of-range WORD (`set('DH1', -1)` or `set('DH1', 65536)`) -- Reject non-finite float (`set('DF1', float('nan'))`, `float('inf')`) -- Reject invalid TXT (`set('TXT1', 'AB')`, non-ASCII) -- Allow space TXT (`set('TXT1', ' ')`) -- Allow blank TXT (`set('TXT1', '')`) -- Reject bool for numeric banks (`set('DS1', True)`) - -### Server Lifecycle - -74. Context manager: server starts and stops cleanly -75. Explicit `start()` / `stop()` lifecycle -76. `serve_forever()` blocks until `stop()` called from another task -77. Multiple start/stop cycles work correctly -78. Stop while no clients connected -79. Stop while client connected — connection closed gracefully - -### DataProvider Error Handling - -80. Provider `read()` raises → Modbus `SlaveDeviceFailure` returned -81. Provider `write()` raises → Modbus `SlaveDeviceFailure` returned -82. Server continues operating after provider error - -### Integration (ClickClient ↔ ClickServer) - -83. Driver writes DF, provider sees value via `get()` -84. Provider `set()` value, driver reads it -85. Float round-trip preserves value (within float32 precision) -86. Int32 round-trip preserves value -87. Int16 signed round-trip (positive and negative) -88. Int16 unsigned (DH) round-trip -89. Bool round-trip -90. Text round-trip -91. Sparse coil (X/Y) round-trip -92. Read range via driver matches individual provider values - ---- - -## Summary: Shared vs. Server-Specific - -| Component | Location | -|-----------|----------| -| AddressType, bank configs, constants | `pyclickplc.core` (shared) | -| Address parsing, validation | `pyclickplc.core` (shared) | -| Forward mapping (PLC → Modbus) | `pyclickplc.core` (shared) | -| Register packing/unpacking | `pyclickplc.core` (shared) | -| Sparse addressing, text handling | `pyclickplc.core` (shared) | -| **Reverse mapping (Modbus → PLC)** | `pyclickplc.core` (shared, new) | -| DataProvider protocol | `pyclickplc.server` | -| MemoryDataProvider | `pyclickplc.server` | -| ClickServer class | `pyclickplc.server` | -| pymodbus server integration | `pyclickplc.server` | From 36ea7edce93f95baaed1c977db7c5bcf05bff178 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:37:34 -0500 Subject: [PATCH 45/55] copy: update description --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6bd64ef..2b88f94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ Repository = "https://github.com/ssweber/pyclickplc" [project] name = "pyclickplc" -description = "Utilities for AutomationDirect CLICK Plcs" +description = "Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O." authors = [ { name="ssweber", email="57631333+ssweber@users.noreply.github.com" }, ] From d7588e3af57cc0cacfaca48267a6acc7177c56c0 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:01:24 -0500 Subject: [PATCH 46/55] fix: rename xdu/ydu -> xd0u/yd0u --- README.md | 6 +++--- src/pyclickplc/client.py | 8 ++++---- tests/test_client.py | 24 ++++++++++++------------ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e6319cc..7f5dec1 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ async def main(): value = await plc.ds[1] # bare value result = await plc.ds.read(1, 3) # DS1..DS3 (inclusive range) xd_word = await plc.xd[3] # XD3 (display-indexed, 0..8) - await plc.ydu.write(0x1234) # YD0u explicit upper-byte alias - xdu_word = await plc.xdu.read() # {"XD0u": ...} + await plc.yd0u.write(0x1234) # YD0u explicit upper-byte alias + xd0u_word = await plc.xd0u.read() # {"XD0u": ...} # Address interface await plc.addr.write("df1", 3.14) @@ -60,7 +60,7 @@ All `read()` methods return `ModbusResponse`, a mapping keyed by canonical upper - Lookups are normalized: `resp["ds1"]` resolves `"DS1"` - `await plc.ds[1]` returns a bare value instead of a mapping -- `plc.xd`/`plc.yd` are display-indexed (`0..8`); `plc.xdu`/`plc.ydu` are aliases for `XD0u`/`YD0u` +- `plc.xd`/`plc.yd` are display-indexed (`0..8`); `plc.xd0u`/`plc.yd0u` are aliases for `XD0u`/`YD0u` ## ModbusService (Sync + Polling) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index 82fb075..d836217 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -38,7 +38,7 @@ BoolBankAttr: TypeAlias = Literal["x", "X", "y", "Y", "c", "C", "t", "T", "ct", "CT", "sc", "SC"] DisplayBankAttr: TypeAlias = Literal["xd", "XD", "yd", "YD"] -UpperByteAttr: TypeAlias = Literal["xdu", "XDU", "ydu", "YDU"] +UpperByteAttr: TypeAlias = Literal["xd0u", "XD0U", "yd0u", "YD0U"] IntBankAttr: TypeAlias = Literal[ "ds", "DS", @@ -749,8 +749,8 @@ def __init__( "YD": FixedAddressAccessor(self, "YD", 1), } self.tags: dict[str, dict[str, str]] = {} - self.xdu = self._upper_byte_accessors["XD"] - self.ydu = self._upper_byte_accessors["YD"] + self.xd0u = self._upper_byte_accessors["XD"] + self.yd0u = self._upper_byte_accessors["YD"] self.addr = AddressInterface(self) self.tag = TagInterface(self) @@ -824,7 +824,7 @@ def __getattr__( if name.startswith("_"): raise AttributeError(name) upper = name.upper() - if upper in {"XDU", "YDU"}: + if upper in {"XD0U", "YD0U"}: return self._upper_byte_accessors[upper[:2]] if upper in {"XD", "YD"}: return self._get_display_accessor(upper) diff --git a/tests/test_client.py b/tests/test_client.py index 9962bb2..c676cf2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -169,10 +169,10 @@ async def test_getattr_yd_is_display_indexed_accessor(self): @pytest.mark.asyncio async def test_upper_byte_aliases_are_available(self): plc = _make_plc() - assert isinstance(plc.xdu, FixedAddressAccessor) - assert isinstance(plc.ydu, FixedAddressAccessor) - assert plc.XDU is plc.xdu - assert plc.YDU is plc.ydu + assert isinstance(plc.xd0u, FixedAddressAccessor) + assert isinstance(plc.yd0u, FixedAddressAccessor) + assert plc.XD0U is plc.xd0u + assert plc.YD0U is plc.yd0u @pytest.mark.asyncio async def test_getattr_case_insensitive(self): @@ -238,9 +238,9 @@ async def test_repr_xd_display_accessor(self): assert repr(plc.xd) == "" @pytest.mark.asyncio - async def test_repr_xdu_fixed_accessor(self): + async def test_repr_xd0u_fixed_accessor(self): plc = _make_plc() - assert repr(plc.xdu) == "" + assert repr(plc.xd0u) == "" # ============================================================================== @@ -412,22 +412,22 @@ async def test_read_xd_out_of_range_raises(self): class TestFixedAddressAccessor: @pytest.mark.asyncio - async def test_read_xdu(self): + async def test_read_xd0u(self): plc = _make_plc() _set_read_registers(plc, [0x1234]) - result = await plc.xdu.read() + result = await plc.xd0u.read() assert result == {"XD0u": 0x1234} @pytest.mark.asyncio - async def test_write_xdu_not_writable(self): + async def test_write_xd0u_not_writable(self): plc = _make_plc() with pytest.raises(ValueError, match="not writable"): - await plc.xdu.write(0x1234) + await plc.xd0u.write(0x1234) @pytest.mark.asyncio - async def test_write_ydu(self): + async def test_write_yd0u(self): plc = _make_plc() - await plc.ydu.write(0x1234) + await plc.yd0u.write(0x1234) _get_write_registers_mock(plc).assert_called_once_with(57857, [0x1234]) From 86b1d08d75f057169d36cea318a6544f9263ec11 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:39:16 -0500 Subject: [PATCH 47/55] fix: Rename Dataview -> DataView throughout --- src/pyclickplc/__init__.py | 4 ++-- src/pyclickplc/dataview.py | 46 +++++++++++++++++++------------------- tests/test_dataview.py | 34 ++++++++++++++-------------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/pyclickplc/__init__.py b/src/pyclickplc/__init__.py index 5dc7d4d..813b44d 100644 --- a/src/pyclickplc/__init__.py +++ b/src/pyclickplc/__init__.py @@ -13,7 +13,7 @@ ) from .client import ClickClient, ModbusResponse from .dataview import ( - DataviewFile, + DataViewFile, DataViewRecord, check_cdv_file, get_data_type_for_address, @@ -63,7 +63,7 @@ "MemoryDataProvider", "ServerClientInfo", "run_server_tui", - "DataviewFile", + "DataViewFile", "DataViewRecord", "check_cdv_file", "get_data_type_for_address", diff --git a/src/pyclickplc/dataview.py b/src/pyclickplc/dataview.py index 3976b6e..bb7d068 100644 --- a/src/pyclickplc/dataview.py +++ b/src/pyclickplc/dataview.py @@ -88,7 +88,7 @@ class _CdvStorageCode: # Max rows in a dataview MAX_DATAVIEW_ROWS = 100 -DataviewValue: TypeAlias = bool | int | float | str | None +DataViewValue: TypeAlias = bool | int | float | str | None def get_data_type_for_address(address: str) -> DataType | None: @@ -153,7 +153,7 @@ class DataViewRecord: # Core data (stored in CDV file) address: str = "" # e.g., "X001", "DS1", "CTD250" data_type: DataType | None = None # DataType for the address - new_value: DataviewValue = None # Native Python value for optional write + new_value: DataViewValue = None # Native Python value for optional write # Display-only fields (populated from SharedAddressData) nickname: str = field(default="", compare=False) @@ -437,7 +437,7 @@ class DisplayParseResult: """Result object for non-throwing display -> native parsing.""" ok: bool - value: DataviewValue = None + value: DataViewValue = None error: str = "" @@ -445,11 +445,11 @@ def _default_cdv_header(has_new_values: bool) -> str: return f"{-1 if has_new_values else 0},0,0" -def _rows_snapshot(rows: list[DataViewRecord]) -> list[tuple[str, DataType | None, DataviewValue]]: +def _rows_snapshot(rows: list[DataViewRecord]) -> list[tuple[str, DataType | None, DataViewValue]]: return [(row.address, row.data_type, row.new_value) for row in rows] -def _coerce_for_compare(value: DataviewValue, data_type: DataType) -> DataviewValue: +def _coerce_for_compare(value: DataViewValue, data_type: DataType) -> DataViewValue: if value is None: return None @@ -494,8 +494,8 @@ def _coerce_for_compare(value: DataviewValue, data_type: DataType) -> DataviewVa def _values_equal_for_data_type( - expected: DataviewValue, - actual: DataviewValue, + expected: DataViewValue, + actual: DataViewValue, data_type: DataType | None, ) -> bool: if expected is None and actual is None: @@ -520,7 +520,7 @@ def _row_placeholder(rows: list[DataViewRecord], index: int) -> DataViewRecord: @dataclass -class DataviewFile: +class DataViewFile: """CDV file model with row data in native Python types.""" rows: list[DataViewRecord] = field(default_factory=create_empty_dataview) @@ -528,7 +528,7 @@ class DataviewFile: header: str = "0,0,0" path: Path | None = None _original_bytes: bytes | None = field(default=None, repr=False, compare=False) - _original_rows: list[tuple[str, DataType | None, DataviewValue]] = field( + _original_rows: list[tuple[str, DataType | None, DataViewValue]] = field( default_factory=list, repr=False, compare=False, @@ -538,7 +538,7 @@ class DataviewFile: _row_storage_tokens: list[str | None] = field(default_factory=list, repr=False, compare=False) @staticmethod - def value_to_display(value: DataviewValue, data_type: DataType | None) -> str: + def value_to_display(value: DataViewValue, data_type: DataType | None) -> str: """Render a native value as a display string.""" if data_type is None: return "" @@ -556,7 +556,7 @@ def try_parse_display(display_str: str, data_type: DataType | None) -> DisplayPa """Parse a display string to a native value without raising.""" if not display_str: return DisplayParseResult(ok=True, value=None) - ok, error = DataviewFile.validate_display(display_str, data_type) + ok, error = DataViewFile.validate_display(display_str, data_type) if not ok or data_type is None: return DisplayParseResult(ok=False, error=error) native = display_to_datatype(display_str, data_type) @@ -569,21 +569,21 @@ def validate_row_display(row: DataViewRecord, display_str: str) -> tuple[bool, s """Validate a user edit for a specific row.""" if not row.is_writable: return False, "Read-only address" - return DataviewFile.validate_display(display_str, row.data_type) + return DataViewFile.validate_display(display_str, row.data_type) @staticmethod def set_row_new_value_from_display(row: DataViewRecord, display_str: str) -> None: """Strictly set a row's native new_value from a display string.""" - ok, error = DataviewFile.validate_row_display(row, display_str) + ok, error = DataViewFile.validate_row_display(row, display_str) if not ok: raise ValueError(error) - parsed = DataviewFile.try_parse_display(display_str, row.data_type) + parsed = DataViewFile.try_parse_display(display_str, row.data_type) if not parsed.ok: raise ValueError(parsed.error) row.new_value = parsed.value @classmethod - def load(cls, path: Path | str) -> DataviewFile: + def load(cls, path: Path | str) -> DataViewFile: """Load a CDV file and parse new values into native Python types.""" path_obj = Path(path) if not path_obj.exists(): @@ -733,7 +733,7 @@ def verify(self, path: Path | str | None = None) -> list[str]: if target is None: raise ValueError("No path provided for verify") - disk = DataviewFile.load(target) + disk = DataViewFile.load(target) issues: list[str] = [] for i in range(MAX_DATAVIEW_ROWS): @@ -774,13 +774,13 @@ def verify(self, path: Path | str | None = None) -> list[str]: return issues -def read_cdv(path: Path | str) -> DataviewFile: - """Read a CDV file into a DataviewFile model.""" - return DataviewFile.load(path) +def read_cdv(path: Path | str) -> DataViewFile: + """Read a CDV file into a DataViewFile model.""" + return DataViewFile.load(path) -def write_cdv(path: Path | str, dataview: DataviewFile) -> None: - """Write a DataviewFile to a CDV path.""" +def write_cdv(path: Path | str, dataview: DataViewFile) -> None: + """Write a DataViewFile to a CDV path.""" dataview.save(path) @@ -873,7 +873,7 @@ def check_cdv_file(path: Path | str) -> list[str]: filename = path.name try: - dataview = DataviewFile.load(path) + dataview = DataViewFile.load(path) except Exception as exc: # pragma: no cover - exercised by caller tests return [f"CDV {filename}: Error loading file - {exc}"] @@ -939,7 +939,7 @@ def verify_cdv( row.new_value is not None for row in rows[:MAX_DATAVIEW_ROWS] if not row.is_empty ) - dataview = DataviewFile( + dataview = DataViewFile( rows=rows, has_new_values=has_values, header=_default_cdv_header(has_values), diff --git a/tests/test_dataview.py b/tests/test_dataview.py index 76ca2cd..543652d 100644 --- a/tests/test_dataview.py +++ b/tests/test_dataview.py @@ -7,7 +7,7 @@ MAX_DATAVIEW_ROWS, WRITABLE_SC, WRITABLE_SD, - DataviewFile, + DataViewFile, DataViewRecord, DisplayParseResult, _CdvStorageCode, @@ -32,7 +32,7 @@ def load_cdv(path): def save_cdv(path, rows, has_new_values: bool, header: str | None = None): - dataview = DataviewFile( + dataview = DataViewFile( rows=rows, has_new_values=has_new_values, header=header or f"{-1 if has_new_values else 0},0,0", @@ -475,42 +475,42 @@ def test_valid_int(self): assert validate_new_value("100", DataType.INT) == (True, "") -class TestDataviewFileDisplayHelpers: +class TestDataViewFileDisplayHelpers: def test_value_to_display(self): - assert DataviewFile.value_to_display(100, DataType.INT) == "100" - assert DataviewFile.value_to_display(None, DataType.INT) == "" + assert DataViewFile.value_to_display(100, DataType.INT) == "100" + assert DataViewFile.value_to_display(None, DataType.INT) == "" def test_try_parse_display(self): - parsed = DataviewFile.try_parse_display("100", DataType.INT) + parsed = DataViewFile.try_parse_display("100", DataType.INT) assert parsed == DisplayParseResult(ok=True, value=100, error="") - parsed_empty = DataviewFile.try_parse_display("", DataType.INT) + parsed_empty = DataViewFile.try_parse_display("", DataType.INT) assert parsed_empty == DisplayParseResult(ok=True, value=None, error="") - parsed_invalid = DataviewFile.try_parse_display("abc", DataType.INT) + parsed_invalid = DataViewFile.try_parse_display("abc", DataType.INT) assert parsed_invalid.ok is False assert parsed_invalid.error == "Must be integer" def test_validate_row_display(self): row = DataViewRecord(address="XD0", data_type=DataType.HEX) - assert DataviewFile.validate_row_display(row, "0001") == (False, "Read-only address") + assert DataViewFile.validate_row_display(row, "0001") == (False, "Read-only address") row = DataViewRecord(address="DS1") - assert DataviewFile.validate_row_display(row, "100") == (False, "No address set") + assert DataViewFile.validate_row_display(row, "100") == (False, "No address set") row = DataViewRecord(address="DS1", data_type=DataType.INT) - assert DataviewFile.validate_row_display(row, "abc") == (False, "Must be integer") + assert DataViewFile.validate_row_display(row, "abc") == (False, "Must be integer") def test_set_row_new_value_from_display(self): row = DataViewRecord(address="DS1", data_type=DataType.INT) - DataviewFile.set_row_new_value_from_display(row, "100") + DataViewFile.set_row_new_value_from_display(row, "100") assert row.new_value == 100 - DataviewFile.set_row_new_value_from_display(row, "") + DataViewFile.set_row_new_value_from_display(row, "") assert row.new_value is None with pytest.raises(ValueError, match="Must be integer"): - DataviewFile.set_row_new_value_from_display(row, "abc") + DataViewFile.set_row_new_value_from_display(row, "abc") class TestLoadCdv: @@ -597,7 +597,7 @@ def test_save_with_new_values(self, tmp_path): assert loaded_rows[0].new_value is True -class TestDataviewFileIO: +class TestDataViewFileIO: def test_read_write_aliases(self, tmp_path): cdv = tmp_path / "test.cdv" rows = create_empty_dataview() @@ -607,7 +607,7 @@ def test_read_write_aliases(self, tmp_path): save_cdv(cdv, rows, has_new_values=True) dataview = read_cdv(cdv) - assert isinstance(dataview, DataviewFile) + assert isinstance(dataview, DataViewFile) assert dataview.rows[0].new_value == 42 out = tmp_path / "out.cdv" @@ -624,7 +624,7 @@ def test_byte_identical_roundtrip(self, tmp_path): cdv.write_text("".join(lines), encoding="utf-16") original_bytes = cdv.read_bytes() - dataview = DataviewFile.load(cdv) + dataview = DataViewFile.load(cdv) dataview.save() assert cdv.read_bytes() == original_bytes From fcdda2af369ea9ca535d57d8dc6c2454442ce366 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:45:05 -0500 Subject: [PATCH 48/55] feat(blocks): keep BlockTag names opaque and add opt-in structured name helpers ## Summary Add non-breaking structured block-name helpers while preserving `BlockTag.name` as an opaque round-trippable string. ## What changed - Added `StructuredBlockName` in `pyclickplc.blocks` - Added `parse_structured_block_name(name)` (opt-in, non-throwing parser) - Added `group_udt_block_names(names)` to group UDT fields by base name (`Base.field`) - Kept core block-tag parsing/matching logic unchanged (`parse_block_tag`, pairing, range matching) ## Spec/Test updates - Added tests to verify `BlockTag.name` round-trips unchanged for: - `Base.field` - `Base:named_array(2,3)` - `Base:block(5)` - `Base:block(start=5)` - Added tests for helper parser behavior and UDT grouping ## Why This gives downstream tools (for example ClickNick) an opt-in way to interpret structured names for UI features (like grouping UDT attributes) without changing existing block-tag semantics. --- src/pyclickplc/blocks.py | 98 +++++++++++++++++++++++++++++++++++++++- tests/test_blocks.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/src/pyclickplc/blocks.py b/src/pyclickplc/blocks.py index f13ed94..5cfaa5f 100644 --- a/src/pyclickplc/blocks.py +++ b/src/pyclickplc/blocks.py @@ -6,8 +6,9 @@ from __future__ import annotations +import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, Protocol +from typing import TYPE_CHECKING, Iterable, Literal, Protocol if TYPE_CHECKING: pass @@ -51,6 +52,101 @@ class BlockTag: bg_color: str | None +@dataclass(frozen=True) +class StructuredBlockName: + """Opt-in parse result for structured block naming conventions. + + `parse_block_tag()` treats block names as opaque strings. This helper is + intentionally separate and optional for callers that want to interpret + naming conventions such as: + - UDT fields: ``Base.field`` + - Named arrays: ``Base:named_array(count,stride)`` + - Plain blocks with logical start override: + ``Base:block(n)`` / ``Base:block(start=n)`` + """ + + raw: str + kind: Literal["plain", "udt", "named_array", "block"] + base: str + field: str | None = None + count: int | None = None + stride: int | None = None + start: int | None = None + + +_STRUCTURED_IDENT = r"[A-Za-z_][A-Za-z0-9_]*" +_UDT_BLOCK_NAME_RE = re.compile( + rf"^(?P{_STRUCTURED_IDENT})\.(?P{_STRUCTURED_IDENT})$" +) +_NAMED_ARRAY_BLOCK_NAME_RE = re.compile( + rf"^(?P{_STRUCTURED_IDENT}):named_array\((?P[1-9][0-9]*),(?P[1-9][0-9]*)\)$" +) +_BLOCK_START_BLOCK_NAME_RE = re.compile( + rf"^(?P{_STRUCTURED_IDENT}):block\((?P(?:0|[1-9][0-9]*|start=(?:0|[1-9][0-9]*)))\)$" +) + + +def parse_structured_block_name(name: str) -> StructuredBlockName: + """Parse optional structured naming conventions from a block name. + + This parser is non-throwing and non-strict by design. Names that do not + exactly match a supported convention are returned as ``kind="plain"``. + """ + named_array_match = _NAMED_ARRAY_BLOCK_NAME_RE.fullmatch(name) + if named_array_match is not None: + return StructuredBlockName( + raw=name, + kind="named_array", + base=named_array_match.group("base"), + count=int(named_array_match.group("count")), + stride=int(named_array_match.group("stride")), + ) + + udt_match = _UDT_BLOCK_NAME_RE.fullmatch(name) + if udt_match is not None: + return StructuredBlockName( + raw=name, + kind="udt", + base=udt_match.group("base"), + field=udt_match.group("field"), + ) + + block_start_match = _BLOCK_START_BLOCK_NAME_RE.fullmatch(name) + if block_start_match is not None: + start_token = block_start_match.group("start") + if start_token.startswith("start="): + start = int(start_token.split("=", maxsplit=1)[1]) + else: + start = int(start_token) + return StructuredBlockName( + raw=name, + kind="block", + base=block_start_match.group("base"), + start=start, + ) + + return StructuredBlockName(raw=name, kind="plain", base=name) + + +def group_udt_block_names(names: Iterable[str]) -> dict[str, tuple[str, ...]]: + """Group UDT field block names by base name. + + Returns: + Mapping of ``Base`` -> ordered tuple of unique field names for tags + that match ``Base.field``. + """ + grouped: dict[str, list[str]] = {} + for name in names: + structured = parse_structured_block_name(name) + if structured.kind != "udt" or structured.field is None: + continue + fields = grouped.setdefault(structured.base, []) + if structured.field not in fields: + fields.append(structured.field) + + return {base: tuple(fields) for base, fields in grouped.items()} + + def _extract_bg_attribute(tag_content: str) -> tuple[str, str | None]: """Extract bg attribute from tag content. diff --git a/tests/test_blocks.py b/tests/test_blocks.py index 501896b..c80b916 100644 --- a/tests/test_blocks.py +++ b/tests/test_blocks.py @@ -9,9 +9,12 @@ extract_block_name, find_block_range_indices, find_paired_tag_index, + format_block_tag, get_block_type, + group_udt_block_names, is_block_tag, parse_block_tag, + parse_structured_block_name, strip_block_tag, validate_block_span, ) @@ -202,6 +205,75 @@ def test_strip_block_tag_with_inequality(self): assert strip_block_tag(text) == text +class TestOpaqueStructuredNames: + """BlockTag.name should remain an opaque string.""" + + def test_structured_names_round_trip(self): + names = [ + "Base.field", + "Base:named_array(2,3)", + "Base:block(5)", + "Base:block(start=5)", + ] + + for name in names: + open_comment = format_block_tag(name, "open") + close_comment = format_block_tag(name, "close") + self_closing_comment = format_block_tag(name, "self-closing") + + assert parse_block_tag(open_comment).name == name + assert parse_block_tag(close_comment).name == name + assert parse_block_tag(self_closing_comment).name == name + + assert extract_block_name(open_comment) == name + assert extract_block_name(close_comment) == name + assert extract_block_name(self_closing_comment) == name + + +class TestStructuredBlockNameHelpers: + """Tests for opt-in structured block-name parsing helpers.""" + + def test_parse_structured_block_name_udt(self): + parsed = parse_structured_block_name("Alarm.id") + assert parsed.kind == "udt" + assert parsed.base == "Alarm" + assert parsed.field == "id" + + def test_parse_structured_block_name_named_array(self): + parsed = parse_structured_block_name("AlarmPacked:named_array(2,3)") + assert parsed.kind == "named_array" + assert parsed.base == "AlarmPacked" + assert parsed.count == 2 + assert parsed.stride == 3 + + def test_parse_structured_block_name_block_start(self): + parsed = parse_structured_block_name("WordBank:block(start=5)") + assert parsed.kind == "block" + assert parsed.base == "WordBank" + assert parsed.start == 5 + + def test_parse_structured_block_name_plain_fallback(self): + parsed = parse_structured_block_name("Alarm.bad-name") + assert parsed.kind == "plain" + assert parsed.base == "Alarm.bad-name" + + def test_group_udt_block_names(self): + grouped = group_udt_block_names( + [ + "Alarm.id", + "Alarm.On", + "Alarm.id", + "AlarmPacked:named_array(2,3)", + "WordBank:block(5)", + "Pump.state", + ] + ) + assert grouped == { + "Alarm": ("id", "On"), + "Pump": ("state",), + } + + # Helper dataclass for testing matching functions @dataclass class MockRow: From e1a465ca71ef5c392b2a2227fb3665cab29ec578 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:57:02 -0500 Subject: [PATCH 49/55] feat: Harden nickname validation: remove is_system and require explicit system_bank rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This changes nickname validation from a broad boolean flag to explicit bank-based rules. ## What changed - Removed `is_system` from `validate_nickname(...)`. - `validate_nickname` now accepts only: - `system_bank="SC"` or `"SD"` for SC/SD system nickname rules - `system_bank="X"` for strict X system nickname rules (`_IO...` only) - `system_bank=None` for normal user nickname rules ## Behavior now - `system_bank=None`: - rejects leading `_` - enforces forbidden character checks - `system_bank="SC"` / `"SD"`: - allows PLC system punctuation and leading underscores - `system_bank="X"`: - requires `_IO...` - still enforces forbidden character checks This removes ambiguous “system mode” behavior and forces callers to pass explicit context when they want system-style validation. ## Tests Updated validation tests to reflect the new API and stricter bank-specific behavior. --- src/pyclickplc/validation.py | 37 +++++++++++++++++++++++++++--------- tests/test_validation.py | 36 ++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/pyclickplc/validation.py b/src/pyclickplc/validation.py index 7036d84..182c849 100644 --- a/src/pyclickplc/validation.py +++ b/src/pyclickplc/validation.py @@ -68,16 +68,17 @@ # ============================================================================== -def validate_nickname(nickname: str, *, is_system: bool = False) -> tuple[bool, str]: +def validate_nickname(nickname: str, *, system_bank: str | None = None) -> tuple[bool, str]: """Validate nickname format (length, characters, reserved words). Does NOT check uniqueness -- that is application-specific. Args: nickname: The nickname to validate - is_system: If True, skip leading underscore and forbidden character checks - (PLC system-generated names for SC, SD, and X banks). Length and - reserved keyword checks still apply. + system_bank: Optional system bank context: + - "SC"/"SD": allows PLC system punctuation + - "X": allows only _IO... style system names + - None: standard user nickname rules Returns: Tuple of (is_valid, error_message) - error_message is "" if valid @@ -88,14 +89,32 @@ def validate_nickname(nickname: str, *, is_system: bool = False) -> tuple[bool, if len(nickname) > NICKNAME_MAX_LENGTH: return False, f"Too long ({len(nickname)}/24)" - if not is_system: - if nickname.startswith("_"): - return False, "Cannot start with _" - + def _forbidden_char_error() -> str: invalid_chars = set(nickname) & FORBIDDEN_CHARS if invalid_chars: chars_display = "".join(sorted(invalid_chars)[:3]) - return False, f"Invalid: {chars_display}" + return f"Invalid: {chars_display}" + return "" + + bank = system_bank.upper() if system_bank else None + + if bank in {"SC", "SD"}: + # SC/SD may contain PLC-owned punctuation and leading underscores. + pass + elif bank == "X": + # X system names are PLC auto-generated feedback aliases (_IO...). + if not nickname.startswith("_IO") or len(nickname) == 3 or not nickname[3].isdigit(): + return False, "X system names must start with _IO" + invalid_error = _forbidden_char_error() + if invalid_error: + return False, invalid_error + else: + # Standard user nickname rules. + if nickname.startswith("_"): + return False, "Cannot start with _" + invalid_error = _forbidden_char_error() + if invalid_error: + return False, invalid_error if nickname.lower() in RESERVED_NICKNAMES: return False, "Reserved keyword" diff --git a/tests/test_validation.py b/tests/test_validation.py index 958283f..7024a38 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -41,24 +41,46 @@ def test_starts_with_underscore(self): assert valid is False assert "Cannot start with _" in error - def test_system_allows_underscore(self): - assert validate_nickname("_IO1_Module_Error", is_system=True) == (True, "") - assert validate_nickname("_SystemName", is_system=True) == (True, "") + def test_non_system_rejects_leading_underscore(self): + valid, error = validate_nickname("_SystemName") + assert valid is False + assert "Cannot start with _" in error def test_system_allows_forbidden_chars(self): # SC nicknames contain / - assert validate_nickname("Comm/Port_1", is_system=True) == (True, "") + assert validate_nickname("Comm/Port_1", system_bank="SC") == (True, "") # SD nicknames contain ( and ) - assert validate_nickname("_Fixed_Scan_Time(ms)", is_system=True) == (True, "") + assert validate_nickname("_Fixed_Scan_Time(ms)", system_bank="SD") == ( + True, + "", + ) + + def test_non_system_rejects_sc_sd_punctuation(self): + valid, error = validate_nickname("Comm/Port_1") + assert valid is False + assert "Invalid" in error + + def test_x_system_requires_io_prefix(self): + valid, error = validate_nickname("_SystemName", system_bank="X") + assert valid is False + assert "_IO" in error + + def test_x_system_rejects_forbidden_chars(self): + valid, error = validate_nickname("_IO1.Module", system_bank="X") + assert valid is False + assert "Invalid" in error + + def test_x_system_allows_io_style_name(self): + assert validate_nickname("_IO1_Module_Error", system_bank="X") == (True, "") def test_system_still_enforces_length_and_reserved(self): # Length still enforced for system nicknames - valid, error = validate_nickname("_" + "a" * 24, is_system=True) + valid, error = validate_nickname("_" + "a" * 24, system_bank="SC") assert valid is False assert "Too long" in error # Reserved keywords still enforced - valid, error = validate_nickname("log", is_system=True) + valid, error = validate_nickname("log", system_bank="SC") assert valid is False assert "Reserved" in error From 9a0e34e32ce2e5c5e76be9f6e19fa6554336ea19 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:33:41 -0500 Subject: [PATCH 50/55] lint: replace server test ignores with typed casts Replace test-only assignment suppressions with explicit typed casts. - In tests/test_server.py, replace six `# type: ignore[assignment]` comments with `cast(ModbusTcpServer, ...)` assignments for `_server`. - Add required imports for `cast` and `ModbusTcpServer`. - Keep formatter/lint cleanup from Ruff in blocks/client test files (import normalization, line wrap, and blank-line cleanup). Result: `make lint` passes cleanly. --- src/pyclickplc/blocks.py | 7 +++---- src/pyclickplc/client.py | 1 - tests/test_client.py | 1 - tests/test_server.py | 14 ++++++++------ 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/pyclickplc/blocks.py b/src/pyclickplc/blocks.py index 5cfaa5f..2dff7e3 100644 --- a/src/pyclickplc/blocks.py +++ b/src/pyclickplc/blocks.py @@ -7,8 +7,9 @@ from __future__ import annotations import re +from collections.abc import Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable, Literal, Protocol +from typing import TYPE_CHECKING, Literal, Protocol if TYPE_CHECKING: pass @@ -75,9 +76,7 @@ class StructuredBlockName: _STRUCTURED_IDENT = r"[A-Za-z_][A-Za-z0-9_]*" -_UDT_BLOCK_NAME_RE = re.compile( - rf"^(?P{_STRUCTURED_IDENT})\.(?P{_STRUCTURED_IDENT})$" -) +_UDT_BLOCK_NAME_RE = re.compile(rf"^(?P{_STRUCTURED_IDENT})\.(?P{_STRUCTURED_IDENT})$") _NAMED_ARRAY_BLOCK_NAME_RE = re.compile( rf"^(?P{_STRUCTURED_IDENT}):named_array\((?P[1-9][0-9]*),(?P[1-9][0-9]*)\)$" ) diff --git a/src/pyclickplc/client.py b/src/pyclickplc/client.py index d836217..8abbe94 100644 --- a/src/pyclickplc/client.py +++ b/src/pyclickplc/client.py @@ -692,7 +692,6 @@ async def write( await self._plc.addr.write(tag_info["address"], data) - # ============================================================================== # ClickClient # ============================================================================== diff --git a/tests/test_client.py b/tests/test_client.py index c676cf2..301f515 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -808,7 +808,6 @@ async def test_write_tag_case_insensitive(self): _get_write_registers_mock(plc).assert_called_once() - # ============================================================================== # TXT write tests (mocked) # ============================================================================== diff --git a/tests/test_server.py b/tests/test_server.py index f678eab..170778d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,9 +2,11 @@ from __future__ import annotations +from typing import cast from unittest.mock import MagicMock import pytest +from pymodbus.server import ModbusTcpServer from pyclickplc.banks import DataType from pyclickplc.modbus import modbus_to_plc_register, pack_value, plc_to_modbus @@ -557,12 +559,12 @@ def test_is_running_false_before_start(self): def test_is_running_true_when_server_active(self): server = ClickServer(MemoryDataProvider()) - server._server = _FakeServer(active=True) # type: ignore[assignment] + server._server = cast(ModbusTcpServer, _FakeServer(active=True)) assert server.is_running() is True def test_is_running_false_when_server_inactive(self): server = ClickServer(MemoryDataProvider()) - server._server = _FakeServer(active=False) # type: ignore[assignment] + server._server = cast(ModbusTcpServer, _FakeServer(active=False)) assert server.is_running() is False def test_list_clients_empty_when_not_started(self): @@ -574,7 +576,7 @@ def test_list_clients_includes_peer(self): fake = _FakeServer(active=True) fake.active_connections["abc"] = _FakeConnection(("127.0.0.1", 5020)) fake.active_connections["def"] = _FakeConnection(None) - server._server = fake # type: ignore[assignment] + server._server = cast(ModbusTcpServer, fake) clients = server.list_clients() assert len(clients) == 2 @@ -586,7 +588,7 @@ def test_list_clients_includes_peer(self): def test_disconnect_client_unknown_returns_false(self): server = ClickServer(MemoryDataProvider()) fake = _FakeServer(active=True) - server._server = fake # type: ignore[assignment] + server._server = cast(ModbusTcpServer, fake) assert server.disconnect_client("missing") is False def test_disconnect_client_known_returns_true(self): @@ -594,7 +596,7 @@ def test_disconnect_client_known_returns_true(self): fake = _FakeServer(active=True) connection = _FakeConnection(("127.0.0.1", 1234)) fake.active_connections["known"] = connection - server._server = fake # type: ignore[assignment] + server._server = cast(ModbusTcpServer, fake) assert server.disconnect_client("known") is True connection.close.assert_called_once_with() @@ -606,7 +608,7 @@ def test_disconnect_all_clients(self): c2 = _FakeConnection(("127.0.0.1", 2222)) fake.active_connections["a"] = c1 fake.active_connections["b"] = c2 - server._server = fake # type: ignore[assignment] + server._server = cast(ModbusTcpServer, fake) count = server.disconnect_all_clients() assert count == 2 From a5f69bc45ad241ff5b6f730af49f7f1c785dd98d Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:47:29 -0500 Subject: [PATCH 51/55] docs: rewrite stab --- CHANGELOG.md | 32 ++++++ README.md | 207 ++++++++++------------------------ docs/gen_reference.py | 193 ++++++++++++++++++++++++++----- docs/guides/addressing.md | 45 ++++++++ docs/guides/client.md | 6 + docs/guides/files.md | 6 + docs/guides/modbus_service.md | 7 +- docs/guides/server.md | 4 + docs/guides/types.md | 36 ++++++ docs/index.md | 10 +- mkdocs.yml | 17 +-- pyproject.toml | 3 +- 12 files changed, 379 insertions(+), 187 deletions(-) create mode 100644 docs/guides/addressing.md create mode 100644 docs/guides/types.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..b2ebd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,33 @@ # Changelog + +## 0.1.0 - 2026-02-26 + +### Added + +- v0.1 documentation structure with capability-oriented API reference pages: + - Client API + - Service API + - Server API + - Files API + - Addressing API + - Validation API + - Advanced API +- New usage guides: + - `guides/types.md` for native Python value contracts + - `guides/addressing.md` for canonical normalized addressing and XD/YD details +- API reference coverage guard in `docs/gen_reference.py` to ensure all exported symbols in `pyclickplc.__all__` are documented exactly once. + +### Changed + +- README restructured for v0.1 launch: + - clearer async (`ClickClient`) vs sync (`ModbusService`) entry points + - native Python type contract table + - explicit error model (`ValueError` validation vs `OSError` transport) + - `XD0u` / `YD0u` moved out of quickstart to addressing guidance + - stability policy documented as "stable core, evolving edges" +- MkDocs navigation updated to capability-oriented API pages and new core guides. + +### Notes + +- Stable core APIs are documented in primary navigation. +- Lower-level Modbus mapping and bank metadata APIs are documented under Advanced API and may evolve more quickly. diff --git a/README.md b/README.md index 7f5dec1..b6bd69a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O. +Documentation: https://ssweber.github.io/pyclickplc/ + ## Installation ```bash @@ -10,37 +12,33 @@ uv add pyclickplc pip install pyclickplc ``` -Requires Python 3.11+. The Modbus client and server depend on [pymodbus](https://github.com/pymodbus-dev/pymodbus). +Requires Python 3.11+. Modbus client/server functionality depends on [pymodbus](https://github.com/pymodbus-dev/pymodbus). -## Quickstart +## Choose Your Interface -`ClickClient` is an async Modbus TCP client for CLICK PLCs. +- `ClickClient`: async API for direct `asyncio` applications. +- `ModbusService`: sync + polling API for UI/event-driven applications that do not want to manage an async loop. -```python -import asyncio -from pyclickplc import AddressRecord, ClickClient +## Native Python Type Contract -async def main(): - async with ClickClient("192.168.1.10", 502) as plc: - # Bank accessor - await plc.ds.write(1, 100) - value = await plc.ds[1] # bare value - result = await plc.ds.read(1, 3) # DS1..DS3 (inclusive range) - xd_word = await plc.xd[3] # XD3 (display-indexed, 0..8) - await plc.yd0u.write(0x1234) # YD0u explicit upper-byte alias - xd0u_word = await plc.xd0u.read() # {"XD0u": ...} +All read/write APIs operate on native Python values. - # Address interface - await plc.addr.write("df1", 3.14) - by_addr = await plc.addr.read("df1") - yd_display = await plc.addr.read("YD0-YD8") # display-step range for XD/YD +| Bank family | Python type | Examples | +| --- | --- | --- | +| `X`, `Y`, `C`, `T`, `CT`, `SC` | `bool` | `True`, `False` | +| `DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD` | `int` | `42`, `0x1234` | +| `DF` | `float` | `3.14` | +| `TXT` | `str` | `"A"` | -asyncio.run(main()) -``` +`read()` methods return `ModbusResponse`, keyed by canonical normalized addresses (`"DS1"`, `"X001"`): -Tags let you reference addresses by nickname: +- lookups are normalized (`resp["ds1"]` resolves `"DS1"`) +- single index reads like `await plc.ds[1]` return a bare native Python value + +## Quickstart (`ClickClient`, async) ```python +import asyncio from pyclickplc import AddressRecord, ClickClient tags = { @@ -49,160 +47,75 @@ tags = { async def main(): async with ClickClient("192.168.1.10", 502, tags=tags) as plc: - await plc.tag.write("MyTag", 42) - tag_value = await plc.tag.read("mytag") # case-insensitive - all_tag_values = await plc.tag.read_all() # excludes SC/SD by default + # Bank accessors + await plc.ds.write(1, 100) + ds1 = await plc.ds[1] + df_values = await plc.df.read(1, 3) + await plc.y.write(1, True) -asyncio.run(main()) -``` + # String address interface + await plc.addr.write("df1", 3.14) + df1 = await plc.addr.read("DF1") -All `read()` methods return `ModbusResponse`, a mapping keyed by canonical uppercase addresses (`"DS1"`, `"X001"`): + # Tag interface (case-insensitive) + await plc.tag.write("mytag", 42.0) + tag_value = await plc.tag.read("MyTag") -- Lookups are normalized: `resp["ds1"]` resolves `"DS1"` -- `await plc.ds[1]` returns a bare value instead of a mapping -- `plc.xd`/`plc.yd` are display-indexed (`0..8`); `plc.xd0u`/`plc.yd0u` are aliases for `XD0u`/`YD0u` + print(ds1, df_values, df1, tag_value) -## ModbusService (Sync + Polling) +asyncio.run(main()) +``` -`ModbusService` is a synchronous wrapper intended for UI/event-driven callers. It owns a background asyncio loop and provides polling plus bulk writes. +## `ModbusService` (sync + polling) ```python from pyclickplc import ModbusService, ReconnectConfig def on_values(values): - print(values) # ModbusResponse keyed by canonical addresses + print(values) # ModbusResponse keyed by canonical normalized addresses svc = ModbusService( poll_interval_s=0.5, - reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), # optional + reconnect=ReconnectConfig(delay_s=0.5, max_delay_s=5.0), on_values=on_values, ) svc.connect("192.168.1.10", 502, device_id=1, timeout=1) -svc.set_poll_addresses(["ds1", "df1", "y1"]) -print(svc.read(["ds1", "df1"])) - -results = svc.write( - { - "ds1": 100, - "y1": True, - "x1": True, # not writable -> per-item failure entry - } -) -print(results) +svc.set_poll_addresses(["DS1", "DF1", "Y1"]) +print(svc.read(["DS1", "DF1"])) +print(svc.write({"DS1": 100, "Y1": True, "X1": True})) # X1 is not writable svc.disconnect() ``` -Error semantics: -- invalid read addresses raise `ValueError` -- transport/protocol read failures raise `OSError` -- writes return per-address outcomes (`ok` + `error`) for UI reporting - -## Modbus Server - -`ClickServer` simulates a CLICK PLC over Modbus TCP. `MemoryDataProvider` is the built-in in-memory backend. - -```python -import asyncio -from pyclickplc import ClickServer, MemoryDataProvider - -async def main(): - provider = MemoryDataProvider() - provider.bulk_set({ - "DS1": 42, - "Y001": True, - }) - - async with ClickServer(provider, host="localhost", port=5020): - # Server is now accepting Modbus TCP connections - await asyncio.sleep(60) - -asyncio.run(main()) -``` - -`MemoryDataProvider` convenience methods: -- `get(address)` -- `set(address, value)` -- `bulk_set({address: value, ...})` - -Interactive server TUI helper: - -```python -import asyncio -from pyclickplc import ClickServer, MemoryDataProvider, run_server_tui - -async def main(): - provider = MemoryDataProvider() - provider.set("DS1", 42) - - server = ClickServer(provider, host="127.0.0.1", port=5020) - await run_server_tui(server) - -asyncio.run(main()) -``` - -TUI commands: -- `help` -- `status` -- `clients` -- `disconnect ` -- `disconnect all` -- `shutdown` (`exit` / `quit`) +## Error Model -## Nickname CSV Files +- Validation and address parsing failures raise `ValueError`. +- Transport/protocol failures raise `OSError` for reads. +- `ModbusService.write()` returns per-address outcomes (`ok` + `error`) instead of raising per-item validation failures. -Read and write CLICK software nickname CSV files. +## Addressing Nuances -```python -from pyclickplc import read_csv, write_csv - -# Read — returns AddressRecordMap (dict[int, AddressRecord] compatible) -records = read_csv("nicknames.csv") -for key, record in records.items(): - print(record.display_address, record.nickname, record.comment) - -# Address/nickname lookup views -ds1 = records.addr["ds1"] -tag = records.tag["mytag"] # case-insensitive nickname lookup - -# Write — only records with content are written -count = write_csv("output.csv", records) -``` - -## DataView CDV Files - -Read and write CLICK DataView `.cdv` files (UTF-16 LE format). +- Address strings are canonical normalized (`x1` -> `X001`, `ds1` -> `DS1`). +- `X`/`Y` are sparse address families; not every numeric value is valid. +- `XD`/`YD` accessors are display-indexed (`0..8`) by default. +- `XD0u`/`YD0u` are explicit upper-byte aliases for advanced use cases. -```python -from pyclickplc import read_cdv, write_cdv - -# Read -dataview = read_cdv("dataview.cdv") -for row in dataview.rows: - if not row.is_empty: - print(row.address, row.data_type, row.new_value) +See the addressing guide for details: +- https://ssweber.github.io/pyclickplc/guides/addressing/ -# Write -write_cdv("output.cdv", dataview) -``` +## Other Features -## Address Parsing +- Modbus simulator: `ClickServer`, `MemoryDataProvider`, `run_server_tui` +- CLICK nickname CSV I/O: `read_csv`, `write_csv`, `AddressRecordMap` +- CLICK DataView CDV I/O: `read_cdv`, `write_cdv`, `verify_cdv`, `check_cdv_file` -Parse and format PLC address strings. +## v0.1 API Stability -```python -from pyclickplc import parse_address, format_address_display, normalize_address +`pyclickplc` v0.1 follows a “stable core, evolving edges” policy. -bank, index = parse_address("DS100") # ("DS", 100) -bank, index = parse_address("X001") # ("X", 1) -bank, index = parse_address("XD0u") # ("XD", 1) — MDB index - -display = format_address_display("X", 1) # "X001" -display = format_address_display("XD", 1) # "XD0u" - -normalized = normalize_address("x1") # "X001" -``` +- Stable core: client/service/server/file I/O/address/validation APIs surfaced in the docs site primary navigation. +- Advanced/evolving: low-level Modbus mapping helpers and bank metadata (`ModbusMapping`, `plc_to_modbus`, `BANKS`, etc.). ## Development @@ -210,7 +123,7 @@ normalized = normalize_address("x1") # "X001" uv sync --all-extras --dev # Install dependencies make test # Run tests (uv run pytest) make lint # Lint (codespell, ruff, ty) -make docs-build # Build docs with mkdocstrings +make docs-build # Build docs (mkdocs + mkdocstrings) make docs-serve # Serve docs locally make # All of the above ``` diff --git a/docs/gen_reference.py b/docs/gen_reference.py index 068d9da..06f23a9 100644 --- a/docs/gen_reference.py +++ b/docs/gen_reference.py @@ -1,37 +1,176 @@ -"""Generate MkDocs API reference pages for pyclickplc modules.""" +"""Generate curated MkDocs API reference pages for pyclickplc public exports.""" +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from importlib import import_module from pathlib import Path import mkdocs_gen_files PACKAGE = "pyclickplc" -ROOT = Path(__file__).resolve().parents[1] -SRC_DIR = ROOT / "src" / PACKAGE - -overview_lines = [ - "# API Reference", - "", - "This section is generated from source using `mkdocstrings`.", - "", - "## Modules", -] - -for module_path in sorted(SRC_DIR.glob("*.py")): - if module_path.name == "__main__.py": - continue - - if module_path.name == "__init__.py": - identifier = PACKAGE - doc_rel_path = Path("reference/api") / f"{PACKAGE}.md" - else: - identifier = f"{PACKAGE}.{module_path.stem}" - doc_rel_path = Path("reference/api") / f"{module_path.stem}.md" + + +@dataclass(frozen=True) +class ReferencePage: + slug: str + title: str + tier: str + summary: str + symbols: tuple[str, ...] + + +PAGES: tuple[ReferencePage, ...] = ( + ReferencePage( + slug="client", + title="Client API", + tier="Stable Core", + summary="Async client and response mapping APIs.", + symbols=("ClickClient", "ModbusResponse"), + ), + ReferencePage( + slug="service", + title="Service API", + tier="Stable Core", + summary="Synchronous service wrapper and polling lifecycle APIs.", + symbols=("ModbusService", "ReconnectConfig", "ConnectionState", "WriteResult"), + ), + ReferencePage( + slug="server", + title="Server API", + tier="Stable Core", + summary="CLICK Modbus TCP simulator and server utilities.", + symbols=("ClickServer", "MemoryDataProvider", "ServerClientInfo", "run_server_tui"), + ), + ReferencePage( + slug="files", + title="Files API", + tier="Stable Core", + summary="Nickname CSV and DataView CDV models and file I/O helpers.", + symbols=( + "read_csv", + "write_csv", + "AddressRecordMap", + "read_cdv", + "write_cdv", + "verify_cdv", + "check_cdv_file", + "DataViewFile", + "DataViewRecord", + "get_data_type_for_address", + "validate_new_value", + ), + ), + ReferencePage( + slug="addressing", + title="Addressing API", + tier="Stable Core", + summary="Address model and canonical normalized address helpers.", + symbols=("AddressRecord", "parse_address", "normalize_address", "format_address_display"), + ), + ReferencePage( + slug="validation", + title="Validation API", + tier="Stable Core", + summary="Nickname/comment/initial-value validators and system nickname constants.", + symbols=( + "SYSTEM_NICKNAME_TYPES", + "validate_nickname", + "validate_comment", + "validate_initial_value", + ), + ), + ReferencePage( + slug="advanced", + title="Advanced API", + tier="Advanced / Evolving", + summary="Lower-level bank metadata and Modbus mapping helpers.", + symbols=( + "BANKS", + "BankConfig", + "DataType", + "ModbusMapping", + "plc_to_modbus", + "modbus_to_plc", + "pack_value", + "unpack_value", + ), + ), +) + + +def _validate_manifest() -> None: + exported = set(import_module(PACKAGE).__all__) + assigned = [symbol for page in PAGES for symbol in page.symbols] + counts = Counter(assigned) + + duplicates = sorted(symbol for symbol, count in counts.items() if count > 1) + assigned_set = set(counts) + missing = sorted(exported - assigned_set) + extra = sorted(assigned_set - exported) + + if not (duplicates or missing or extra): + return + + parts: list[str] = ["API reference manifest does not match pyclickplc.__all__."] + if duplicates: + parts.append(f"Duplicate symbols: {', '.join(duplicates)}") + if missing: + parts.append(f"Missing exported symbols: {', '.join(missing)}") + if extra: + parts.append(f"Unknown symbols not exported: {', '.join(extra)}") + raise RuntimeError(" ".join(parts)) + + +def _write_reference_page(page: ReferencePage) -> None: + doc_rel_path = Path("reference/api") / f"{page.slug}.md" + lines = [ + f"# {page.title}", + "", + f"**Tier:** {page.tier}", + "", + page.summary, + "", + ] + for symbol in page.symbols: + lines.append(f"::: {PACKAGE}.{symbol}") + lines.append("") with mkdocs_gen_files.open(doc_rel_path, "w") as fd: - fd.write(f"::: {identifier}\n") + fd.write("\n".join(lines).rstrip() + "\n") + mkdocs_gen_files.set_edit_path(doc_rel_path, Path("docs/gen_reference.py")) + + +def _write_index() -> None: + stable_pages = [page for page in PAGES if page.tier == "Stable Core"] + advanced_pages = [page for page in PAGES if page.tier != "Stable Core"] + lines = [ + "# API Reference", + "", + "This section is generated from an explicit, versioned public API manifest.", + "", + "## Stability Policy", + "", + "- Stable core pages document v0.1 compatibility commitments.", + "- Advanced API pages document lower-level helpers that may evolve faster.", + "", + "## Stable Core Pages", + "", + ] + for page in stable_pages: + lines.append(f"- [{page.title}](api/{page.slug}.md)") + + lines.extend(["", "## Advanced Pages", ""]) + for page in advanced_pages: + lines.append(f"- [{page.title}](api/{page.slug}.md)") + + with mkdocs_gen_files.open("reference/index.md", "w") as fd: + fd.write("\n".join(lines).rstrip() + "\n") + mkdocs_gen_files.set_edit_path("reference/index.md", Path("docs/gen_reference.py")) - mkdocs_gen_files.set_edit_path(doc_rel_path, module_path.relative_to(ROOT)) - overview_lines.append(f"- [`{identifier}`](api/{doc_rel_path.name})") -with mkdocs_gen_files.open("reference/index.md", "w") as fd: - fd.write("\n".join(overview_lines) + "\n") +_validate_manifest() +for ref_page in PAGES: + _write_reference_page(ref_page) +_write_index() diff --git a/docs/guides/addressing.md b/docs/guides/addressing.md new file mode 100644 index 0000000..4fd5a55 --- /dev/null +++ b/docs/guides/addressing.md @@ -0,0 +1,45 @@ +# Addressing + +`pyclickplc` uses canonical normalized address strings across APIs. + +## Canonical Normalized Addresses + +- Address parsing is case-insensitive. +- Normalization returns canonical display format. + +Examples: + +```python +from pyclickplc import normalize_address, parse_address + +assert normalize_address("x1") == "X001" +assert normalize_address("ds1") == "DS1" +assert normalize_address("xd0u") == "XD0u" + +assert parse_address("X001") == ("X", 1) +assert parse_address("XD0u") == ("XD", 1) # MDB index +``` + +## Sparse `X` and `Y` Ranges + +`X` and `Y` are sparse banks. Not every numeric value is valid (for example `X017` is invalid). + +Use parser/normalizer helpers to validate before building dynamic address lists. + +## `XD` and `YD` Display-Indexed Access + +- `plc.xd` and `plc.yd` are display-indexed (`0..8`). +- `plc.addr.read("XD0-XD4")` and `plc.addr.read("YD0-YD2")` use display-step ranges. +- Hidden odd MDB slots are not exposed in display-indexed range reads. + +## `XD0u` and `YD0u` (Upper-Byte Aliases) + +`XD0u` and `YD0u` are explicit upper-byte addresses and advanced edge cases: + +- Use `plc.xd0u.read()` / `plc.yd0u.write(...)` for direct alias access. +- `XD`/`YD` ranges cannot include `u` addresses (for example `XD0-XD0u` is invalid). + +## See Also + +- Type and value contract: [`guides/types.md`](types.md) +- Full API contracts: `API Reference -> Addressing API` diff --git a/docs/guides/client.md b/docs/guides/client.md index fb6e60f..9c5ca63 100644 --- a/docs/guides/client.md +++ b/docs/guides/client.md @@ -24,3 +24,9 @@ asyncio.run(main()) - String addresses: `plc.addr.read("DS1")`, `plc.addr.write("C1", True)` - Tags (with `tags=`): `plc.tag.read("name")`, `plc.tag.write("name", value)` (case-insensitive) +## Related Guides + +- Native value and bank typing rules: [`guides/types.md`](types.md) +- Canonical normalized addressing and XD/YD details: [`guides/addressing.md`](addressing.md) +- Full API signatures: `API Reference -> Client API` + diff --git a/docs/guides/files.md b/docs/guides/files.md index c10874c..194ad62 100644 --- a/docs/guides/files.md +++ b/docs/guides/files.md @@ -30,3 +30,9 @@ display = format_address_display("X", 1) # "X001" normalized = normalize_address("x1") # "X001" ``` +## Related Guides + +- Address semantics and edge cases: [`guides/addressing.md`](addressing.md) +- Native value contract: [`guides/types.md`](types.md) +- Full API signatures: `API Reference -> Files API` + diff --git a/docs/guides/modbus_service.md b/docs/guides/modbus_service.md index 53863fa..2f48349 100644 --- a/docs/guides/modbus_service.md +++ b/docs/guides/modbus_service.md @@ -35,10 +35,15 @@ svc.close() # same as disconnect() - The next sync call (`connect`, `read`, `write`, etc.) will start the loop again. - `reconnect=ReconnectConfig(...)` controls ClickClient auto-reconnect backoff. -## Error Semantics +## Error Semantics and Thread Safety - Invalid addresses/values at write-time are returned per address with `ok=False`. - Invalid addresses passed to `read(...)` raise `ValueError`. - Transport/protocol errors raise `OSError` for reads and are reported per-address for writes. - `on_state` and `on_values` callbacks run on the service thread. - Do not call synchronous service methods (`connect`, `disconnect`, `read`, `write`, poll config methods) from these callbacks. Marshal work to another thread/UI loop. + +## Related Guides + +- Native value rules and write validation: [`guides/types.md`](types.md) +- Address parsing/normalization rules: [`guides/addressing.md`](addressing.md) diff --git a/docs/guides/server.md b/docs/guides/server.md index 7c479e0..3369ed8 100644 --- a/docs/guides/server.md +++ b/docs/guides/server.md @@ -52,3 +52,7 @@ Supported commands: - `disconnect all` - `shutdown` (`exit` / `quit`) +## Related Reference + +- `API Reference -> Server API` + diff --git a/docs/guides/types.md b/docs/guides/types.md new file mode 100644 index 0000000..2676f3a --- /dev/null +++ b/docs/guides/types.md @@ -0,0 +1,36 @@ +# Native Value Types + +`pyclickplc` reads and writes native Python values. + +## Bank Family to Python Type + +| Bank family | Python type | Example | +| --- | --- | --- | +| `X`, `Y`, `C`, `T`, `CT`, `SC` | `bool` | `True` | +| `DS`, `DD`, `DH`, `TD`, `CTD`, `SD`, `XD`, `YD` | `int` | `42`, `0x1234` | +| `DF` | `float` | `3.14` | +| `TXT` | `str` | `"A"` | + +## Read Return Shapes + +- `read()` returns `ModbusResponse` keyed by canonical normalized addresses. +- Mapping lookups are normalized (`resp["ds1"]` resolves `DS1`). +- Single index access like `await plc.ds[1]` returns a bare native Python value. + +## Write Validation Rules + +- Type mismatches raise `ValueError` (for example writing `str` to `DS`). +- Out-of-range values raise `ValueError`. +- Non-writable addresses raise `ValueError`. +- Transport/protocol failures raise `OSError`. + +## `ModbusService` Write Semantics + +- `read(...)` invalid addresses raise `ValueError`. +- `read(...)` transport failures raise `OSError`. +- `write(...)` returns per-address `WriteResult` entries with `ok` and `error`. + +## See Also + +- Address semantics and normalization: [`guides/addressing.md`](addressing.md) +- Full API contracts: `API Reference -> Client API` and `Service API` diff --git a/docs/index.md b/docs/index.md index 0163ddf..f95ae04 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,14 @@ ## Start Here -- Core usage guides are under `Core Usage` in the left nav. -- Full API docs are generated under `API Reference`. +- Use the `Core Usage` guides for task-oriented docs. +- Use `API Reference` for the versioned public API surface. +- `Advanced API` documents lower-level helpers that may evolve more quickly. + +## v0.1 Stability Policy + +- Stable core: client/service/server/file I/O/address/validation APIs. +- Evolving edges: low-level Modbus mapping helpers and bank metadata. ## Local Docs Commands diff --git a/mkdocs.yml b/mkdocs.yml index 6d70deb..375e46b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,19 +9,20 @@ nav: - Home: index.md - Core Usage: - Modbus Client: guides/client.md + - Native Value Types: guides/types.md + - Addressing: guides/addressing.md - Modbus Service: guides/modbus_service.md - Modbus Server: guides/server.md - File I/O: guides/files.md - API Reference: - Overview: reference/index.md - - Modules: - - client: reference/api/client.md - - modbus_service: reference/api/modbus_service.md - - server: reference/api/server.md - - addresses: reference/api/addresses.md - - nicknames: reference/api/nicknames.md - - dataview: reference/api/dataview.md - - validation: reference/api/validation.md + - Client API: reference/api/client.md + - Service API: reference/api/service.md + - Server API: reference/api/server.md + - Files API: reference/api/files.md + - Addressing API: reference/api/addressing.md + - Validation API: reference/api/validation.md + - Advanced API: reference/api/advanced.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index 2b88f94..71262cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,7 @@ [project.urls] Repository = "https://github.com/ssweber/pyclickplc" -# Homepage = "https://..." -# Documentation = "https://..." +Documentation = "https://ssweber.github.io/pyclickplc/" [project] name = "pyclickplc" From d29c5932ca662bb254e34b2c22c16c6603a3dc8d Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:52:04 -0500 Subject: [PATCH 52/55] copy: add server links --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b6bd69a..5555736 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ See the addressing guide for details: - CLICK nickname CSV I/O: `read_csv`, `write_csv`, `AddressRecordMap` - CLICK DataView CDV I/O: `read_cdv`, `write_cdv`, `verify_cdv`, `check_cdv_file` +Server docs: +- Guide: https://ssweber.github.io/pyclickplc/guides/server/ +- API: https://ssweber.github.io/pyclickplc/reference/api/server/ + ## v0.1 API Stability `pyclickplc` v0.1 follows a “stable core, evolving edges” policy. From a535e34169b79044ed6cda67289213a90ce40523 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:54:00 -0500 Subject: [PATCH 53/55] copy: update to use `read_csv` in quickstart --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5555736..352cdb5 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,9 @@ All read/write APIs operate on native Python values. ```python import asyncio -from pyclickplc import AddressRecord, ClickClient +from pyclickplc import ClickClient, read_csv -tags = { - "temp_source": AddressRecord(memory_type="DF", address=1, nickname="MyTag"), -} +tags = read_csv("nicknames.csv") async def main(): async with ClickClient("192.168.1.10", 502, tags=tags) as plc: From e112b85c97f3e15a702477a8ae480ef5ae0c81a7 Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:17:58 -0500 Subject: [PATCH 54/55] docs: add handwritten for ClickClient --- docs/gen_reference.py | 15 +++++++++++ src/pyclickplc/modbus_service.py | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docs/gen_reference.py b/docs/gen_reference.py index 06f23a9..d949a63 100644 --- a/docs/gen_reference.py +++ b/docs/gen_reference.py @@ -133,6 +133,21 @@ def _write_reference_page(page: ReferencePage) -> None: page.summary, "", ] + if page.slug == "client": + lines.extend( + [ + "## Client Surface", + "", + "- Dynamic bank accessors: `plc.ds`, `plc.df`, `plc.y`, `plc.txt`, etc.", + "- Display-indexed accessors: `plc.xd` and `plc.yd` use display indices `0..8`.", + "- Upper-byte aliases: `plc.xd0u` and `plc.yd0u` expose `XD0u` / `YD0u`.", + "- String-address interface: `plc.addr.read(...)` and `plc.addr.write(...)`.", + "- Nickname/tag interface: `plc.tag.read(...)`, `plc.tag.write(...)`, and `plc.tag.read_all(...)`.", + "", + "Because accessor attributes are dynamic, this section is hand-curated and complements docstring-generated signatures below.", + "", + ] + ) for symbol in page.symbols: lines.append(f"::: {PACKAGE}.{symbol}") lines.append("") diff --git a/src/pyclickplc/modbus_service.py b/src/pyclickplc/modbus_service.py index ea1cb84..d48dbb0 100644 --- a/src/pyclickplc/modbus_service.py +++ b/src/pyclickplc/modbus_service.py @@ -239,9 +239,22 @@ def connect( device_id: int = 1, timeout: int = 1, ) -> None: + """Connect to a CLICK PLC endpoint. + + Args: + host: PLC hostname or IP address. + port: Modbus TCP port. + device_id: Modbus unit/device ID. + timeout: Client timeout in seconds. + + Raises: + OSError: Connection attempt failed. + ValueError: Invalid connection arguments. + """ self._submit_wait(self._connect_async(host, port, device_id=device_id, timeout=timeout)) def disconnect(self) -> None: + """Disconnect from the PLC and stop the service loop thread.""" thread = self._thread if thread is None: return @@ -253,19 +266,30 @@ def disconnect(self) -> None: self._stop_loop_thread() def close(self) -> None: + """Alias for :meth:`disconnect`.""" self.disconnect() # Poll configuration ----------------------------------------------------- def set_poll_addresses(self, addresses: Iterable[str]) -> None: + """Replace the active polling address set. + + Args: + addresses: Address strings to poll each cycle. + + Raises: + ValueError: Any address is invalid. + """ normalized, parsed = _normalize_addresses_for_read(addresses) plan = _build_read_plan(normalized, parsed) self._submit_wait(self._set_poll_config_async(tuple(normalized), tuple(plan), enabled=True)) def clear_poll_addresses(self) -> None: + """Clear the active polling address set.""" self._submit_wait(self._set_poll_config_async((), (), enabled=True)) def stop_polling(self) -> None: + """Pause polling while keeping the current configured address set.""" self._submit_wait( self._set_poll_config_async( self._poll_config.addresses, @@ -277,6 +301,18 @@ def stop_polling(self) -> None: # Sync operations -------------------------------------------------------- def read(self, addresses: Iterable[str]) -> ModbusResponse[PlcValue]: + """Synchronously read one or more addresses. + + Args: + addresses: Address strings to read. + + Returns: + ModbusResponse keyed by canonical normalized addresses. + + Raises: + ValueError: Any address is invalid. + OSError: Not connected or transport/protocol read failure. + """ normalized, parsed = _normalize_addresses_for_read(addresses) plan = _build_read_plan(normalized, parsed) result = self._submit_wait(self._read_plan_async(plan)) @@ -289,6 +325,15 @@ def write( self, values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]], ) -> list[WriteResult]: + """Synchronously write one or more address values. + + Args: + values: Mapping or iterable of ``(address, value)`` pairs. + + Returns: + Per-item write outcomes preserving input order. + Validation and write failures are reported in each ``WriteResult``. + """ items: list[tuple[object, object]] if isinstance(values, Mapping): items = list(values.items()) From 7a461f3568b520ac994d8063575c913484bc24fa Mon Sep 17 00:00:00 2001 From: ssweber <57631333+ssweber@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:18:20 -0500 Subject: [PATCH 55/55] docs: enable llmstxt --- README.md | 2 ++ mkdocs.yml | 9 +++++ pyproject.toml | 1 + uv.lock | 92 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 352cdb5..f5eca3a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Utilities for AutomationDirect CLICK PLCs: Modbus TCP client/server, address helpers, nickname CSV I/O, and DataView CDV I/O. Documentation: https://ssweber.github.io/pyclickplc/ +LLM docs index: https://ssweber.github.io/pyclickplc/llms.txt +LLM full context: https://ssweber.github.io/pyclickplc/llms-full.txt ## Installation diff --git a/mkdocs.yml b/mkdocs.yml index 375e46b..396f3a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,7 @@ site_name: pyclickplc site_description: Documentation for pyclickplc repo_url: https://github.com/ssweber/pyclickplc +site_url: https://ssweber.github.io/pyclickplc/ theme: name: material @@ -44,3 +45,11 @@ plugins: show_root_heading: true show_signature_annotations: true show_source: false + - llmstxt: + full_output: llms-full.txt + sections: + Core Usage: + - guides/*.md + API Reference: + - reference/index.md + - reference/api/*.md diff --git a/pyproject.toml b/pyproject.toml index 71262cc..c85337f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ docs = [ "mkdocs-material>=9.6.0", "mkdocstrings[python]>=0.29.0", "mkdocs-gen-files>=0.5.0", + "mkdocs-llmstxt>=0.3.0", ] # [project.scripts] diff --git a/uv.lock b/uv.lock index 0b2dad5..a76ebe6 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -231,14 +244,27 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, ] [[package]] @@ -315,6 +341,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdformat" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, +] + +[[package]] +name = "mdformat-tables" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdformat" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/fc/995ba209096bdebdeb8893d507c7b32b7e07d9a9f2cdc2ec07529947794b/mdformat_tables-1.0.0.tar.gz", hash = "sha256:a57db1ac17c4a125da794ef45539904bb8a9592e80557d525e1f169c96daa2c8", size = 6106, upload-time = "2024-08-23T23:41:33.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/37/d78e37d14323da3f607cd1af7daf262cb87fe614a245c15ad03bb03a2706/mdformat_tables-1.0.0-py3-none-any.whl", hash = "sha256:94cd86126141b2adc3b04c08d1441eb1272b36c39146bab078249a41c7240a9a", size = 5104, upload-time = "2024-08-23T23:41:31.863Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -397,6 +448,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] +[[package]] +name = "mkdocs-llmstxt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, + { name = "mdformat-tables" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f5/4c31cdffa7c09bf48d8c7a50d8342dc100abac98ac4150826bc11afc0c9f/mkdocs_llmstxt-0.5.0.tar.gz", hash = "sha256:b2fa9e6d68df41d7467e948a4745725b6c99434a36b36204857dbd7bb3dfe041", size = 33909, upload-time = "2025-11-20T14:02:24.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2b/82928cc9e8d9269cd79e7ebf015efdc4945e6c646e86ec1d4dba1707f215/mkdocs_llmstxt-0.5.0-py3-none-any.whl", hash = "sha256:753c699913d2d619a9072604b26b6dc9f5fb6d257d9b107857f80c8a0b787533", size = 12040, upload-time = "2025-11-20T14:02:23.483Z" }, +] + [[package]] name = "mkdocs-material" version = "9.7.1" @@ -529,6 +595,7 @@ dev = [ docs = [ { name = "mkdocs" }, { name = "mkdocs-gen-files" }, + { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] @@ -549,6 +616,7 @@ dev = [ docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, + { name = "mkdocs-llmstxt", specifier = ">=0.3.0" }, { name = "mkdocs-material", specifier = ">=9.6.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.0" }, ] @@ -754,6 +822,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "ty" version = "0.0.14" @@ -822,3 +899,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +]