Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions specs/SPEC-004-blueprint-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# SPEC-004: Blueprint ID on Devices

## Context

Multiple devices of the same model and vendor share an underlying blueprint, and
therefore have identical manifests. An agent reading blueprints to learn device
interfaces issues redundant `read_blueprint` calls for same-blueprint devices,
wasting network round-trips and context.

The SDK exposes a stable `blueprint_id` on every device — the platform does not
allow creating a device without specifying a blueprint. Exposing this field on
the MCP `Device` model lets the agent dedupe: same `blueprint_id` ⇒ identical
manifest ⇒ read once, reuse for every matching device.

`device.type` (LUA, VIRTUAL_UCM, GATEWAY, ...) is the transport category, not a
blueprint identifier. `blueprint_id` is a separate field that fills a gap the
existing model has no equivalent for.

## Architectural Decisions

1. **Add `blueprint_id: str` to `domain.Device` (required).** All devices have
a blueprint. Sourced directly from the SDK's `Device.blueprint_id`.

2. **Flow the field end-to-end** through `core.DeviceDTO` and
`mcp.models.Device` so the value propagates from SDK → DTO → domain → MCP
response without loss.

3. **Dedup is agent-side, guided by the `search_devices` docstring.** Paginating
a site in BASIC view is cheap; the agent sees `blueprint_id` on every device
and can identify which devices share a blueprint. The `search_devices`
docstring guides the agent: devices sharing the same `blueprint_id` have
identical manifests, so `read_blueprint` need only be called once per unique
`blueprint_id`. `read_blueprint`'s parameter list is unchanged.

## Constraints

- Do not change `read_blueprint`'s parameter list or behavior.
- Do not add a `blueprint_id` filter to `search_devices` or
`DeviceSearchQuery`.
- Do not change `device.type` semantics or values.
- Do not add profile-related fields or concepts (separate specs).

## Acceptance Criteria

1. `domain.Device` has `blueprint_id: str` (required, not optional).

2. `core.DeviceDTO` has `blueprint_id: str` (required).

3. `mcp.models.Device` exposes `blueprint_id: str` (required), populated by
`from_domain`.

4. `EnapterDataMapper.to_device_dto` maps `blueprint_id` from the SDK `Device`
object's `blueprint_id`.

5. The `search_devices` tool docstring includes guidance that devices sharing
the same `blueprint_id` have identical manifests, and that `read_blueprint`
need only be called once per unique `blueprint_id`.

6. The `tests/integration/schemas/search_devices.json` snapshot is regenerated
and shows `blueprint_id` as a required string field on the Device item
schema.

7. Unit tests cover:
- `to_device_dto` maps `blueprint_id` from the SDK object.
- `mcp.models.Device.from_domain` propagates `blueprint_id`.

8. `make check` passes.
90 changes: 90 additions & 0 deletions specs/SPEC-005-profile-mappings-on-declarations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# SPEC-005: Profile Mappings on Declarations

## Context

Enapter blueprints can alias local telemetry, property, or command names to
different profile-defined names via a per-declaration `implements` field. For
example, a blueprint may declare a telemetry attribute `irradiance` locally that
maps to `sensor.solar_irradiance.solar_irradiance` in the profile:

```yaml
telemetry:
irradiance:
type: float
display_name: Solar Irradiance
unit: W/m2
implements: sensor.solar_irradiance.solar_irradiance
```

Automation rules access telemetry, properties, and commands by their local
blueprint names. When an agent reads rule code alongside a device's blueprint,
it needs to see the per-declaration mapping to connect local names to profile
names — especially when the two differ.

The current manifest mapper silently drops this field. This spec surfaces it as
optional metadata on existing declaration models.

## Architectural Decisions

1. **Add `implements: list[str] | None = None`** to
`TelemetryAttributeDeclaration`, `PropertyDeclaration`, and
`CommandDeclaration` — both domain dataclasses and MCP pydantic models. The
field is a list of dot-notation profile identifier strings (e.g.,
`["energy.battery"]`, `["lib.energy.battery.soc"]`). Although the blueprint
YAML declares `implements` as a single string, the platform API serializes it
as a single-element list. The `implements` field name conveys the meaning;
no type alias is introduced (consistent with other list fields in the
codebase). The platform supports per-declaration `implements` on these three
declaration types.

2. **Do not add `implements` to `AlertDeclaration` or
`CommandArgumentDeclaration`.** The platform does not support per-declaration
`implements` on alerts or command arguments.

3. **Map the field from the manifest dict's per-declaration `implements` key.**
The API delivers the value as a list. When the key is absent, the field is
`None`. The field is purely additive metadata on existing declarations — no
new tool, no new section, no return-type change.

## Constraints

- Do not add `implements` to `AlertDeclaration` or `CommandArgumentDeclaration`.
- Do not add a top-level `implements` field to `DeviceManifest` (separate spec).
- Do not add a new `BlueprintSection` value (separate spec).
- Do not change `read_blueprint`'s return type or existing sections.
- Do not add `implements` to the `Device` model or `BlueprintSummary`.

## Acceptance Criteria

1. `domain.TelemetryAttributeDeclaration` has `implements: list[str] | None =
None`.

2. `domain.PropertyDeclaration` has `implements: list[str] | None = None`.

3. `domain.CommandDeclaration` has `implements: list[str] | None = None`.

4. `domain.AlertDeclaration` does NOT have an `implements` field.

5. `domain.CommandArgumentDeclaration` does NOT have an `implements` field.

6. `EnapterDataMapper` maps per-declaration `implements` from the declaration
dict's `implements` key for telemetry attributes, properties, and commands.
The API delivers the value as a list. When the key is absent, the field is
`None`.

7. `mcp.models.TelemetryAttributeDeclaration`, `mcp.models.PropertyDeclaration`,
and `mcp.models.CommandDeclaration` each have `implements: list[str] | None
= None`, populated by `from_domain`.

8. The `tests/integration/schemas/read_blueprint.json` snapshot is regenerated
and shows `implements` as an optional array-of-strings property on the
`TelemetryAttributeDeclaration`, `PropertyDeclaration`, and
`CommandDeclaration` schemas.

9. Unit tests cover:
- `to_telemetry_attribute_declaration` / `to_property_declaration` /
`to_command_declaration` map `implements` (present → list of strings;
absent → `None`).
- MCP declaration models' `from_domain` propagates `implements`.

10. `make check` passes.
101 changes: 101 additions & 0 deletions specs/SPEC-006-device-profile-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# SPEC-006: Device Profile Membership

## Context

The Enapter rule engine exposes `device.implements()` as a first-class Lua API,
returning the list of profiles a device implements. Rule code branches on
profile membership:

```lua
local implements, _ = device.implements()
if implements["energy.battery"] then
local soc = device.telemetry.now("battery_soc")
...
end
```

An agent deciphering such a rule needs to resolve which profiles the target
device implements. Profile membership is a composite fact — a profile like
`energy.battery` requires multiple declarations (soc, electrical, status, etc.)
spread across the manifest. This membership is recorded only in the manifest's
top-level `implements` field; it cannot be reconstructed from per-declaration
mappings alone.

Without exposing this list, the agent cannot answer "does this device implement
`energy.battery`?" from server data. The profiles repository at
`https://github.com/Enapter/profiles` documents what each profile means, but
the server must expose which profiles a device claims to implement.

## Architectural Decisions

1. **Add `implements: list[str]` to `domain.DeviceManifest`.** Each entry is a
dot-notation profile identifier string (e.g., `energy.battery`). Mapped from
the manifest dict's `implements` key, defaulting to `[]` when absent.

2. **Add `BlueprintSection.IMPLEMENTS = "implements"`,** mirroring the YAML key
(consistent with `TELEMETRY`, `PROPERTIES`, `ALERTS`, `COMMANDS`).

3. **`read_blueprint(section="implements")` returns `list[str]`** — the
device's implemented profile names from `manifest.implements`, filtered by
`name_regexp` (matched against the profile name string), paginated by
`offset`/`limit`. The return-type union of `read_blueprint` grows by a `str`
variant. The agent knows from its `section` input what item type to expect,
so the heterogeneous union is not ambiguous in practice.

4. **Add the profiles-repo link to the `read_blueprint` docstring.** This is
where the agent encounters profile data — per-declaration `implements`
mappings on declarations (SPEC-005) and the full list via the `implements`
section. The link gives the external reference for resolving profile names
against the canonical definitions at
`https://github.com/Enapter/profiles`.

5. **The section is additive.** Existing sections (telemetry, properties,
alerts, commands) and their return types are unchanged. An agent only calls
`section="implements"` when deciphering `device.implements()` in rule code.

## Constraints

- Do not add `implements` as a field on `domain.Device`, `mcp.models.Device`,
or `BlueprintSummary`. Profile membership is not part of search results — it
surfaces only when the agent explicitly reads the `implements` section.
- Do not add an `implements` filter to `search_devices`. Profile-name-based
device search is out of scope.
- Do not add a `search_profiles` tool or `Profile` domain/MCP model.
- Do not change existing `read_blueprint` sections or their return types. The
new `IMPLEMENTS` section is additive.
- Do not add PyYAML or any YAML-parsing dependency.

## Acceptance Criteria

1. `domain.DeviceManifest` has `implements: list[str]` (required).

2. `EnapterDataMapper.to_device_manifest` maps `implements` from the manifest
dict's `implements` key, defaulting to `[]` when absent.

3. `domain.BlueprintSection` has `IMPLEMENTS = "implements"`.

4. `ApplicationServer.read_blueprint` with `section=IMPLEMENTS` returns
`list[str]` from `manifest.implements`, filtered by `name_regexp`
(matched against the profile name string) and paginated by `offset`/`limit`.

5. The `read_blueprint` return-type union (both `core.ApplicationServer` and
`mcp.server.Server` layers) includes `str`.

6. The `read_blueprint` tool docstring:
- Lists `"implements"` among the available sections.
- References `https://github.com/Enapter/profiles` and explains that it
documents the standardized profiles that blueprints implement.

7. The `tests/integration/schemas/read_blueprint.json` snapshot is regenerated
and shows:
- `"implements"` in the `section` input enum.
- `string` as an `anyOf` variant in the output item schema.

8. Unit tests cover:
- `to_device_manifest` maps `implements` (present → list; absent → `[]`).
- `ApplicationServer.read_blueprint` with `section=IMPLEMENTS` returns the
filtered, paginated profile name list (mocked manifest).
- The MCP-layer `read_blueprint` returns bare strings for the `IMPLEMENTS`
section and declarations for the other sections.

9. `make check` passes.
17 changes: 12 additions & 5 deletions src/enapter_mcp_server/core/application_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ async def _search_devices_basic(
devices.append(
domain.Device(
id=device_dto.id,
blueprint_id=device_dto.blueprint_id,
name=device_dto.name,
site_id=device_dto.site_id,
type=device_dto.type,
Expand Down Expand Up @@ -241,6 +242,7 @@ async def _search_devices_full(
devices.append(
domain.Device(
id=device_dto.id,
blueprint_id=device_dto.blueprint_id,
name=device_dto.name,
site_id=device_dto.site_id,
type=device_dto.type,
Expand Down Expand Up @@ -269,7 +271,8 @@ async def read_blueprint(
offset: int,
limit: int,
) -> list[
domain.PropertyDeclaration
str
| domain.PropertyDeclaration
| domain.TelemetryAttributeDeclaration
| domain.AlertDeclaration
| domain.CommandDeclaration
Expand All @@ -281,13 +284,16 @@ async def read_blueprint(
assert device_dto.manifest is not None

entities: list[
domain.PropertyDeclaration
str
| domain.PropertyDeclaration
| domain.TelemetryAttributeDeclaration
| domain.AlertDeclaration
| domain.CommandDeclaration
]
] = []

match section:
case domain.BlueprintSection.IMPLEMENTS:
entities = list(device_dto.manifest.implements)
case domain.BlueprintSection.PROPERTIES:
entities = list(device_dto.manifest.properties.values())
case domain.BlueprintSection.TELEMETRY:
Expand All @@ -299,8 +305,9 @@ async def read_blueprint(
case _:
raise NotImplementedError(section)

entities = [e for e in entities if name_pattern.search(e.name)]
entities.sort(key=lambda e: e.name)
key = lambda e: e if isinstance(e, str) else e.name
entities = [e for e in entities if name_pattern.search(key(e))]
entities.sort(key=key)
return entities[offset : offset + limit]

async def get_historical_telemetry(
Expand Down
1 change: 1 addition & 0 deletions src/enapter_mcp_server/core/device_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@dataclasses.dataclass(frozen=True, kw_only=True)
class DeviceDTO:
id: str
blueprint_id: str
name: str
site_id: str
type: domain.DeviceType
Expand Down
1 change: 1 addition & 0 deletions src/enapter_mcp_server/domain/blueprint_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ class BlueprintSection(enum.Enum):
PROPERTIES = "properties"
ALERTS = "alerts"
COMMANDS = "commands"
IMPLEMENTS = "implements"
1 change: 1 addition & 0 deletions src/enapter_mcp_server/domain/command_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ class CommandDeclaration:
access_level: AccessRole
description: str | None
arguments: list[CommandArgumentDeclaration]
implements: list[str] | None = None
1 change: 1 addition & 0 deletions src/enapter_mcp_server/domain/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
@dataclasses.dataclass(frozen=True, kw_only=True)
class Device:
id: str
blueprint_id: str
name: str
site_id: str
type: DeviceType
Expand Down
1 change: 1 addition & 0 deletions src/enapter_mcp_server/domain/device_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
class DeviceManifest:
description: str | None
vendor: str | None
implements: list[str]
properties: dict[str, PropertyDeclaration]
telemetry: dict[str, TelemetryAttributeDeclaration]
alerts: dict[str, AlertDeclaration]
Expand Down
1 change: 1 addition & 0 deletions src/enapter_mcp_server/domain/property_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class PropertyDeclaration:
description: str | None
enum: list[Any] | None
unit: str | None
implements: list[str] | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ class TelemetryAttributeDeclaration:
description: str | None
enum: list[Any] | None
unit: str | None
implements: list[str] | None = None
Loading
Loading