Files
nickol-knx-mcp/tests/test_device_catalog.py
Nikolay MiroshnichenkoandClaude Fable 5 5651be2930 feat: exact device models — local catalog for decompose_device + parse_devices_from_project (v0.7.0)
- device_library: NICKOL_KNX_CATALOG env points at a local device-library
  YAML file/dir; decompose_device returns the exact vendor object model
  (source: catalog-exact) and falls back to generic recipes
  (source: recipe-approximate). Env unset = behaviour unchanged.
- appprog_parser (new) + MCP tool parse_devices_from_project: deterministic
  extraction of exact comm-object models from M-* application programs in a
  .knxproj/.knxprod (order number via nested <Product>, DPST-x-y -> x.00y,
  per-channel block/stride detection, coverage manifest). Read-only,
  PII-safe: never reads the client P-*/0.xml. Now 25 MCP tools.
- tests: test_device_catalog.py + test_appprog_parser.py (synthetic,
  self-contained)
- docs: README/README.ru/docs site/announcements synced to v0.7.0, 25 tools

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 06:48:19 +02:00

79 lines
3.1 KiB
Python

"""decompose_device: generic-recipe fallback + opt-in local exact catalog.
Self-contained: builds a tiny synthetic catalog in a temp dir, so the test does
NOT depend on any locally-harvested (unshipped) catalog data.
"""
import sys, os, tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import nickol_knx_mcp.device_library as dl
SAMPLE_CATALOG = """\
schema_version: "0.1"
manufacturer: "TestVendor"
devices:
- order_number: "TV-DIM4"
name: "TestDimmer 4CH"
category: "actuator/dimmer"
application_program: { name: "TestDimmer", version: "v3" }
channels: { dimmer_channels: 4 }
object_counts: { master_catalog_total: 92 }
repeating_blocks:
- unit: "dimmer_channel"
instances: 4
objects_per_instance: 5
stride: 5
objects:
- { number: 1, name: "[C1] On/Off", dpt: "1.001", role: cmd, size_bits: 1 }
- { number: 2, name: "[C1] On/Off (Status)", dpt: "1.001", role: status, size_bits: 1 }
- { number: 3, name: "[C1] Relative Dimming", dpt: "3.007", role: cmd, size_bits: 4 }
- { number: 4, name: "[C1] Absolute Dimming", dpt: "5.001", role: cmd, size_bits: 8 }
- { number: 5, name: "[C1] Pattern", role: cmd, size_bits: 8 } # no dpt -> stays None
"""
def _reset(catalog_path=None):
if catalog_path:
os.environ["NICKOL_KNX_CATALOG"] = catalog_path
else:
os.environ.pop("NICKOL_KNX_CATALOG", None)
dl._CATALOG_INDEX = None # drop the module cache between modes
# 1) No catalog configured -> generic recipe, behaviour unchanged
_reset()
r = dl.decompose_device("ZIO-MB24", 2)
assert r["matched"] and r["source"] == "recipe-approximate", r
assert r["total_ga"] == r["objects_per_unit"] * 2, r
r = dl.decompose_device("no-such-device")
assert r["matched"] is False and r["source"] == "recipe-approximate", r
# 2) Local catalog configured -> exact model, matched by order number AND by name
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "vendor.yaml"), "w", encoding="utf-8") as fh:
fh.write(SAMPLE_CATALOG)
_reset(d)
for q in ("TV-DIM4", "tv-dim4", "TestDimmer 4CH", "testdimmer 4ch"):
r = dl.decompose_device(q)
assert r["source"] == "catalog-exact", (q, r["source"])
assert r["order_number"] == "TV-DIM4", (q, r)
assert r["application_program"]["version"] == "v3", r
assert r["total_master_objects"] == 92, r
blk = r["blocks"][0]
assert blk["unit"] == "dimmer_channel" and blk["instances"] == 4, blk
assert blk["objects_per_instance"] == 5, blk
assert blk["first_instance_objects"][0]["dpt"] == "1.001", blk
assert blk["first_instance_objects"][4]["dpt"] is None, "no DPT must stay None, never guessed"
# a device NOT in the catalog still falls back to the generic recipe
r = dl.decompose_device("dimmer")
assert r["source"] == "recipe-approximate" and r["matched"], r
# 3) env cleared -> back to recipe-only, no stale cache
_reset()
assert dl.decompose_device("TV-DIM4")["matched"] is False
print("OK — device catalog wiring: recipe fallback + catalog-exact + name match + honest unverified DPT")