Files
Nikolay Miroshnichenko 264767f91f ci: ruff + mypy gates, Python 3.13/3.14 in the matrix, Dependabot
The CI ran tests only. Added a lint job with two narrow gates:

ruff with select = E9,F — syntax errors and pyflakes (undefined names, unused
imports, broken f-strings). Style rules are deliberately off: the full default
set flagged 202 things, almost all cosmetic, and a gate nobody reads is not a
gate. Fixed the 9 findings it did have (8 unused imports, 1 empty f-string).

mypy, clean at 13 errors fixed. None of the 13 was a live bug, so every fix is
an annotation or a local rename: the two accumulators in advanced, the block map
in appprog_parser, a reused loop name in project and in repair, a TypedDict lost
through dict(), a mixed-type summary dict in handover. Two are worth naming.
_CH_TOKEN_RE now captures the letter prefix directly instead of re-matching its
own capture, which removes an unguarded .group() on a possibly-None match; the
regex accepts exactly the same strings. repair's status-DPT lookup now spells out
the None case, which is what dict.get already did since None is not a key there.

Verified the fixes moved nothing: the full suite passes, and the corpus guard
reports no drift across all six real projects, the 3646-GA one included.

Matrix extended to 3.13 and 3.14; both were smoke-tested locally first.
Dependabot config for pip and github-actions, weekly and grouped so a quiet week
is one PR. Dependabot security updates and CodeQL default setup enabled in the
repository settings.
2026-09-12 21:36:23 +02:00

864 lines
40 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Room Template Library — R1 (vertical slice).
Compose a NEW KNX project from a list of parametrised *room templates*.
Design contract (see ``room_templates/SCHEMA.md``):
* A room template is a locale-neutral YAML file. Its identity is a semantic
``slot_id`` (an ASCII identifier), **never** its human name — ``labels.ru`` /
``labels.en`` are presentation only, so a translation never changes identity.
* Each template declares ``parameters`` (window count, lighting-circuit count,
…). ``area_m2`` is a **hint** that seeds defaults, carrying provenance; it is
never treated as a normative fact and, in R1, never changes GA counts.
* Each template declares functional **slots**. A slot carries per-slot
``basic`` / ``comfort`` presets (NOT one monolithic room level), so a house
can mix "comfort climate + basic lighting" per room.
* Slots reference **function types** (lighting_switch, lighting_dimmer,
shutter, climate_floor, presence). A function type expands into a fixed set
of KNX communication objects (command + status), each with a canonical DPT.
The composition pipeline is deliberately layered and pure:
templates + room specs
-> resolved functional model (IR dataclasses; NOT ``GARecord``)
-> address allocation (main = domain, middle = role, sub sequential)
-> a real ``.knxproj`` ZIP (ETS project/22 XML)
-> re-read through :func:`project.load_project` (the SAME reader used for
third-party projects) so the generator never touches the classifier.
Everything downstream (ETS export, linters, manifest, BOM) runs off the re-read
project, exactly like a hand-made ETS file. The server never writes to a bus.
R1 scope: NEW projects only, dry-run by default. Docking into an existing
project (allocation lockfile, drift detection) and the exact device-resolver are
R2 — see ``docs/roadmap/room-library/implementation-plan.md``.
"""
from __future__ import annotations
import io
import json
import os
import zipfile
from dataclasses import dataclass, field
from typing import Any, Optional
# --------------------------------------------------------------------------- #
# Public format contract
# --------------------------------------------------------------------------- #
SCHEMA_VERSION = 1
SUPPORTED_LANGUAGES = ("ru", "en")
# Default 3-level taxonomy (CLAUDE.md methodology — main group = function domain).
DOMAIN_MAIN: dict[str, int] = {
"central": 0,
"lighting": 1,
"shutter": 2,
"hvac": 3,
"sensor": 4,
"energy": 5,
"diagnostics": 6,
"reserve": 7,
}
DOMAIN_LABELS: dict[str, dict[str, str]] = {
"central": {"ru": "Центральные функции", "en": "Central"},
"lighting": {"ru": "Освещение", "en": "Lighting"},
"shutter": {"ru": "Шторы", "en": "Shutters"},
"hvac": {"ru": "Климат", "en": "HVAC"},
"sensor": {"ru": "Датчики", "en": "Sensors"},
"energy": {"ru": "Энергия", "en": "Energy"},
"diagnostics": {"ru": "Диагностика", "en": "Diagnostics"},
"reserve": {"ru": "Резерв", "en": "Reserve"},
}
# Middle group = role/sub-function inside a domain. Command roles and their
# feedback roles live in DISTINCT, predictable middles (the pairing engine keys
# off name tokens, not adjacency — see CLAUDE.md).
MIDDLE_ROLE: dict[str, dict[str, int]] = {
"lighting": {"cmd_onoff": 0, "dimming": 1, "brightness_value": 2,
"status_onoff": 3, "brightness_status": 4},
"shutter": {"move": 0, "stop": 1, "position": 2, "slat": 3,
"position_status": 4, "slat_status": 5, "safety_lock": 6},
# fan(3) is a reserved role slot (no template function uses it yet); airing(6)
# carries the ventilation stage, valve(7) the modulating actuating-value.
"hvac": {"onoff": 0, "mode": 1, "setpoint": 2, "fan": 3, "status": 4,
"actual_temp": 5, "airing": 6, "valve": 7},
"sensor": {"presence": 0, "illuminance": 1, "climate": 2},
"central": {"scene": 0, "command": 1},
}
MIDDLE_LABELS: dict[str, dict[int, dict[str, str]]] = {
"lighting": {0: {"ru": "Вкл/Выкл", "en": "On/Off"},
1: {"ru": "Диммирование", "en": "Dimming"},
2: {"ru": "Значение яркости", "en": "Brightness value"},
3: {"ru": "Статус вкл/выкл", "en": "On/Off status"},
4: {"ru": "Статус яркости", "en": "Brightness status"}},
"shutter": {0: {"ru": "Движение", "en": "Move"},
1: {"ru": "Стоп", "en": "Stop"},
2: {"ru": "Позиция", "en": "Position"},
3: {"ru": "Ламели", "en": "Slats"},
4: {"ru": "Статус позиции", "en": "Position status"},
5: {"ru": "Статус ламелей", "en": "Slat status"},
6: {"ru": "Блокировка (защита)", "en": "Safety lock"}},
"hvac": {0: {"ru": "Вкл/Выкл", "en": "On/Off"},
1: {"ru": "Режим", "en": "Mode"},
2: {"ru": "Уставка", "en": "Setpoint"},
3: {"ru": "Вентилятор", "en": "Fan"},
4: {"ru": "Статусы", "en": "Statuses"},
5: {"ru": "Температура факт", "en": "Actual temperature"},
6: {"ru": "Проветривание", "en": "Ventilation"},
7: {"ru": "Клапан", "en": "Valve"}},
"sensor": {0: {"ru": "Присутствие/Движение", "en": "Presence/Motion"},
1: {"ru": "Освещённость", "en": "Illuminance"},
2: {"ru": "Климат в комнатах", "en": "Room climate"}},
"central": {0: {"ru": "Сцены", "en": "Scenes"},
1: {"ru": "Центральные команды", "en": "Central commands"}},
}
MAX_SUB = 255 # 3-level sub range 0..255; sub 0 reserved as range anchor
MAX_MIDDLE = 7 # 3-level middle range 0..7
# --------------------------------------------------------------------------- #
# Function-type object model (function-first: objects, not devices).
# Each object: role_key, ru label, en label, dpt (main, sub), kind, domain,
# middle_role. Command objects are paired to their status object by name tokens.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ObjSpec:
role: str
label_ru: str
label_en: str
dpt_main: int
dpt_sub: int
kind: str # command | status | sensor
domain: str
middle_role: str
def label(self, language: str) -> str:
return self.label_ru if language == "ru" else self.label_en
@property
def dpst(self) -> str:
return f"DPST-{self.dpt_main}-{self.dpt_sub}"
FUNCTION_OBJECTS: dict[str, list[ObjSpec]] = {
"lighting_switch": [
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "lighting", "cmd_onoff"),
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "lighting", "status_onoff"),
],
"lighting_dimmer": [
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "lighting", "cmd_onoff"),
ObjSpec("dimming", "диммирование", "dimming", 3, 7, "command", "lighting", "dimming"),
ObjSpec("brightness", "яркость", "brightness", 5, 1, "command", "lighting", "brightness_value"),
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "lighting", "status_onoff"),
ObjSpec("brightness_status", "яркость статус", "brightness status", 5, 1, "status", "lighting", "brightness_status"),
],
"lighting_dali": [
# DALI is a downstream bus behind a gateway; from the KNX side a DALI group
# exposes the SAME objects as a dimmer channel (the DALI specifics live
# inside the gateway). No invented DPTs — a 1:1 clone of lighting_dimmer.
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "lighting", "cmd_onoff"),
ObjSpec("dimming", "диммирование", "dimming", 3, 7, "command", "lighting", "dimming"),
ObjSpec("brightness", "яркость", "brightness", 5, 1, "command", "lighting", "brightness_value"),
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "lighting", "status_onoff"),
ObjSpec("brightness_status", "яркость статус", "brightness status", 5, 1, "status", "lighting", "brightness_status"),
],
"shutter": [
ObjSpec("updown", "вверх/вниз", "up/down", 1, 8, "command", "shutter", "move"),
ObjSpec("stop", "стоп", "stop", 1, 10, "command", "shutter", "stop"),
ObjSpec("position", "позиция", "position", 5, 1, "command", "shutter", "position"),
ObjSpec("position_status", "позиция статус", "position status", 5, 1, "status", "shutter", "position_status"),
],
# Venetian / façade blind: a roller shutter PLUS slat-angle control (the roller
# `shutter` models only up/down/position). Slat command + slat status close the
# feedback gap for the angle; the wind/frost safety-lock is a WRITE-ONLY input
# to the actuator (no status object exists for it — see analyze._is_safety_input).
"shutter_venetian": [
ObjSpec("updown", "вверх/вниз", "up/down", 1, 8, "command", "shutter", "move"),
ObjSpec("stop", "стоп", "stop", 1, 10, "command", "shutter", "stop"),
ObjSpec("position", "позиция", "position", 5, 1, "command", "shutter", "position"),
ObjSpec("position_status", "позиция статус", "position status", 5, 1, "status", "shutter", "position_status"),
ObjSpec("slat", "ламели", "slat", 5, 1, "command", "shutter", "slat"),
ObjSpec("slat_status", "ламели статус", "slat status", 5, 1, "status", "shutter", "slat_status"),
ObjSpec("safety_lock", "блокировка защита", "safety lock", 1, 1, "command", "shutter", "safety_lock"),
],
"climate_floor": [
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "hvac", "onoff"),
ObjSpec("setpoint", "уставка", "setpoint", 9, 1, "command", "hvac", "setpoint"),
ObjSpec("mode", "режим", "mode", 20, 102, "command", "hvac", "mode"),
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "hvac", "status"),
ObjSpec("setpoint_status", "уставка статус", "setpoint status", 9, 1, "status", "hvac", "status"),
ObjSpec("mode_status", "режим статус", "mode status", 20, 102, "status", "hvac", "status"),
ObjSpec("actual_temp", "температура факт", "actual temperature", 9, 1, "sensor", "hvac", "actual_temp"),
# Modulating valve: a continuous actuating-value (%) command + its status.
# A real underfloor/radiator loop is driven continuously, not just on/off,
# and without a status HA cannot show the real valve opening.
ObjSpec("actuating_value", "уровень клапана", "actuating value", 5, 1, "command", "hvac", "valve"),
ObjSpec("actuating_value_status", "уровень клапана статус", "actuating value status", 5, 1, "status", "hvac", "status"),
],
# Ventilation / airing: a discrete airing STAGE (1-byte counter, DPT 5.010 —
# a device/integrator choice, see 01-dpt-derivation.md) plus its feedback, and
# the air-quality sensors that drive it (CO₂ 9.008, humidity 9.007).
"ventilation": [
ObjSpec("airing_stage", "ступень проветривания", "airing stage", 5, 10, "command", "hvac", "airing"),
ObjSpec("airing_stage_status", "ступень проветривания статус", "airing stage status", 5, 10, "status", "hvac", "status"),
ObjSpec("co2", "CO2", "CO2", 9, 8, "sensor", "sensor", "climate"),
ObjSpec("humidity", "влажность", "humidity", 9, 7, "sensor", "sensor", "climate"),
],
"presence": [
ObjSpec("occupancy", "присутствие", "occupancy", 1, 18, "sensor", "sensor", "presence"),
ObjSpec("illuminance", "освещённость", "illuminance", 9, 4, "sensor", "sensor", "illuminance"),
],
# Air-quality multisensor: self-reporting temperature + humidity + CO₂ (no
# command, so no status pair — these are read-only measurements).
"multisensor_air": [
ObjSpec("temperature", "температура", "temperature", 9, 1, "sensor", "sensor", "climate"),
ObjSpec("humidity", "влажность", "humidity", 9, 7, "sensor", "sensor", "climate"),
ObjSpec("co2", "CO2", "CO2", 9, 8, "sensor", "sensor", "climate"),
],
# House-level central functions (main 0). A scene control (18.001, no status —
# a scene has no single state) and per-domain all-off / all-on broadcasts
# (1.001, central macros — no single state to read back).
"central_scene": [
ObjSpec("scene", "сцена", "scene", 18, 1, "command", "central", "scene"),
],
"central_all_off": [
ObjSpec("all_off", "всё выкл", "all off", 1, 1, "command", "central", "command"),
],
"central_all_on": [
ObjSpec("all_on", "всё вкл", "all on", 1, 1, "command", "central", "command"),
],
}
# Function type -> candidate device recipe (device_library) for the BOM proposal.
BOM_RECIPE: dict[str, str] = {
"lighting_switch": "switch_output",
"lighting_dimmer": "dimmer_channel",
"lighting_dali": "dali_gateway_group",
"shutter": "shutter_channel",
"shutter_venetian": "shutter_channel",
"climate_floor": "floor_heating_zone",
"presence": "presence_detector",
}
class RoomLibraryError(ValueError):
"""Raised on an unrecoverable composition error (address exhaustion, bad
template, colliding room labels). Never a silent overflow."""
# --------------------------------------------------------------------------- #
# Resolved intermediate model (IR) — deliberately separate dataclasses, NOT the
# public GARecord. The IR is the composition's own contract; GARecord only
# appears AFTER the generated project is re-read by the standard loader.
# --------------------------------------------------------------------------- #
@dataclass
class ResolvedObject:
role: str
name: str
dpt_main: int
dpt_sub: int
kind: str
domain: str
main: int
middle: int
sub: int
@property
def address(self) -> str:
return f"{self.main}/{self.middle}/{self.sub}"
@property
def dpst(self) -> str:
return f"DPST-{self.dpt_main}-{self.dpt_sub}"
@dataclass
class ResolvedSlot:
slot_id: str
label: str
function_type: str
index: int # 1-based index within a multi-instance slot
objects: list[ResolvedObject] = field(default_factory=list)
@dataclass
class ResolvedRoom:
template_id: str
label: str
preset: str
params: dict[str, Any]
slots: list[ResolvedSlot] = field(default_factory=list)
@dataclass
class ComposedHouse:
project_name: str
language: str
rooms: list[ResolvedRoom] = field(default_factory=list)
@property
def objects(self) -> list[ResolvedObject]:
out: list[ResolvedObject] = []
for r in self.rooms:
for s in r.slots:
out.extend(s.objects)
return out
# --------------------------------------------------------------------------- #
# Template loading
# --------------------------------------------------------------------------- #
def _templates_dir() -> str:
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "room_templates")
def load_builtin_templates() -> dict[str, dict[str, Any]]:
"""Load every built-in room template keyed by its semantic ``slot_id``."""
import yaml
out: dict[str, dict[str, Any]] = {}
d = _templates_dir()
for fn in sorted(os.listdir(d)):
if not fn.endswith((".yaml", ".yml")):
continue
with open(os.path.join(d, fn), encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if isinstance(data, dict) and data.get("slot_id"):
data.setdefault("_source_file", fn)
out[data["slot_id"]] = data
return out
def load_template_file(path: str) -> dict[str, Any]:
import yaml
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
if not isinstance(data, dict):
raise RoomLibraryError(f"{path}: template root must be a mapping")
data.setdefault("_source_file", os.path.basename(path))
return data
# --------------------------------------------------------------------------- #
# Template validation (schema-valid rung of the validity ladder)
# --------------------------------------------------------------------------- #
_IDENT_OK = set("abcdefghijklmnopqrstuvwxyz0123456789_")
def _is_ident(s: Any) -> bool:
return isinstance(s, str) and bool(s) and set(s) <= _IDENT_OK
def _finding(severity: str, code: str, message: str, **extra: Any) -> dict[str, Any]:
f = {"severity": severity, "code": code, "message": message}
f.update(extra)
return f
def validate_template(tmpl: dict[str, Any]) -> dict[str, Any]:
"""Validate one room template against the R1 schema. Report-only.
Returns ``{ok, schema_version, slot_id, findings}``. ``ok`` is False when any
finding is an error.
"""
findings: list[dict[str, Any]] = []
sv = tmpl.get("schema_version")
if sv is None:
findings.append(_finding("error", "schema_version_missing",
"Template has no schema_version (breaks migration/compat)."))
elif sv != SCHEMA_VERSION:
findings.append(_finding("error", "schema_version_unsupported",
f"schema_version {sv} is not supported (this build understands "
f"{SCHEMA_VERSION})."))
slot_id = tmpl.get("slot_id")
if not _is_ident(slot_id):
findings.append(_finding("error", "slot_id_invalid",
"Template slot_id must be a locale-neutral ASCII identifier "
"(identity must not come from a human name)."))
labels = tmpl.get("labels") or {}
for lang in SUPPORTED_LANGUAGES:
if not (labels.get(lang) or "").strip():
findings.append(_finding("error", "label_missing",
f"Template is missing labels.{lang}.", lang=lang))
# parameters: each needs a default; area_m2 must be declared a hint with provenance.
params = tmpl.get("parameters") or {}
if not isinstance(params, dict):
findings.append(_finding("error", "parameters_invalid", "parameters must be a mapping."))
params = {}
for pname, pdef in params.items():
if not isinstance(pdef, dict) or "default" not in pdef:
findings.append(_finding("error", "parameter_no_default",
f"Parameter '{pname}' has no default.", parameter=pname))
continue
if pname == "area_m2":
if pdef.get("role") != "hint":
findings.append(_finding("error", "area_not_hint",
"area_m2 must be declared role: hint — area is a defaults "
"recommender, never a normative fact.", parameter=pname))
if not pdef.get("provenance"):
findings.append(_finding("warning", "area_no_provenance",
"area_m2 should carry provenance (source/user_overridden).",
parameter=pname))
# slots
slots = tmpl.get("slots") or []
if not isinstance(slots, list) or not slots:
findings.append(_finding("error", "slots_missing", "Template declares no slots."))
slots = []
seen_slots: set[str] = set()
for i, slot in enumerate(slots):
if not isinstance(slot, dict):
findings.append(_finding("error", "slot_invalid", f"Slot #{i} is not a mapping."))
continue
sid = slot.get("slot_id")
if not _is_ident(sid):
findings.append(_finding("error", "slot_id_invalid",
f"Slot #{i} slot_id must be a locale-neutral identifier.",
slot=sid))
elif sid in seen_slots:
findings.append(_finding("error", "slot_id_duplicate",
f"Slot id '{sid}' is used more than once.", slot=sid))
else:
seen_slots.add(str(sid))
slabels = slot.get("labels") or {}
for lang in SUPPORTED_LANGUAGES:
if not (slabels.get(lang) or "").strip():
findings.append(_finding("error", "slot_label_missing",
f"Slot '{sid}' is missing labels.{lang}.",
slot=sid, lang=lang))
presets = slot.get("presets") or {}
if not isinstance(presets, dict) or not {"basic", "comfort"} <= set(presets):
findings.append(_finding("error", "slot_presets_missing",
f"Slot '{sid}' must define both basic and comfort presets.",
slot=sid))
presets = presets if isinstance(presets, dict) else {}
for pname, pdef in presets.items():
if not isinstance(pdef, dict):
findings.append(_finding("error", "preset_invalid",
f"Slot '{sid}' preset '{pname}' must be a mapping.",
slot=sid, preset=pname))
continue
if not pdef.get("enabled", False):
continue # a disabled preset needs no function/multiplicity
ftype = pdef.get("function")
if ftype not in FUNCTION_OBJECTS:
findings.append(_finding("error", "unknown_function",
f"Slot '{sid}' preset '{pname}' uses unknown function "
f"'{ftype}'. Known: {sorted(FUNCTION_OBJECTS)}.",
slot=sid, preset=pname, function=ftype))
mult = pdef.get("multiplicity") or {}
if not _mult_is_valid(mult, params):
findings.append(_finding("error", "multiplicity_invalid",
f"Slot '{sid}' preset '{pname}' multiplicity {mult} is "
"invalid (need {fixed: N} or {param: <declared param>}).",
slot=sid, preset=pname))
errors = sum(1 for f in findings if f["severity"] == "error")
return {
"ok": errors == 0,
"schema_version": sv,
"slot_id": slot_id,
"source_file": tmpl.get("_source_file"),
"findings_summary": {"errors": errors,
"warnings": sum(1 for f in findings if f["severity"] == "warning"),
"total": len(findings)},
"findings": findings,
}
# Public alias (the MCP tool name mirrors this).
validate_room_template = validate_template
def _mult_is_valid(mult: Any, params: dict[str, Any]) -> bool:
if not isinstance(mult, dict):
return False
if "fixed" in mult:
return isinstance(mult["fixed"], int) and mult["fixed"] >= 0
if "param" in mult:
return mult["param"] in params
return False
# --------------------------------------------------------------------------- #
# Resolution: templates + room specs -> ResolvedRoom (function-first IR)
# --------------------------------------------------------------------------- #
def _canonical_key(spec: dict[str, Any]) -> str:
"""Order-independent identity of a room request, so permuting the room list
yields byte-identical output (permutation invariance)."""
return json.dumps({
"template": spec.get("template"),
"preset": spec.get("preset", "basic"),
"params": spec.get("params") or {},
"slot_presets": spec.get("slot_presets") or {},
"label": spec.get("label"),
}, sort_keys=True, ensure_ascii=False)
def _resolve_multiplicity(mult: dict[str, Any], params: dict[str, Any]) -> int:
if "fixed" in mult:
return max(0, int(mult["fixed"]))
if "param" in mult:
return max(0, int(params.get(mult["param"], 0)))
return 0
def _room_params(tmpl: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {}
for pname, pdef in (tmpl.get("parameters") or {}).items():
params[pname] = pdef.get("default")
for pname, val in (override or {}).items():
params[pname] = val
return params
def _instance_labels(rooms: list[dict[str, Any]], templates: dict[str, dict[str, Any]],
language: str) -> list[str]:
"""Assign a unique, human-readable zone label to each (canonically sorted)
room. Same template used N times -> base label, base 2, base 3 …"""
labels: list[str] = []
counts: dict[str, int] = {}
for spec in rooms:
tmpl = templates[spec["template"]]
base = spec.get("label") or (tmpl.get("labels") or {}).get(language) or spec["template"]
counts[base] = counts.get(base, 0) + 1
labels.append(base if counts[base] == 1 else f"{base} {counts[base]}")
return labels
def resolve_house(rooms: list[dict[str, Any]], templates: dict[str, dict[str, Any]],
language: str = "ru", project_name: str = "Room Library house") -> ComposedHouse:
"""Resolve room specs into an allocated :class:`ComposedHouse`.
Deterministic and permutation-invariant: rooms are sorted by a canonical key
before allocation, so the same *set* of rooms always yields identical
addresses regardless of input order.
"""
if language not in SUPPORTED_LANGUAGES:
raise RoomLibraryError(f"language must be one of {SUPPORTED_LANGUAGES}")
if not rooms:
raise RoomLibraryError("compose needs at least one room")
# composition-valid rung: every referenced template must exist & validate.
for spec in rooms:
tid = spec.get("template")
if tid not in templates:
raise RoomLibraryError(f"unknown template '{tid}'. Known: {sorted(templates)}")
v = validate_template(templates[tid])
if not v["ok"]:
raise RoomLibraryError(f"template '{tid}' is invalid: {v['findings']}")
ordered = sorted(rooms, key=_canonical_key)
labels = _instance_labels(ordered, templates, language)
if len(set(labels)) != len(labels):
raise RoomLibraryError(f"room labels collide: {labels} — give distinct 'label' overrides")
# sub counter per (main, middle); sub 0 is the range anchor, GAs start at 1.
sub_counter: dict[tuple[int, int], int] = {}
def alloc(domain: str, middle_role: str) -> tuple[int, int, int]:
main = DOMAIN_MAIN[domain]
middle = MIDDLE_ROLE[domain][middle_role]
if middle > MAX_MIDDLE:
raise RoomLibraryError(
f"middle group {middle} out of range for domain '{domain}'")
key = (main, middle)
nxt = sub_counter.get(key, 0) + 1
if nxt > MAX_SUB:
raise RoomLibraryError(
f"address space exhausted in {main}/{middle}/* (>{MAX_SUB} sub-addresses); "
"split the domain across more middle groups or projects — refusing to overflow")
sub_counter[key] = nxt
return main, middle, nxt
house = ComposedHouse(project_name=project_name, language=language)
for spec, zone_label in zip(ordered, labels):
tmpl = templates[spec["template"]]
room_preset = spec.get("preset", "basic")
slot_presets = spec.get("slot_presets") or {}
params = _room_params(tmpl, spec.get("params") or {})
room = ResolvedRoom(template_id=spec["template"], label=zone_label,
preset=room_preset, params=params)
for slot in tmpl.get("slots") or []:
sid = slot["slot_id"]
eff_preset = slot_presets.get(sid, room_preset)
pdef = (slot.get("presets") or {}).get(eff_preset) or {}
if not pdef.get("enabled", False):
continue
ftype = pdef["function"]
n = _resolve_multiplicity(pdef.get("multiplicity") or {}, params)
slot_label = (slot.get("labels") or {}).get(language, sid)
for idx in range(1, n + 1):
inst_label = slot_label if n == 1 else f"{slot_label} {idx}"
rslot = ResolvedSlot(slot_id=sid, label=inst_label,
function_type=ftype, index=idx)
for obj in FUNCTION_OBJECTS[ftype]:
main, middle, sub = alloc(obj.domain, obj.middle_role)
name = f"{zone_label} {inst_label} {obj.label(language)}"
rslot.objects.append(ResolvedObject(
role=obj.role, name=name,
dpt_main=obj.dpt_main, dpt_sub=obj.dpt_sub,
kind=obj.kind, domain=obj.domain,
main=main, middle=middle, sub=sub))
room.slots.append(rslot)
house.rooms.append(room)
return house
# --------------------------------------------------------------------------- #
# .knxproj ZIP writer (ETS project/22). We generate a real archive and re-read
# it with the standard loader; the generator never builds GARecord directly.
# --------------------------------------------------------------------------- #
_XML_ESCAPE = {"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}
def _esc(s: str) -> str:
return "".join(_XML_ESCAPE.get(c, c) for c in str(s))
def _master_xml() -> str:
"""knx_master.xml — copied from the shipped demo project when available,
otherwise a minimal stub (xknxproject only needs MediumType MT-0 resolvable)."""
for cand in (
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "examples", "demo-home", "demo-home.knxproj"),
):
cand = os.path.normpath(cand)
if os.path.isfile(cand):
try:
with zipfile.ZipFile(cand) as z:
return z.read("knx_master.xml").decode("utf-8")
except Exception: # noqa: BLE001
pass
# minimal fallback master
return ('<?xml version="1.0" encoding="utf-8"?>\n'
'<KNX xmlns="http://knx.org/xml/project/22">\n'
' <MasterData>\n'
' <MediumTypes><MediumType Id="MT-0" Name="TP" Number="0" /></MediumTypes>\n'
' </MasterData>\n</KNX>\n')
def build_knxproj_bytes(house: ComposedHouse, proj_id: str = "P-ROOMLIB") -> bytes:
"""Serialise a :class:`ComposedHouse` to a real ``.knxproj`` ZIP (bytes)."""
objs = house.objects
# group by main -> middle for GroupRanges
by_main: dict[int, dict[int, list[ResolvedObject]]] = {}
domain_by_main: dict[int, str] = {}
for o in objs:
by_main.setdefault(o.main, {}).setdefault(o.middle, []).append(o)
domain_by_main[o.main] = o.domain
out: list[str] = []
w = out.append
w('<?xml version="1.0" encoding="utf-8"?>')
w('<KNX xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" CreatedBy="nickol-knx-mcp" '
'ToolVersion="0.0.0" xmlns="http://knx.org/xml/project/22">')
w(f' <Project Id="{proj_id}">')
w(' <Installations>')
w(f' <Installation Name="" DefaultLine="{proj_id}-0_L-1">')
w(' <Topology>')
w(f' <Area Id="{proj_id}-0_A-1" Address="1" Puid="1">')
w(f' <Line Id="{proj_id}-0_L-1" Address="1" Puid="2">'
f'<Segment Id="{proj_id}-0_S-1" Number="0" MediumTypeRefId="MT-0" Puid="3" /></Line>')
w(' </Area>')
w(' </Topology>')
w(' <GroupAddresses>')
w(' <GroupRanges>')
for main in sorted(by_main):
domain = domain_by_main[main]
mname = DOMAIN_LABELS.get(domain, {}).get(house.language, domain)
rs, re_ = main << 11, (main << 11) + 2047
w(f' <GroupRange Id="{proj_id}-0_GR-{main}" RangeStart="{rs}" '
f'RangeEnd="{re_}" Name="{_esc(mname)}" Puid="{100 + main}">')
for middle in sorted(by_main[main]):
midlabel = MIDDLE_LABELS.get(domain, {}).get(middle, {}).get(
house.language, f"Middle {middle}")
ms, me = (main << 11) | (middle << 8), ((main << 11) | (middle << 8)) + 255
w(f' <GroupRange Id="{proj_id}-0_GR-{main}_{middle}" '
f'RangeStart="{ms}" RangeEnd="{me}" Name="{_esc(midlabel)}" '
f'Puid="{1000 + main * 8 + middle}">')
for o in sorted(by_main[main][middle], key=lambda x: x.sub):
raw = (o.main << 11) | (o.middle << 8) | o.sub
w(f' <GroupAddress Id="{proj_id}-0_GA-{raw}" Address="{raw}" '
f'Name="{_esc(o.name)}" DatapointType="{o.dpst}" Puid="{10000 + raw}" />')
w(' </GroupRange>')
w(' </GroupRange>')
w(' </GroupRanges>')
w(' </GroupAddresses>')
w(' </Installation>')
w(' </Installations>')
w(' </Project>')
w('</KNX>')
zero_xml = "\n".join(out)
project_xml = (
'<?xml version="1.0" encoding="utf-8"?>\n'
'<KNX xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" CreatedBy="nickol-knx-mcp" '
'ToolVersion="0.0.0" xmlns="http://knx.org/xml/project/22">\n'
f' <Project Id="{proj_id}">\n'
f' <ProjectInformation Name="{_esc(house.project_name)}" '
'GroupAddressStyle="ThreeLevel" '
'Comment="Generated by nickol-knx-mcp Room Library (R1) — review before ETS import" '
'ProjectType="House" />\n'
' </Project>\n'
'</KNX>\n')
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(f"{proj_id}/project.xml", project_xml)
z.writestr(f"{proj_id}/0.xml", zero_xml)
z.writestr("knx_master.xml", _master_xml())
z.writestr(f"{proj_id}.signature", "nickol-knx-mcp room-library synthetic (unsigned)")
return buf.getvalue()
# --------------------------------------------------------------------------- #
# Manifest + BOM
# --------------------------------------------------------------------------- #
def build_manifest(house: ComposedHouse) -> dict[str, Any]:
"""Deterministic allocation manifest (the negative-oracle surface)."""
rooms_out: list[dict[str, Any]] = []
total_ga = 0
for room in house.rooms:
slots_out: list[dict[str, Any]] = []
for slot in room.slots:
slots_out.append({
"slot_id": slot.slot_id,
"label": slot.label,
"function": slot.function_type,
"index": slot.index,
"objects": [{"role": o.role, "address": o.address, "name": o.name,
"dpt": f"{o.dpt_main}.{o.dpt_sub:03d}", "kind": o.kind,
"domain": o.domain} for o in slot.objects],
})
total_ga += len(slot.objects)
rooms_out.append({
"template": room.template_id,
"label": room.label,
"preset": room.preset,
"params": room.params,
"slots": slots_out,
})
# per-domain / per-main usage
usage: dict[str, dict[str, Any]] = {}
for o in house.objects:
key = f"{o.main} {o.domain}"
u = usage.setdefault(key, {"main": o.main, "domain": o.domain, "count": 0,
"max_sub_used": 0})
u["count"] += 1
u["max_sub_used"] = max(u["max_sub_used"], o.sub)
return {
"schema_version": SCHEMA_VERSION,
"project_name": house.project_name,
"language": house.language,
"taxonomy": {str(v): k for k, v in DOMAIN_MAIN.items()},
"totals": {"rooms": len(house.rooms), "group_addresses": total_ga},
"allocation": sorted(usage.values(), key=lambda x: x["main"]),
"rooms": rooms_out,
}
def build_bom_proposal(house: ComposedHouse) -> dict[str, Any]:
"""Propose candidate devices from the device library for the resolved
functions. A PROPOSAL, not an exact BOM — exact set-cover selection is R2."""
from .device_library import decompose_device
# count function-type instances (each ResolvedSlot is one channel/zone/unit)
counts: dict[str, int] = {}
for room in house.rooms:
for slot in room.slots:
counts[slot.function_type] = counts.get(slot.function_type, 0) + 1
items: list[dict[str, Any]] = []
for ftype, n in sorted(counts.items()):
recipe = BOM_RECIPE.get(ftype)
dev = decompose_device(recipe, channels=n) if recipe else {"matched": False}
items.append({
"function": ftype,
"channels_required": n,
"candidate_recipe": recipe,
"unit": dev.get("unit"),
"objects_per_unit": dev.get("objects_per_unit"),
"recipe_note": dev.get("note"),
})
return {
"note": "Candidate devices only (function-first). Exact device selection with "
"channel/price optimisation is R2; verify channel counts against real "
"product datasheets before ordering.",
"items": items,
}
# --------------------------------------------------------------------------- #
# Top-level compose
# --------------------------------------------------------------------------- #
def compose(rooms: list[dict[str, Any]], language: str = "ru",
project_name: str = "Room Library house",
templates: Optional[dict[str, dict[str, Any]]] = None) -> dict[str, Any]:
"""Compose a NEW house from room specs and return all artifacts + linter run.
``rooms`` items: ``{template, preset?, slot_presets?, params?, label?}``.
Returns a dict with: manifest, ets_xml, ets_csv, bom, lint (round-trip through
the standard loader), and the raw ``.knxproj`` bytes are NOT returned (the
server persists them on request).
"""
from .project import load_project
from .analyze import validate_naming, detect_missing_status, detect_dpt_issues
from .policy import check_policy, load_policy
templates = templates if templates is not None else load_builtin_templates()
house = resolve_house(rooms, templates, language=language, project_name=project_name)
knxproj = build_knxproj_bytes(house)
# Round-trip: write to a temp file and re-read with the SAME loader used for
# third-party projects, then lint the re-read model (not the IR).
import tempfile
tf = tempfile.NamedTemporaryFile(suffix=".knxproj", delete=False)
try:
tf.write(knxproj)
tf.close()
loaded = load_project(tf.name)
finally:
os.unlink(tf.name)
from .generate_ets import generate_ets_xml, generate_ets_csv
naming = validate_naming(loaded)
missing = detect_missing_status(loaded)
dpt = detect_dpt_issues(loaded)
policy = check_policy(loaded, load_policy(None))
all_findings = naming + missing + dpt + policy.get("findings", [])
errors = sum(1 for f in all_findings if f.get("severity") == "error")
warnings = sum(1 for f in all_findings if f.get("severity") == "warning")
manifest = build_manifest(house)
return {
"project_name": project_name,
"language": language,
"totals": manifest["totals"],
"lint": {
"reread_group_addresses": len(loaded.gas),
"errors": errors,
"warnings": warnings,
"clean": errors == 0 and warnings == 0,
"naming": naming,
"missing_status": missing,
"dpt": dpt,
"policy": {"summary": policy.get("findings_summary"),
"findings": policy.get("findings")},
},
"manifest": manifest,
"bom": build_bom_proposal(house),
"ets_xml": generate_ets_xml(loaded),
"ets_csv": generate_ets_csv(loaded),
"_knxproj_bytes": knxproj,
}