# pyclickplc > Documentation for pyclickplc # Getting Started # Quickstart Connect to a simulated PLC, read and write values, then generate CLICK project files — no hardware required. ## Install ``` # Requires Python 3.11+ uv add pyclickplc ``` ## Connect and read/write Start a simulated PLC with `ClickServer`, then read and write registers with `ClickClient`: ``` import asyncio from pyclickplc import ClickClient, ClickServer, MemoryDataProvider async def main(): provider = MemoryDataProvider() provider.bulk_set({"DS1": 100, "DS2": 200, "C1": True}) async with ClickServer(provider, host="localhost", port=5020): async with ClickClient("localhost", 5020) as plc: # Read a single register ds1 = await plc.ds[1] print(f"DS1 = {ds1}") # 100 # Write a register await plc.ds.write(1, 999) print(f"DS1 = {await plc.ds[1]}") # 999 # Read a range values = await plc.ds.read(1, 2) print(values) # {"DS1": 999, "DS2": 200} # Read/write by address string await plc.addr.write("C1", False) c1 = await plc.addr.read("C1") print(f"C1 = {c1}") # False asyncio.run(main()) ``` `MemoryDataProvider` holds PLC values in memory. `ClickServer` exposes them over Modbus TCP. `ClickClient` talks Modbus TCP and returns native Python types — `bool` for coils, `int` for data registers, `float` for `DF`, `str` for `TXT`. ## Traffic light simulator A more realistic example: a state machine that cycles a traffic light through red, green, and yellow. ``` import asyncio from pyclickplc import ClickClient, ClickServer, MemoryDataProvider STATES = { "red": {"C1": True, "C2": False, "C3": False, "TXT1": "RED"}, "green": {"C1": False, "C2": False, "C3": True, "TXT1": "GREEN"}, "yellow": {"C1": False, "C2": True, "C3": False, "TXT1": "YELLOW"}, } CYCLE = ["red", "green", "yellow"] DURATIONS = {"red": 3, "green": 3, "yellow": 1} async def run_traffic_light(provider): """Cycle through states, updating the provider directly.""" idx = 0 while True: state = CYCLE[idx] provider.bulk_set(STATES[state]) print(f"Light: {state}") await asyncio.sleep(DURATIONS[state]) idx = (idx + 1) % len(CYCLE) async def main(): provider = MemoryDataProvider() provider.bulk_set(STATES["red"]) # Run the state machine in the background asyncio.create_task(run_traffic_light(provider)) async with ClickServer(provider, host="localhost", port=5020): async with ClickClient("localhost", 5020) as plc: for _ in range(5): await asyncio.sleep(2) txt = await plc.addr.read("TXT1") c1 = await plc.addr.read("C1") c2 = await plc.addr.read("C2") c3 = await plc.addr.read("C3") print(f" Client sees: {txt} (R={c1} Y={c2} G={c3})") asyncio.run(main()) ``` The server side updates `MemoryDataProvider` directly. The client reads over Modbus TCP and gets the current state — same as it would from a real PLC. ## Export to ClickNick Generate nickname CSV and DataView CDV files that you can load in [ClickNick](https://github.com/ssweber/clicknick) or CLICK programming software: ``` from pyclickplc import ( make_address_record, make_dataview_record, write_cdv, write_csv, ) # Nickname CSV — maps addresses to human-readable names nicknames = [ make_address_record("C1", nickname="RedLight"), make_address_record("C2", nickname="YellowLight"), make_address_record("C3", nickname="GreenLight"), make_address_record("TXT1", nickname="TrafficState"), ] write_csv("traffic_light_nicknames.csv", nicknames) # DataView CDV — defines a monitoring view write_cdv("traffic_light_dataview.cdv", [ make_dataview_record(r.display_address) for r in nicknames ]) ``` The CSV file uses the same format as CLICK programming software's nickname export. The CDV file is a UTF-16 LE CSV that CLICK's DataView feature reads directly. ## Next steps - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — bank accessors, string addresses, and tag-based access - [Types & values](https://ssweber.github.io/pyclickplc/guides/types/index.md) — what Python types each bank returns - [Addressing](https://ssweber.github.io/pyclickplc/guides/addressing/index.md) — normalization, sparse X/Y ranges, XD/YD display indexing - [Examples](https://ssweber.github.io/pyclickplc/guides/examples/index.md) — full runnable scripts # Guides # Addressing All pyclickplc APIs accept address strings and normalize them automatically. ## Canonical normalized addresses Input is case-insensitive. Output is always canonical: ``` from pyclickplc import normalize_address, parse_address normalize_address("x1") # "X001" normalize_address("ds1") # "DS1" normalize_address("Df10") # "DF10" parse_address("X001") # ("X", 1) parse_address("DS1") # ("DS", 1) ``` X/Y addresses are zero-padded to 3 digits (`X001`). All other banks use no padding (`DS1`, `DF10`). ## Sparse X and Y ranges `X` and `Y` are hardware I/O banks tied to physical module slots. Not every numeric value is a valid address — for example, `X017` doesn't exist because slots have gaps. ``` from pyclickplc import normalize_address normalize_address("X001") # "X001" — valid normalize_address("X017") # ValueError — no such address ``` Use `normalize_address` or `parse_address` to validate before building dynamic address lists. ## XD and YD display indexing `XD` and `YD` are 16-bit word views of the X/Y I/O banks. They use display indices `0` through `8`: | Display index | What it reads | | ------------- | --------------------------------------- | | `XD0` | X001–X008 as a 16-bit word (lower byte) | | `XD1` | X101–X108 as a 16-bit word (lower byte) | | ... | ... | | `XD8` | X801–X808 as a 16-bit word (lower byte) | Bank accessors use display indexing by default: ``` xd0 = await plc.xd[0] # display index 0 values = await plc.xd.read(0, 3) # display indices 0, 1, 2 ``` ## XD0u and YD0u upper-byte aliases Each XD/YD display index has a lower byte (default) and an upper byte. The upper byte covers inputs X009–X016 for slot 0, X109–X116 for slot 1, and so on. Access the upper byte with the `u` suffix: ``` normalize_address("XD0u") # "XD0u" parse_address("XD0u") # ("XD", 1) — MDB index 1 upper = await plc.xd0u.read() ``` Upper-byte addresses cannot be mixed with regular XD/YD in range reads. ## See also - [Types & values](https://ssweber.github.io/pyclickplc/guides/types/index.md) — what Python type each bank returns - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — how to read and write using these addresses # Modbus Client `ClickClient` reads and writes PLC values over Modbus TCP using native Python types. ``` import asyncio from pyclickplc import ClickClient async def main(): async with ClickClient("192.168.1.10", 502) as plc: await plc.ds.write(1, 100) ds1 = await plc.ds[1] print(ds1) # 100 asyncio.run(main()) ``` ## Bank accessors Every PLC bank has a typed accessor on the client: ``` await plc.ds.write(1, 100) # int await plc.df.write(1, 3.14) # float await plc.y.write(1, True) # bool await plc.txt.write(1, "HELLO") # str ds1 = await plc.ds[1] # single value values = await plc.ds.read(1, 5) # range → ModbusResponse ``` Range reads return a `ModbusResponse`, which is a dict keyed by canonical normalized addresses: ``` values = await plc.ds.read(1, 3) # {"DS1": 100, "DS2": 200, "DS3": 300} values["ds1"] # 100 — lookups are case-insensitive ``` ## String address interface Read and write by address string when the bank isn't known at code time: ``` await plc.addr.write("DS1", 100) await plc.addr.write("df1", 3.14) # case-insensitive value = await plc.addr.read("C1") ``` ## Tag interface Load a nickname CSV and access values by tag name instead of address: ``` from pyclickplc import ClickClient, read_csv tags = read_csv("nicknames.csv") async with ClickClient("192.168.1.10", 502, tags=tags) as plc: await plc.tag.write("TankTemp", 72.5) temp = await plc.tag.read("TankTemp") # case-insensitive ``` Tags resolve to addresses through the nickname CSV mapping. See [File I/O](https://ssweber.github.io/pyclickplc/guides/files/index.md) for CSV details. ## Error handling - Type mismatches and invalid addresses raise `ValueError`. - Transport/protocol failures raise `OSError`. ``` try: await plc.ds.write(1, "not an int") # ValueError except ValueError as e: print(e) try: value = await plc.ds[1] # OSError if connection lost except OSError as e: print(e) ``` ## See also - [Types & values](https://ssweber.github.io/pyclickplc/guides/types/index.md) — what Python type each bank returns - [Addressing](https://ssweber.github.io/pyclickplc/guides/addressing/index.md) — normalization rules and edge cases - [Modbus Service](https://ssweber.github.io/pyclickplc/guides/modbus_service/index.md) — sync wrapper for UI applications # Examples Runnable scripts live in the repository `examples/` directory. ## Traffic Light Simulator Source: [`examples/traffic_light.py`](https://github.com/ssweber/pyclickplc/blob/main/examples/traffic_light.py) What it demonstrates: - `ClickServer` + `MemoryDataProvider` simulation loop - State transitions (`RED -> GREEN -> YELLOW`) via `bulk_set` - Generating CLICK files: - `traffic_light_nicknames.csv` - `traffic_light_dataview.cdv` Run: ``` uv run python examples/traffic_light.py ``` ## Sync PLC Date/Time Source: [`examples/sync_clickplc_datetime.py`](https://github.com/ssweber/pyclickplc/blob/main/examples/sync_clickplc_datetime.py) What it demonstrates: - Multi-PLC concurrent updates with `asyncio.gather` - System register writes for date/time: - Date: `SD29`, `SD31`, `SD32` - Time: `SD34`, `SD35`, `SD36` - SC trigger/error handshake: - Date apply: `SC53` trigger, `SC54` error - Time apply: `SC55` trigger, `SC56` error Run: ``` uv run python examples/sync_clickplc_datetime.py ``` Set targets and datetime directly in the script footer: ``` dt_now = datetime.now() plc_ip_addresses = [ # "192.168.1.10", # Add more IP addresses as needed. ] ``` ## Going further: ladder logic with pyrung The traffic light above uses `asyncio.sleep` for timing — Python drives the state machine. A real CLICK PLC runs timer instructions in ladder logic. [pyrung](https://github.com/ssweber/pyrung) lets you write that logic in Python and simulate it scan-by-scan: ``` from pyrung import Bool, Char, Int, Rung, on_delay, copy, program, Tms State = Char("State") GreenDone, GreenAcc = Bool("GreenDone"), Int("GreenAcc") @program def logic(): with Rung(State == "g"): on_delay(GreenDone, GreenAcc, preset=3000, unit=Tms) with Rung(GreenDone): copy("y", State) # ... yellow → red → green ``` Under the hood, pyrung provides its own `DataProvider` that bridges ladder state to `ClickServer` — so any `ClickClient` or Modbus tool sees live, scan-driven values instead of Python-controlled ones. See pyrung's [traffic light example](https://github.com/ssweber/pyrung/blob/main/examples/traffic_light.py) for the full version with timers, car counters, and deterministic scan stepping. # File I/O Read and write CLICK nickname CSV and DataView CDV files. These are the same formats that CLICK programming software and [ClickNick](https://github.com/ssweber/clicknick) use. ## Nickname CSV Nickname files map PLC addresses to human-readable names, comments, and initial values. ### Read ``` from pyclickplc import read_csv records = read_csv("nicknames.csv") # Look up by address (case-insensitive) motor = records.addr["ds1"] print(motor.nickname, motor.comment) # Look up by tag name (case-insensitive) tag = records.tag["TankTemp"] print(tag.address, tag.data_type) ``` `read_csv` returns an `AddressRecordMap` with `.addr` and `.tag` lookup dicts. ### Write ``` from pyclickplc import make_address_record, write_csv records = [ make_address_record("DS1", nickname="TankTemp", comment="Degrees F"), make_address_record("C1", nickname="PumpRun"), make_address_record("DF1", nickname="FlowRate"), ] count = write_csv("nicknames.csv", records) print(f"Wrote {count} rows") ``` `write_csv` accepts any iterable of `AddressRecord` values (or a mapping keyed by address). Only records with content (nickname, comment, or non-default settings) are written. ### Build records `make_address_record` creates an `AddressRecord` from a display address with sensible defaults: ``` from pyclickplc import make_address_record record = make_address_record("DS1", nickname="TankTemp") # AddressRecord(address="DS1", nickname="TankTemp", data_type=DataType.INT16, ...) ``` Address normalization, data type inference, and default values are handled automatically. ## DataView CDV DataView files define monitoring views for the CLICK programming software. They use UTF-16 LE encoding with a CSV-like structure. ### Read and write ``` from pyclickplc import read_cdv, write_cdv dataview = read_cdv("dataview.cdv") write_cdv("output.cdv", dataview) ``` ### Build a DataView ``` from pyclickplc import make_dataview_record, write_cdv write_cdv("monitoring.cdv", [ make_dataview_record("DS1"), make_dataview_record("C1"), make_dataview_record("DF1", new_value=3.14), # pre-fill a write value ]) ``` `write_cdv` accepts a list of `DataViewRecord` values (or a `DataViewFile` for full control). `make_dataview_record` infers data type from the address. Use `new_value` to pre-populate a write value. ## Address helpers Parse and normalize addresses without a client connection: ``` from pyclickplc import format_address_display, normalize_address, parse_address parse_address("X001") # ("X", 1) normalize_address("x1") # "X001" format_address_display("X", 1) # "X001" ``` See [Addressing](https://ssweber.github.io/pyclickplc/guides/addressing/index.md) for normalization rules and edge cases. ## See also - [Quickstart](https://ssweber.github.io/pyclickplc/getting-started/quickstart/index.md) — generate CSV and CDV files from scratch - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — use tag names from a nickname CSV # Modbus Service `ModbusService` wraps `ClickClient` for applications that don't want to manage an async loop — GUI apps, services, or anything that needs synchronous reads and background polling. ``` from pyclickplc import ModbusService, ReconnectConfig def on_values(values): print(values) # ModbusResponse keyed by canonical addresses svc = ModbusService( poll_interval_s=0.5, 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"])) print(svc.write({"DS1": 10, "Y1": True})) svc.disconnect() ``` ## Polling lifecycle - `set_poll_addresses(addresses)` — start polling these addresses. Replaces any previous set. - `clear_poll_addresses()` — stop polling, clear the set. - `stop_polling()` — pause polling. Resumes when you call `set_poll_addresses` again. - `disconnect()` (or `close()`) — fully stop the background loop and thread. The next sync call (`connect`, `read`, `write`, etc.) restarts the loop automatically. ## Read and write - `read(addresses)` returns a `ModbusResponse` keyed by canonical normalized addresses. - `write(values)` accepts a mapping or iterable of `(address, value)` pairs. Returns per-address `WriteResult` entries with `ok` and `error` fields — non-writable or invalid addresses get an error result instead of raising. ## Callbacks and threading `on_values` and `on_state` callbacks run on the service thread, not the main thread. GUI apps should marshal callback data to the UI thread before updating widgets. Do not call `connect`, `disconnect`, `read`, `write`, or poll config methods from inside a callback — this will deadlock. ## Error handling - Invalid addresses passed to `read(...)` raise `ValueError`. - Transport/protocol errors raise `OSError` for reads. - Write errors are reported per-address in the `WriteResult` (no exception raised). ## See also - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — async API for direct `asyncio` use - [Types & values](https://ssweber.github.io/pyclickplc/guides/types/index.md) — native types and validation rules - [Addressing](https://ssweber.github.io/pyclickplc/guides/addressing/index.md) — normalization and sparse ranges # Server & Simulator `ClickServer` simulates a CLICK PLC over Modbus TCP. Use it for development and testing without hardware. ``` 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): print("Server running on localhost:5020") await asyncio.sleep(60) asyncio.run(main()) ``` Connect to it with `ClickClient` or any Modbus TCP tool — it behaves like a real CLICK PLC. ## MemoryDataProvider `MemoryDataProvider` stores PLC values in memory with address validation: ``` provider = MemoryDataProvider() provider.set("DS1", 100) provider.get("DS1") # 100 provider.bulk_set({"DS1": 42, "C1": True, "TXT1": "HELLO"}) ``` Addresses are normalized automatically — `provider.set("ds1", 100)` and `provider.set("DS1", 100)` are equivalent. For the full server + client workflow, see the [quickstart](https://ssweber.github.io/pyclickplc/getting-started/quickstart/index.md). ## Interactive TUI `run_server_tui` adds a terminal interface for inspecting and controlling the server: ``` import asyncio from pyclickplc import ClickServer, MemoryDataProvider, run_server_tui async def main(): provider = MemoryDataProvider() server = ClickServer(provider, host="localhost", port=5020) await run_server_tui(server) asyncio.run(main()) ``` Commands: `help`, `status`, `clients`, `disconnect `, `disconnect all`, `shutdown`. ## See also - [Quickstart](https://ssweber.github.io/pyclickplc/getting-started/quickstart/index.md) — server + client together - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — connect to the simulated PLC # Types & Values Every value you read or write is a native Python type. ## 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"` | No raw Modbus registers — the client handles packing and unpacking automatically. ## Read return shapes Single-index reads return a bare value: ``` ds1 = await plc.ds[1] # int ``` Range reads return a `ModbusResponse`, a dict keyed by canonical normalized addresses: ``` values = await plc.ds.read(1, 3) # {"DS1": 100, "DS2": 200, "DS3": 300} values["ds1"] # 100 — lookups are case-insensitive ``` ## Write validation Writes are validated before being sent to the PLC: - **Type mismatch** — writing `str` to `DS` raises `ValueError`. - **Out of range** — value exceeds the bank's data type limits raises `ValueError`. - **Not writable** — some addresses (certain SC/SD) are read-only and raise `ValueError`. ## See also - [Client guide](https://ssweber.github.io/pyclickplc/guides/client/index.md) — how to read and write values - [Addressing](https://ssweber.github.io/pyclickplc/guides/addressing/index.md) — normalization and sparse ranges # API Reference # 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 - [Client API](https://ssweber.github.io/pyclickplc/reference/api/client/index.md) - [Service API](https://ssweber.github.io/pyclickplc/reference/api/service/index.md) - [Server API](https://ssweber.github.io/pyclickplc/reference/api/server/index.md) - [Files API](https://ssweber.github.io/pyclickplc/reference/api/files/index.md) - [Addressing API](https://ssweber.github.io/pyclickplc/reference/api/addressing/index.md) - [Validation API](https://ssweber.github.io/pyclickplc/reference/api/validation/index.md) ## Advanced Pages - [Advanced API](https://ssweber.github.io/pyclickplc/reference/api/advanced/index.md) # Addressing API **Tier:** Stable Core Address model and canonical normalized address helpers. ## pyclickplc.AddressRecord Immutable record for a single PLC address. Simpler than ClickNick's AddressRow -- omits all UI validation state. For user-facing creation from display addresses, prefer `make_address_record(...)`. ### addr_key ``` addr_key: int ``` Get the AddrKey for this record. ### display_address ``` display_address: str ``` Get the display string for this address. ### data_type_display ``` data_type_display: str ``` Get human-readable data type name. ### is_default_retentive ``` is_default_retentive: bool ``` Return True if retentive matches the default for this memory_type. ### is_default_initial_value ``` is_default_initial_value: bool ``` Return True if the initial value is the default for its data type. ### has_content ``` has_content: bool ``` True if record has any user-defined content worth saving. ### can_edit_initial_value ``` can_edit_initial_value: bool ``` True if initial value can be edited for this memory type. ### can_edit_retentive ``` can_edit_retentive: bool ``` True if retentive setting can be edited for this memory type. ### from_address ``` from_address( address: str, *, nickname: str = "", comment: str = "", initial_value: str = "", retentive: bool | None = None, used: bool | None = None, ) -> AddressRecord ``` Build an AddressRecord from a display address string. ## pyclickplc.parse_address ``` parse_address(address_str: str) -> tuple[str, int] ``` Parse a display address string to (memory_type, mdb_address). Strict: raises ValueError on invalid input. For XD/YD, returns MDB address: "XD1" -> ("XD", 2), "XD0u" -> ("XD", 1). Parameters: | Name | Type | Description | Default | | ------------- | ----- | ------------------------------------------------ | ---------- | | `address_str` | `str` | Address string like "X001", "XD0", "XD0u", "XD8" | *required* | Returns: | Type | Description | | ----------------- | ----------------------------------- | | `tuple[str, int]` | Tuple of (memory_type, mdb_address) | Raises: | Type | Description | | ------------ | -------------------------------- | | `ValueError` | If the address string is invalid | ## pyclickplc.normalize_address ``` normalize_address(address: str) -> str | None ``` Normalize an address string to its canonical display form. E.g., "x1" -> "X001", "xd0u" -> "XD0u". Returns: | Type | Description | | ----- | ----------- | | \`str | None\` | ## pyclickplc.format_address_display ``` 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. # Advanced API **Tier:** Advanced / Evolving Lower-level bank metadata and Modbus mapping helpers. ## pyclickplc.BANKS ``` BANKS: dict[str, BankConfig] = { "X": BankConfig( "X", 1, 816, BIT, valid_ranges=_SPARSE_RANGES ), "Y": BankConfig( "Y", 1, 816, BIT, valid_ranges=_SPARSE_RANGES ), "C": BankConfig("C", 1, 2000, BIT), "T": BankConfig( "T", 1, 500, BIT, interleaved_with="TD" ), "CT": BankConfig( "CT", 1, 250, BIT, interleaved_with="CTD" ), "SC": BankConfig("SC", 1, 1000, BIT), "DS": BankConfig("DS", 1, 4500, INT), "DD": BankConfig("DD", 1, 1000, INT2), "DH": BankConfig("DH", 1, 500, HEX), "DF": BankConfig("DF", 1, 500, FLOAT), "XD": BankConfig("XD", 0, 16, HEX), "YD": BankConfig("YD", 0, 16, HEX), "TD": BankConfig( "TD", 1, 500, INT, interleaved_with="T" ), "CTD": BankConfig( "CTD", 1, 250, INT2, interleaved_with="CT" ), "SD": BankConfig("SD", 1, 1000, INT), "TXT": BankConfig("TXT", 1, 1000, TXT), } ``` ## pyclickplc.BankConfig Configuration for a single PLC memory bank. ## pyclickplc.DataType Bases: `IntEnum` DataType mapping from MDB database. ## pyclickplc.ModbusMapping Modbus address mapping configuration for a PLC memory bank. ### is_writable ``` is_writable: bool ``` True if any write FC (5, 6, 15, 16) is in function_codes. ## pyclickplc.plc_to_modbus ``` plc_to_modbus(bank: str, index: int) -> tuple[int, int] ``` Map PLC address to (modbus_address, register_count). Parameters: | Name | Type | Description | Default | | ------- | ----- | ------------------------------------------------- | ---------- | | `bank` | `str` | Bank name (e.g. "X", "DS", "XD") | *required* | | `index` | `int` | MDB index (e.g. 1 for X001, 0 for XD0, 2 for XD1) | *required* | Returns: | Type | Description | | ----------------- | ----------------------------------------- | | `tuple[int, int]` | Tuple of (modbus_address, register_count) | Raises: | Type | Description | | ------------ | --------------------------- | | `ValueError` | If bank or index is invalid | ## pyclickplc.modbus_to_plc ``` modbus_to_plc( address: int, is_coil: bool ) -> tuple[str, int] | None ``` Reverse map Modbus address to (bank, display_index) or None. Parameters: | Name | Type | Description | Default | | --------- | ------ | ----------------------------------------------------- | ---------- | | `address` | `int` | Raw Modbus coil or register address | *required* | | `is_coil` | `bool` | True for coil address space, False for register space | *required* | Returns: | Type | Description | | ----------------- | ----------- | | \`tuple[str, int] | None\` | ## pyclickplc.pack_value ``` pack_value( value: bool | int | float | str, data_type: DataType ) -> list[int] ``` Pack a Python value into Modbus register(s). Parameters: | Name | Type | Description | Default | | ----------- | ---------- | ------------------------------------- | ---------- | | `value` | \`bool | int | float | | `data_type` | `DataType` | The DataType determining the encoding | *required* | Returns: | Type | Description | | ----------- | ------------------------------ | | `list[int]` | List of 16-bit register values | Raises: | Type | Description | | ------------ | ------------------------------------------------------ | | `ValueError` | If data_type is BIT (coils don't use register packing) | ## pyclickplc.unpack_value ``` unpack_value( registers: list[int], data_type: DataType ) -> int | float | str ``` Unpack Modbus register(s) into a Python value. Parameters: | Name | Type | Description | Default | | ----------- | ----------- | ------------------------------------- | ---------- | | `registers` | `list[int]` | List of 16-bit register values | *required* | | `data_type` | `DataType` | The DataType determining the decoding | *required* | Returns: | Type | Description | | ----- | ----------- | | \`int | float | # Client API **Tier:** Stable Core Async client and response mapping APIs. ## 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. ## pyclickplc.ClickClient Async Modbus TCP driver for CLICK PLCs. ## pyclickplc.ModbusResponse Bases: `AddressNormalizerMixin`, `Mapping[str, TValue_co]`, `Generic[TValue_co]` 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. # Files API **Tier:** Stable Core Nickname CSV and DataView CDV models and file I/O helpers. ## pyclickplc.read_csv ``` 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, Retentive, Address Comment. Parameters: | Name | Type | Description | Default | | ------ | ----- | ----------- | --------------------- | | `path` | \`str | Path\` | Path to the CSV file. | Returns: | Type | Description | | ------------------ | ------------------------------------------------------------------- | | `AddressRecordMap` | AddressRecordMap keyed by addr_key (int) with .addr and .tag views. | ## pyclickplc.write_csv ``` write_csv( path: str | Path, records: Mapping[int, AddressRecord] | Iterable[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. Parameters: | Name | Type | Description | Default | | --------- | ----------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------ | | `path` | \`str | Path\` | Path to write the CSV file. | | `records` | \`Mapping[int, AddressRecord] | Iterable[AddressRecord]\` | Address records to write. Accepts a mapping keyed by addr_key or any iterable of AddressRecord values. | Returns: | Type | Description | | ----- | ----------------------- | | `int` | Number of rows written. | ## pyclickplc.AddressRecordMap Bases: `dict[int, AddressRecord]` Address record mapping with helper lookup views. ## pyclickplc.make_address_record ``` make_address_record( address: str, *, nickname: str = "", comment: str = "", initial_value: str = "", retentive: bool | None = None, used: bool | None = None, ) -> AddressRecord ``` Create an AddressRecord from a display address with inferred defaults. ## pyclickplc.read_cdv ``` read_cdv(path: Path | str) -> DataViewFile ``` Read a CDV file into a DataViewFile model. ## pyclickplc.write_cdv ``` write_cdv( path: Path | str, dataview: DataViewFile | Iterable[DataViewRecord], ) -> None ``` Write a DataViewFile or list of DataViewRecords to a CDV path. ## pyclickplc.verify_cdv ``` verify_cdv( path: Path | str, rows: list[DataViewRecord], has_new_values: bool | None = None, ) -> list[str] ``` Verify in-memory rows against a CDV file using native value comparison. ## pyclickplc.check_cdv_file ``` check_cdv_file(path: Path | str) -> list[str] ``` Validate a single CDV file and return issue strings. ## pyclickplc.DataViewFile CDV file model with row data in native Python types. ### value_to_display ``` value_to_display( value: DataViewValue, data_type: DataType | None ) -> str ``` Render a native value as a display string. ### validate_display ``` validate_display( display_str: str, data_type: DataType | None ) -> tuple[bool, str] ``` Validate display text for a target data type. ### try_parse_display ``` try_parse_display( display_str: str, data_type: DataType | None ) -> DisplayParseResult ``` Parse a display string to a native value without raising. ### validate_row_display ``` validate_row_display( row: DataViewRecord, display_str: str ) -> tuple[bool, str] ``` Validate a user edit for a specific row. ### set_row_new_value_from_display ``` set_row_new_value_from_display( row: DataViewRecord, display_str: str ) -> None ``` Strictly set a row's native new_value from a display string. ### load ``` load(path: Path | str) -> DataViewFile ``` Load a CDV file and parse new values into native Python types. ### save ``` save(path: Path | str | None = None) -> None ``` Save CDV back to disk, converting native values at the file boundary. ### verify ``` verify(path: Path | str | None = None) -> list[str] ``` Compare this in-memory dataview to a CDV file on disk. ## pyclickplc.DataViewRecord 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. For user-facing creation from display addresses, prefer `make_dataview_record(...)`. ### is_empty ``` is_empty: bool ``` Check if this row is empty (no address set). ### is_writable ``` is_writable: bool ``` Check if this address can have a New Value written to it. ### memory_type ``` memory_type: str | None ``` Get the memory type prefix (X, Y, DS, etc.) or None if invalid. ### address_number ``` address_number: str | None ``` Get the address number as a display string, or None if invalid. ### from_address ``` from_address( address: str, *, new_value: DataViewValue = None ) -> DataViewRecord ``` Build a DataViewRecord from a display address string. ### update_data_type ``` update_data_type() -> bool ``` Update the DataType based on the current address. Returns: | Type | Description | | ------ | ----------------------------------------------------------- | | `bool` | True if data_type was updated, False if address is invalid. | ### clear ``` clear() -> None ``` Clear all fields in this row. ## pyclickplc.make_dataview_record ``` make_dataview_record( address: str, *, new_value: DataViewValue = None ) -> DataViewRecord ``` Create a DataViewRecord from a display address with inferred defaults. ## pyclickplc.get_data_type_for_address ``` get_data_type_for_address(address: str) -> DataType | None ``` Get the DataType for an address. Parameters: | Name | Type | Description | Default | | --------- | ----- | --------------------------------- | ---------- | | `address` | `str` | Address string like "X001", "DS1" | *required* | Returns: | Type | Description | | ---------- | ----------- | | \`DataType | None\` | ## pyclickplc.validate_new_value ``` validate_new_value( display_str: str, data_type: DataType ) -> tuple[bool, str] ``` Validate a user-entered display string for the New Value column. # Server API **Tier:** Stable Core CLICK Modbus TCP simulator and server utilities. ## pyclickplc.ClickServer Modbus TCP server simulating a CLICK PLC. ### is_running ``` is_running() -> bool ``` Return True when the underlying listener transport is active. ### list_clients ``` list_clients() -> list[ServerClientInfo] ``` Return the currently connected Modbus TCP clients. ### disconnect_client ``` disconnect_client(client_id: str) -> bool ``` Disconnect a single client by pymodbus connection id. ### disconnect_all_clients ``` disconnect_all_clients() -> int ``` Disconnect all connected clients and return how many were closed. ### start ``` start() -> None ``` Start the Modbus TCP server. ### stop ``` stop() -> None ``` Stop the server gracefully. ### serve_forever ``` serve_forever() -> None ``` Start the server and block until stopped. ## pyclickplc.MemoryDataProvider Bases: `AddressNormalizerMixin` In-memory DataProvider for testing and simple use cases. ### get ``` get(address: str) -> PlcValue ``` Synchronous read convenience. ### set ``` set(address: str, value: PlcValue) -> None ``` Synchronous write convenience. ### bulk_set ``` bulk_set(values: dict[str, PlcValue]) -> None ``` Set multiple values at once. ## pyclickplc.ServerClientInfo Connected client metadata exposed by ClickServer. ## pyclickplc.run_server_tui ``` 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. # Service API **Tier:** Stable Core Synchronous service wrapper and polling lifecycle APIs. ## pyclickplc.ModbusService Synchronous wrapper over ClickClient with background polling. ### connect ``` connect( host: str, port: int = 502, *, device_id: int = 1, timeout: int = 1, ) -> None ``` Connect to a CLICK PLC endpoint. Parameters: | Name | Type | Description | Default | | ----------- | ----- | --------------------------- | ---------- | | `host` | `str` | PLC hostname or IP address. | *required* | | `port` | `int` | Modbus TCP port. | `502` | | `device_id` | `int` | Modbus unit/device ID. | `1` | | `timeout` | `int` | Client timeout in seconds. | `1` | Raises: | Type | Description | | ------------ | ----------------------------- | | `OSError` | Connection attempt failed. | | `ValueError` | Invalid connection arguments. | ### disconnect ``` disconnect() -> None ``` Disconnect from the PLC and stop the service loop thread. ### close ``` close() -> None ``` Alias for :meth:`disconnect`. ### set_poll_addresses ``` set_poll_addresses(addresses: Iterable[str]) -> None ``` Replace the active polling address set. Parameters: | Name | Type | Description | Default | | ----------- | --------------- | ----------------------------------- | ---------- | | `addresses` | `Iterable[str]` | Address strings to poll each cycle. | *required* | Raises: | Type | Description | | ------------ | ----------------------- | | `ValueError` | Any address is invalid. | ### clear_poll_addresses ``` clear_poll_addresses() -> None ``` Clear the active polling address set. ### stop_polling ``` stop_polling() -> None ``` Pause polling while keeping the current configured address set. ### read ``` read(addresses: Iterable[str]) -> ModbusResponse[PlcValue] ``` Synchronously read one or more addresses. Parameters: | Name | Type | Description | Default | | ----------- | --------------- | ------------------------ | ---------- | | `addresses` | `Iterable[str]` | Address strings to read. | *required* | Returns: | Type | Description | | -------------------------- | ------------------------------------------------------- | | `ModbusResponse[PlcValue]` | ModbusResponse keyed by canonical normalized addresses. | Raises: | Type | Description | | ------------ | ------------------------------------------------- | | `ValueError` | Any address is invalid. | | `OSError` | Not connected or transport/protocol read failure. | ### write ``` write( values: Mapping[str, PlcValue] | Iterable[tuple[str, PlcValue]], ) -> list[WriteResult] ``` Synchronously write one or more address values. Parameters: | Name | Type | Description | Default | | -------- | ------------------------ | ---------------------------------- | ---------------------------------------------- | | `values` | \`Mapping[str, PlcValue] | Iterable\[tuple[str, PlcValue]\]\` | Mapping or iterable of (address, value) pairs. | Returns: | Type | Description | | ------------------- | --------------------------------------------------------------- | | `list[WriteResult]` | Per-item write outcomes preserving input order. | | `list[WriteResult]` | Validation and write failures are reported in each WriteResult. | ## pyclickplc.ReconnectConfig ClickClient reconnect behavior used by ModbusService. ## pyclickplc.ConnectionState Bases: `Enum` Connection state notifications emitted by ModbusService. ## pyclickplc.WriteResult Bases: `TypedDict` Per-address write outcome. # Validation API **Tier:** Stable Core Nickname/comment/initial-value validators and system nickname constants. ## pyclickplc.SYSTEM_NICKNAME_TYPES ``` SYSTEM_NICKNAME_TYPES: frozenset[str] = frozenset( {"SC", "SD", "X"} ) ``` ## pyclickplc.validate_nickname ``` 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. Parameters: | Name | Type | Description | Default | | ------------- | ----- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nickname` | `str` | The nickname to validate | *required* | | `system_bank` | \`str | None\` | Optional system bank context: - "SC"/"SD": allows PLC system punctuation - "X": allows only \_IO... style system names - None: standard user nickname rules | Returns: | Type | Description | | ------------------ | ----------------------------------------------------------------- | | `tuple[bool, str]` | Tuple of (is_valid, error_message) - error_message is "" if valid | ## pyclickplc.validate_comment ``` validate_comment(comment: str) -> tuple[bool, str] ``` Validate comment length. Does NOT check block tag uniqueness -- that requires blocks.py context. Parameters: | Name | Type | Description | Default | | --------- | ----- | ----------------------- | ---------- | | `comment` | `str` | The comment to validate | *required* | Returns: | Type | Description | | ------------------ | ----------------------------------------------------------------- | | `tuple[bool, str]` | Tuple of (is_valid, error_message) - error_message is "" if valid | ## pyclickplc.validate_initial_value ``` validate_initial_value( initial_value: str, data_type: int ) -> tuple[bool, str] ``` Validate an initial value against the data type rules. Parameters: | Name | Type | Description | Default | | --------------- | ----- | ----------------------------------------------------------------- | ---------- | | `initial_value` | `str` | The initial value string to validate | *required* | | `data_type` | `int` | The DataType number (0=bit, 1=int, 2=int2, 3=float, 4=hex, 6=txt) | *required* | Returns: | Type | Description | | ------------------ | ----------------------------------------------------------------- | | `tuple[bool, str]` | Tuple of (is_valid, error_message) - error_message is "" if valid |