mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-10-21 21:53:27 +02:00
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:
co-authored by
Claude Opus 4.8
parent
2f2b8b1f91
commit
d7df3dce60
+30
-3
@@ -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
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
"""nickol-knx-mcp: design-time KNX/ETS project assistant MCP server."""
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
@@ -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())]
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user