feat: device-library decompose_device + spec→structure methodology (v0.3.0)

Turn the tool from a .knxproj validator into a design aid.

- device_library.py + decompose_device / list_device_recipes MCP tools:
  expand a device (order number / type / alias) into its group-address
  recipe — command/status/dimming/position/mode objects with DPTs —
  across Zennio + ABB families. Generic vendor facts, typical-wired set.
- docs/spec-to-structure.md: the spec→structure methodology, with a
  measured account of what a spec reproduces (~90%) vs the per-device
  object count it cannot (2–9× per-project parameterisation).
- Ship alongside the Track B generate_handover_pack and the de-noise
  refinements accumulated since 0.2.0.
- CHANGELOG 0.3.0; version bump 0.2.0 → 0.3.0; new device-library tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay Miroshnichenko
2026-07-01 18:33:08 +02:00
co-authored by Claude Opus 4.8
parent 2f2b8b1f91
commit d7df3dce60
7 changed files with 351 additions and 5 deletions
+30 -3
View File
@@ -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
+57
View File
@@ -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.
+1 -1
View File
@@ -1,2 +1,2 @@
"""nickol-knx-mcp: design-time KNX/ETS project assistant MCP server."""
__version__ = "0.2.0"
__version__ = "0.3.0"
+223
View File
@@ -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())]
+20
View File
@@ -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."""
+1 -1
View File
@@ -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"
+19
View File
@@ -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")