diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c6cc8..389b091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -**Project handover pack + noise-reduction refinements**, both driven by a second real signed -Zennio project (a 3646-GA multi-vendor villa, 5× larger, no ETS Functions). +_Nothing yet._ + +## [0.3.0] — 2026-07-01 + +**Spec→structure: a device library and a design methodology, plus the as-built handover pack.** +This release turns the tool from a `.knxproj` *validator* into a *design* aid: from a project +spec you can now expand each device into its group-address recipe and reason about the whole +structure. Shipped alongside the Track B project handover pack and a set of noise-reduction +refinements, the latter two driven by a second real signed Zennio project (a 3646-GA +multi-vendor villa, 5× larger, no ETS Functions). ### Added +- **Device library + `decompose_device` — one device is not one GA** (`device_library.py`, new + MCP tools `decompose_device` + `list_device_recipes`). Each actuator channel expands into a + set of communication objects — command, status, dimming (3.007), absolute value/position + (5.001), HVAC mode (20.102/20.105), colour (232.600/251.600) — each with its own DPT. Given a + manufacturer order number, device type or alias (e.g. `ZDIDBDX4`, `dimmer`, `JRA/S`, + `presence detector`) and a channel count, the tool returns the objects a professional + typically wires per channel and the total GA count, so a spec/ТЗ device list can be turned + into a group-address structure. Recipes cover switch, dimmer, RGBW LED, shutter/blind, + floor-heating, AC gateway, DALI, presence, metering, leak and touch-panel families across + Zennio + ABB. Recipes are **generic vendor facts** compiled from KNX manufacturer ETS product + databases and public documentation — the *typical-wired* subset, not the full selectable + master menu. New regression tests cover the dimmer/shutter/panel/unknown paths. +- **`docs/spec-to-structure.md` — the spec→structure methodology.** Documents the reverse of + validation (design a group-address structure from a spec), the `decompose_device` pipeline, + and an honest, measured account of what is reproducible from a spec (~90 %: taxonomy, domains, + logic structure, command/status pairing, DPT discipline) versus what is not (the exact + per-device object count — an integrator parameterisation choice that varies 2–9× between + projects; predict a range, never a false-precise number). - **`generate_handover_pack` — the as-built commissioning deliverable** (Track B). From a read-only `.knxproj` it assembles `handover.md` (equipment inventory by manufacturer, group-address map by domain, command/status feedback coverage %, KNX Secure scope, QA state), @@ -162,7 +188,8 @@ Initial public beta. - Tested end-to-end on a synthetic project only; real-world `.knxproj` testing is ongoing (see the call for testers in the README). -[Unreleased]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/NickoScope/nickol-knx-mcp/compare/v0.1.0...v0.1.1 diff --git a/docs/spec-to-structure.md b/docs/spec-to-structure.md new file mode 100644 index 0000000..ff2b403 --- /dev/null +++ b/docs/spec-to-structure.md @@ -0,0 +1,57 @@ +# From spec (ТЗ) to group-address structure + +`nickol-knx-mcp` reads and validates a finished `.knxproj`. But the harder, higher-value +direction is the reverse: **from a project spec, design the group-address structure** a +professional would build. This note describes the method and the tools that support it. + +## Why one device ≠ one group address +A spec says "4-channel dimmer, 8 shutters, AC in every room". Each of those expands into a +*set* of communication objects, each with its own DPT: + +- a **dimmer channel** → on/off · on/off-status · relative-dim (3.007) · absolute value + (5.001) · brightness-status (5.001) +- a **shutter channel** → up/down (1.008) · stop (1.010) · position (5.001) · position-status +- an **AC unit** → on/off · mode (20.105) · setpoint (9.001) · fan · ambient-temp · statuses + +The `decompose_device` tool encodes these recipes: + +``` +decompose_device("ZDIDBDX4", channels=4) → 5 objects/channel × 4 = 20 GA (with DPTs) +decompose_device("JRA/S") → ABB shutter recipe (1.008/1.010/5.001) +decompose_device("Z50") → panel: 0 new GAs (references existing) +``` + +`list_device_recipes` lists the built-in library (Zennio + ABB families; more via the same +schema). + +## The pipeline +``` +spec / ТЗ + → device list + quantities (what equipment, per room) + → decompose_device per type (device → object recipe + DPT) + → command/status discipline (every actuator gets its status GA) + → logic layer (central macros, zone groups, scenes, + motion/HVAC/shutter automation, reserves) + → validated group-address structure (analyze_all → 0 errors) + → generate_ets_group_addresses (XML) (import into ETS) +``` + +## What is reproducible from a spec — and what is not (measured, honestly) +Validated by designing a full structure from a real project's spec and comparing to the +as-built export: + +- **Reproducible ≈ 90%+**: the *taxonomy* (function-domain main groups), *domains*, the + *logic structure*, *command/status pairing*, and *DPT discipline*. +- **NOT derivable from a spec**: the exact *object count* per device. Integrators enable a + subset of each device's master object menu, and how large that subset is varies **2–9× + between projects** — it is a design choice living in the ETS parameter config, not in the + spec. So the device library gives the object *menu*; the exact count needs the real + project. Predict a **range**, never a false-precise number. + +Bottom line: a spec plus this method reproduces the *structure* of a professional design to +~90%; the last mile is the integrator's per-device parameterisation. + +## Safety +Everything here is design-time and read-only. The server has no KNX/IP bus libraries — it +cannot reach an installation. Always produce a report and review it before importing into +ETS or deploying to Home Assistant. diff --git a/nickol_knx_mcp/__init__.py b/nickol_knx_mcp/__init__.py index 6f7513c..420ce24 100644 --- a/nickol_knx_mcp/__init__.py +++ b/nickol_knx_mcp/__init__.py @@ -1,2 +1,2 @@ """nickol-knx-mcp: design-time KNX/ETS project assistant MCP server.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/nickol_knx_mcp/device_library.py b/nickol_knx_mcp/device_library.py new file mode 100644 index 0000000..6426583 --- /dev/null +++ b/nickol_knx_mcp/device_library.py @@ -0,0 +1,223 @@ +"""Device library — per-device group-address decomposition recipes. + +A KNX device is not "one GA". Each channel/output of an actuator expands into a set +of communication objects (command, status, dimming, position, mode, …), each with its +own DPT. This module maps a device's ``order_number`` (or a device *type*) to its +**design-time decomposition recipe** — the objects a professional integrator typically +wires per channel — so a spec/ТЗ that says "4-channel dimmer" can be expanded into the +right group-address set. + +Provenance: recipes are compiled from KNX manufacturer ETS product databases and public +product documentation (Zennio, ABB), cross-checked against manufacturer "communication +objects" tables. Counts are the *typical wired* set, not the full selectable master menu +(integrators enable a subset — see the ``enablement`` note). DPTs are the canonical KNX +types per function. Where a manufacturer datum is not publicly confirmable it is omitted +rather than guessed. +""" + +from __future__ import annotations + +from typing import Any, Optional + +# Each recipe: per-channel/per-unit object list [(function, dpt, role)]. +# role: cmd | status | param. These are the *design* objects (typical wired set). +_RECIPES: dict[str, dict[str, Any]] = { + # ---- Zennio ------------------------------------------------------------ + "switch_output": { + "aliases": ["ZIO-MB24", "ZIOMB24V2", "ZIO-MB8P", "ZIO-MB16P", "ZIOMB16V3", + "SA/S", "switch actuator"], + "unit": "output", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ], + "note": "Switch output. Zennio master menu ~11 obj/output (adds scene, timer, " + "lock, logic); ABB labels status 1.001. Typical wired: cmd + status.", + }, + "dimmer_channel": { + "aliases": ["ZDIDBDX4", "DIMinBOX", "ZDINDX2", "ZDINDX4", "NarrowDIM", + "UD/S", "dimmer"], + "unit": "channel", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ("Relative dimming", "3.007", "cmd"), + ("Absolute dimming / value", "5.001", "cmd"), + ("Brightness (status)", "5.001", "status"), + ], + "note": "Dimmer channel. Zennio DIMinBOX DX4 master = 23 obj/channel " + "(adds scenes, timer, alarms, diagnostics). Typical wired = these 5.", + }, + "led_rgbw": { + "aliases": ["ZDI-RGBDX4", "Lumento"], + "unit": "channel", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ("Brightness value", "5.001", "cmd"), + ("Brightness (status)", "5.001", "status"), + ("Colour RGB(W)", "232.600", "cmd"), + ], + "note": "RGB/RGBW LED. Colour DPT 232.600 (RGB) / 251.600 (RGBW). " + "CCT/tunable-white via a colour-temperature 5.001/7.600 object.", + }, + "shutter_channel": { + "aliases": ["ZIO–MBSHU8", "ZIO–MBSHU4", "MAXinBOX SHUTTER", "JRA/S", "JSB/S", + "shutter", "blind"], + "unit": "channel", + "objects": [ + ("Up/Down", "1.008", "cmd"), + ("Stop / step", "1.010", "cmd"), + ("Position", "5.001", "cmd"), + ("Position (status)", "5.001", "status"), + ("Lock", "1.003", "param"), + ], + "note": "Shutter/blind. ABB uses 1.008 (blind) / 1.007 (shutter) on the move " + "object; venetian blinds add a slat-position 5.001 object.", + }, + "floor_heating_zone": { + "aliases": ["ZCL-8HT230", "ZCL-4HT230", "HeatingBOX", "floor heating"], + "unit": "zone", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ("Setpoint °C", "9.001", "cmd"), + ("Setpoint (status)", "9.001", "status"), + ("HVAC mode", "20.102", "cmd"), + ("HVAC mode (status)", "20.102", "status"), + ("Valve / contactor", "1.001", "cmd"), + ("Valve (status)", "1.001", "status"), + ], + "note": "Floor-heating zone (8-object recipe). Convector/radiator = 5 " + "(setpoint·status·mode·status·valve).", + }, + "ac_gateway": { + "aliases": ["ZN1CL-KLIC-DI", "KLIC-DI", "KLIC-DA", "DK-AC-KNX", "FCC/S", + "FCA/S", "air conditioning"], + "unit": "unit", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ("Mode", "20.105", "cmd"), + ("Mode (status)", "20.105", "status"), + ("Setpoint °C", "9.001", "cmd"), + ("Setpoint (status)", "9.001", "status"), + ("Fan speed", "5.001", "cmd"), + ("Ambient temperature", "9.001", "status"), + ], + "note": "AC split-unit gateway. Mode 20.105 (heat/cool/fan/dry/auto) or " + "20.102 (HVAC mode). Fan-coil (FCC/S) uses 20.102.", + }, + "dali_group": { + "aliases": ["ZDIDLIV2", "DALI-BOX", "DG/S", "DALI"], + "unit": "group", + "objects": [ + ("On/Off", "1.001", "cmd"), + ("On/Off (status)", "1.001", "status"), + ("Relative dimming", "3.007", "cmd"), + ("Brightness value", "5.001", "cmd"), + ("Brightness (status)", "5.001", "status"), + ], + "note": "DALI light group. Gateway addresses up to 64 ballasts / 16 groups; " + "per-group recipe ≈ dimmer.", + }, + "presence_detector": { + "aliases": ["ZPDEZTPVT", "EyeZen", "presence", "motion"], + "unit": "detector", + "objects": [ + ("Movement / presence", "1.001", "cmd"), + ("Movement (status)", "1.001", "status"), + ("Luminosity (lux)", "9.004", "status"), + ("Lock", "1.003", "param"), + ("Scene output", "18.001", "cmd"), + ], + "note": "Presence/motion. Zennio master ~10 obj (adds timers, second lock, " + "test-mode, external-on, threshold). Constant-light uses 5.010.", + }, + "water_meter": { + "aliases": ["ZRX-KCI4S0", "KCI", "S0 meter", "meter"], + "unit": "input", + "objects": [ + ("Total consumption", "13.013", "status"), + ("This month", "13.013", "status"), + ("Last month", "13.013", "status"), + ("Reset", "1.001", "cmd"), + ("Start value", "13.013", "param"), + ("Instant / pulse", "13.013", "status"), + ], + "note": "Pulse/S0 meter. DPT depends on medium: energy 13.013, volume 13.x.", + }, + "leak_sensor": { + "aliases": ["WS", "leak", "water sensor"], + "unit": "sensor", + "objects": [ + ("Leak alarm", "1.005", "status"), + ("Confirmation", "1.001", "cmd"), + ], + "note": "Leak sensor. Pairs with a water-shutoff valve (open/close 1.001 + status).", + }, + "panel": { + "aliases": ["ZVIZ50", "ZVIZ35", "Z70", "Z50", "Z35", "Flat", "ZVIF", "panel", + "keypad", "touch panel"], + "unit": "panel", + "objects": [], + "note": "Touch panel / keypad. References EXISTING GAs (its buttons drive other " + "devices' objects); creates ~0 new GAs of its own. Buttons are multi-DPT " + "(1.001 / 1.008 / 3.007 / 5.010 / 18.001) bound to the target function.", + }, +} + + +def _lookup(key: str) -> Optional[str]: + """Resolve an order_number / type / alias to a recipe key (case-insensitive).""" + k = (key or "").strip().lower() + if not k: + return None + if k in _RECIPES: + return k + for name, rec in _RECIPES.items(): + for a in rec.get("aliases", []): + al = a.lower() + if k == al or al in k or k in al: + return name + return None + + +def decompose_device(order_number: str, channels: int = 1) -> dict[str, Any]: + """Return the group-address decomposition recipe for a device. + + Args: + order_number: manufacturer order number, device type, or alias + (e.g. 'ZIO-MB24', 'dimmer', 'JRA/S', 'presence detector'). + channels: number of channels/outputs/zones to expand (default 1). + + Returns a recipe with per-unit objects and the total GA count for ``channels``. + """ + key = _lookup(order_number) + if key is None: + return { + "matched": False, + "query": order_number, + "note": "No recipe found. Known types: " + ", ".join(sorted(_RECIPES)), + } + rec = _RECIPES[key] + objs = [{"function": f, "dpt": d, "role": r} for (f, d, r) in rec["objects"]] + return { + "matched": True, + "query": order_number, + "type": key, + "unit": rec["unit"], + "objects_per_unit": len(objs), + "objects": objs, + "channels": channels, + "total_ga": len(objs) * max(channels, 1), + "note": rec["note"], + "provenance": "compiled from KNX manufacturer ETS databases + public docs; " + "typical-wired set (integrators enable a subset of the master menu)", + } + + +def list_recipes() -> list[dict[str, Any]]: + """List all device decomposition recipes (type, unit, objects/unit).""" + return [{"type": k, "unit": v["unit"], "objects_per_unit": len(v["objects"]), + "aliases": v["aliases"]} for k, v in sorted(_RECIPES.items())] diff --git a/nickol_knx_mcp/server.py b/nickol_knx_mcp/server.py index f4c8cbd..053c9f7 100644 --- a/nickol_knx_mcp/server.py +++ b/nickol_knx_mcp/server.py @@ -21,6 +21,7 @@ from .generate_ha import generate_ha_yaml from .generate_ets import generate_ets_csv, generate_ets_xml from .report import build_report from .handover import build_handover +from .device_library import decompose_device as _decompose_device, list_recipes mcp = FastMCP("nickol-knx") @@ -264,6 +265,25 @@ def generate_handover_pack(output_dir: Optional[str] = None) -> dict[str, Any]: return out +@mcp.tool() +def decompose_device(order_number: str, channels: int = 1) -> dict[str, Any]: + """Expand a device into its group-address decomposition recipe. + + A KNX actuator channel is not one GA — it expands into command/status/dimming/ + position/mode objects, each with its DPT. Given a device order number, type or + alias (e.g. 'ZIO-MB24', 'dimmer', 'JRA/S', 'presence detector') and a channel + count, returns the objects a professional wires per channel and the total GA + count. Use when turning a spec/ТЗ device list into a group-address structure. + """ + return _decompose_device(order_number, channels=channels) + + +@mcp.tool() +def list_device_recipes() -> list[dict[str, Any]]: + """List the device decomposition recipes in the built-in device library.""" + return list_recipes() + + @mcp.tool() def workspace_info() -> dict[str, Any]: """Show the confined output workspace and the safety guarantees.""" diff --git a/pyproject.toml b/pyproject.toml index 5a3ec00..9963785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nickol-knx-mcp" -version = "0.2.0" +version = "0.3.0" description = "Design-time KNX/ETS6 project assistant as an MCP server (parse .knxproj, validate, generate HA YAML + ETS CSV/XML). No live bus access." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index eae67e4..eb1679a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -468,3 +468,22 @@ assert "KNX topology" in _svg import xml.dom.minidom as _mdom _mdom.parseString(_svg) print("OK: handover pack — 7 sections, inventory, summary, well-formed SVG") + +# --------------------------------------------------------------------------- # +# Regression: device-library decomposition recipes. +# --------------------------------------------------------------------------- # +print("\n=== REGRESSION: device-library decompose_device ===") +from nickol_knx_mcp.device_library import decompose_device, list_recipes +_dim=decompose_device("ZDIDBDX4", channels=4) # DIMinBOX DX4, 4 channels +assert _dim["matched"] and _dim["type"]=="dimmer_channel", _dim +assert any(o["dpt"]=="3.007" for o in _dim["objects"]), "dimmer must expose 3.007 rel-dim" +assert any(o["dpt"]=="5.001" for o in _dim["objects"]), "dimmer must expose 5.001 abs" +assert _dim["total_ga"]==_dim["objects_per_unit"]*4 +_sh=decompose_device("JRA/S") # ABB shutter alias +assert _sh["type"]=="shutter_channel" and any(o["dpt"]=="1.008" for o in _sh["objects"]) +_p=decompose_device("Z50") # panel → 0 new GAs +assert _p["type"]=="panel" and _p["objects_per_unit"]==0, "panel should create 0 GAs" +_miss=decompose_device("totally-unknown-xyz") +assert _miss["matched"] is False +assert len(list_recipes())>=10 +print("OK: decompose_device — dimmer 3.007+5.001, shutter 1.008, panel=0, unknown handled")