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
717 changes: 358 additions & 359 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def main() -> None:
author="Roman Novatorov",
author_email="rnovatorov@enapter.com",
install_requires=[
"enapter==0.17.1",
"enapter==0.17.2",
"fastmcp==2.14.*",
"sentry-sdk==2.53.*",
"httpx==0.*",
Expand Down
22 changes: 8 additions & 14 deletions src/enapter_mcp_server/core/application_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
from .device_dto import DeviceDTO
from .device_search_query import DeviceSearchQuery
from .enapter_api import EnapterAPI
from .errors import (
LatestTelemetryUnavailable,
SearchQueryTooBroad,
)
from .errors import SearchQueryTooBroad
from .site_search_query import SiteSearchQuery


Expand Down Expand Up @@ -101,11 +98,13 @@ async def _search_devices_basic(
site_id=query.site_id,
expand_manifest=True,
expand_connectivity=True,
expand_active_alerts=True,
) as devices_gen:
async for device_dto in devices_gen:
if query.matches(device_dto):
assert device_dto.manifest is not None
assert device_dto.connectivity is not None
assert device_dto.active_alerts is not None
devices.append(
domain.Device(
id=device_dto.id,
Expand All @@ -116,6 +115,7 @@ async def _search_devices_basic(
device_dto.manifest
),
connectivity_status=device_dto.connectivity,
active_alerts_total=len(device_dto.active_alerts),
)
)

Expand All @@ -136,6 +136,7 @@ async def _search_devices_full(
expand_manifest=True,
expand_properties=True,
expand_connectivity=True,
expand_active_alerts=True,
) as devices_gen:
async for device_dto in devices_gen:
if query.matches(device_dto):
Expand All @@ -149,6 +150,7 @@ async def _search_devices_full(
assert device_dto.manifest is not None
assert device_dto.connectivity is not None
assert device_dto.properties is not None
assert device_dto.active_alerts is not None
devices.append(
domain.Device(
id=device_dto.id,
Expand All @@ -159,25 +161,17 @@ async def _search_devices_full(
device_dto.manifest
),
connectivity_status=device_dto.connectivity,
active_alerts_total=len(device_dto.active_alerts),
properties={
k: device_dto.properties.get(k)
for k in device_dto.manifest.properties
},
active_alerts=await self._get_active_alerts(auth, device_dto.id),
active_alerts=device_dto.active_alerts,
)
)

return devices

async def _get_active_alerts(self, auth: AuthConfig, device_id: str) -> list[str]:
try:
latest_telemetry = await self._enapter_api.get_latest_telemetry(
auth, {device_id: ["alerts"]}
)
except LatestTelemetryUnavailable:
return []
return latest_telemetry.get(device_id, {}).get("alerts") or []

async def read_blueprint(
self,
auth: AuthConfig,
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 @@ -12,4 +12,5 @@ class DeviceDTO:
type: domain.DeviceType
connectivity: domain.ConnectivityStatus | None = None
properties: dict[str, Any] | None = None
active_alerts: list[str] | None = None
manifest: domain.DeviceManifest | None = None
8 changes: 8 additions & 0 deletions src/enapter_mcp_server/core/device_search_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class DeviceSearchQuery:
device_type: domain.DeviceType | None = None
name_regexp: str | None = None
connectivity_status: domain.ConnectivityStatus | None = None
has_active_alerts: bool | None = None

@functools.cached_property
def _name_pattern(self) -> re.Pattern[str] | None:
Expand All @@ -37,4 +38,11 @@ def matches(self, device_dto: DeviceDTO) -> bool:
device_dto.name
):
return False

if self.has_active_alerts is not None:
assert device_dto.active_alerts is not None
has_active_alerts = len(device_dto.active_alerts) > 0
if self.has_active_alerts != has_active_alerts:
return False

return True
2 changes: 2 additions & 0 deletions src/enapter_mcp_server/core/enapter_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async def list_devices(
expand_manifest: bool = False,
expand_properties: bool = False,
expand_connectivity: bool = False,
expand_active_alerts: bool = False,
) -> AsyncGenerator[DeviceDTO, None]:
yield # type: ignore

Expand All @@ -33,6 +34,7 @@ async def get_device(
expand_manifest: bool = False,
expand_connectivity: bool = False,
expand_properties: bool = False,
expand_active_alerts: bool = False,
) -> DeviceDTO: ...

@enapter.async_.generator
Expand Down
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 @@ -14,5 +14,6 @@ class Device:
type: DeviceType
blueprint_summary: BlueprintSummary
connectivity_status: ConnectivityStatus
active_alerts_total: int
properties: dict[str, Any] | None = None
active_alerts: list[str] | None = None
4 changes: 4 additions & 0 deletions src/enapter_mcp_server/http/enapter_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ async def list_devices(
expand_manifest: bool = False,
expand_properties: bool = False,
expand_connectivity: bool = False,
expand_active_alerts: bool = False,
) -> AsyncGenerator[core.DeviceDTO, None]:
async with self._new_client(auth) as client:
async with client.devices.list(
site_id=site_id,
expand_manifest=expand_manifest,
expand_properties=expand_properties,
expand_connectivity=expand_connectivity,
expand_raised_alert_names=expand_active_alerts,
) as s:
async for device in s:
yield self._data_mapper.to_device_dto(device)
Expand All @@ -62,13 +64,15 @@ async def get_device(
expand_manifest: bool = False,
expand_connectivity: bool = False,
expand_properties: bool = False,
expand_active_alerts: bool = False,
) -> core.DeviceDTO:
async with self._new_client(auth) as client:
device = await client.devices.get(
device_id,
expand_manifest=expand_manifest,
expand_connectivity=expand_connectivity,
expand_properties=expand_properties,
expand_raised_alert_names=expand_active_alerts,
)
return self._data_mapper.to_device_dto(device)

Expand Down
7 changes: 6 additions & 1 deletion src/enapter_mcp_server/http/enapter_data_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,25 @@


class EnapterDataMapper:
def to_device_dto(self, device: Any) -> core.DeviceDTO:
def to_device_dto(self, device: enapter.http.api.devices.Device) -> core.DeviceDTO:
connectivity = None
if device.connectivity is not None:
connectivity = domain.ConnectivityStatus(
device.connectivity.status.value.lower()
)

active_alerts = []
if device.raised_alert_names is not None:
active_alerts = device.raised_alert_names

return core.DeviceDTO(
id=device.id,
name=device.name,
site_id=device.site_id,
type=domain.DeviceType(device.type.value.lower()),
connectivity=connectivity,
properties=device.properties,
active_alerts=active_alerts,
manifest=self.to_device_manifest(device.manifest),
)

Expand Down
2 changes: 2 additions & 0 deletions src/enapter_mcp_server/mcp/models/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Device(pydantic.BaseModel):
type: DeviceType
blueprint_summary: BlueprintSummary
connectivity_status: ConnectivityStatus
active_alerts_total: int
properties: dict[str, Any] | None = None
active_alerts: list[str] | None = None

Expand All @@ -32,6 +33,7 @@ def from_domain(cls, device: domain.Device) -> Self:
site_id=device.site_id,
type=device.type.value,
connectivity_status=device.connectivity_status.value,
active_alerts_total=device.active_alerts_total,
properties=device.properties,
active_alerts=device.active_alerts,
blueprint_summary=BlueprintSummary.from_domain(device.blueprint_summary),
Expand Down
2 changes: 2 additions & 0 deletions src/enapter_mcp_server/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ async def search_devices(
type: models.DeviceType | None = None,
name_regexp: str = ".*",
connectivity_status: models.ConnectivityStatus | None = None,
has_active_alerts: bool | None = None,
view: models.DeviceView = "basic",
offset: int = 0,
limit: int = 20,
Expand All @@ -206,6 +207,7 @@ async def search_devices(
if connectivity_status is not None
else None
),
has_active_alerts=has_active_alerts,
)
devices = await self._app.search_devices(
auth=auth,
Expand Down
64 changes: 14 additions & 50 deletions tests/unit/core/test_application_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async def list_devices(
expand_manifest: bool = False,
expand_properties: bool = False,
expand_connectivity: bool = False,
expand_active_alerts: bool = False,
) -> AsyncGenerator[core.DeviceDTO, None]:
for d in self._devices:
if site_id is None or d.site_id == site_id:
Expand All @@ -77,6 +78,7 @@ async def get_device(
expand_manifest: bool = False,
expand_connectivity: bool = False,
expand_properties: bool = False,
expand_active_alerts: bool = False,
) -> core.DeviceDTO:
for device in self._devices:
if device.id == device_id:
Expand Down Expand Up @@ -302,6 +304,7 @@ async def test_search_devices(self) -> None:
site_id="s1",
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
active_alerts=["a1"],
manifest=manifest,
),
core.DeviceDTO(
Expand All @@ -310,6 +313,7 @@ async def test_search_devices(self) -> None:
site_id="s1",
type=domain.DeviceType.GATEWAY,
connectivity=domain.ConnectivityStatus.ONLINE,
active_alerts=[],
manifest=manifest,
),
core.DeviceDTO(
Expand All @@ -318,6 +322,7 @@ async def test_search_devices(self) -> None:
site_id="s2",
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.OFFLINE,
active_alerts=[],
manifest=manifest,
),
]
Expand All @@ -338,6 +343,7 @@ async def test_search_devices(self) -> None:
assert len(result) == 2
assert result[0].connectivity_status == domain.ConnectivityStatus.ONLINE
assert result[0].blueprint_summary is not None
assert result[0].active_alerts_total == 1
assert result[0].properties is None
assert result[0].active_alerts is None

Expand Down Expand Up @@ -430,10 +436,11 @@ async def test_search_devices_full_view(self) -> None:
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
properties={"p1": "v1", "p2": "v2", "extra": "ignored"},
active_alerts=["a1"],
manifest=manifest,
),
]
api = MockEnapterAPI(devices=devices, telemetry={"1": {"alerts": ["a1"]}})
api = MockEnapterAPI(devices=devices)
app = core.ApplicationServer(api)

result = await app.search_devices(
Expand All @@ -449,6 +456,7 @@ async def test_search_devices_full_view(self) -> None:
assert result[0].blueprint_summary is not None
assert result[0].properties == {"p1": "v1", "p2": "v2"}
assert result[0].active_alerts == ["a1"]
assert result[0].active_alerts_total == 1

async def test_search_devices_full_view_with_missing_alerts(self) -> None:
manifest = make_device_manifest(
Expand Down Expand Up @@ -494,58 +502,11 @@ async def test_search_devices_full_view_with_missing_alerts(self) -> None:
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
properties={"p1": "v1"},
active_alerts=[],
manifest=manifest,
),
]
api = MockEnapterAPI(devices=devices, telemetry={"1": {}})
app = core.ApplicationServer(api)

result = await app.search_devices(
core.AuthConfig(token="test"),
query=core.DeviceSearchQuery(site_id="s1", name_regexp=".*"),
offset=0,
limit=10,
view=domain.DeviceView.FULL,
)

assert len(result) == 1
assert result[0].active_alerts == []

async def test_search_devices_full_view_with_unavailable_latest_telemetry(
self,
) -> None:
manifest = make_device_manifest(
description="Desc",
vendor="Enapter",
properties={
"p1": domain.PropertyDeclaration(
name="p1",
display_name="P1",
data_type=domain.DataType.STRING,
description=None,
enum=None,
unit=None,
)
},
telemetry={},
alerts={},
commands={},
)
devices = [
core.DeviceDTO(
id="1",
name="Alpha",
site_id="s1",
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
properties={"p1": "v1"},
manifest=manifest,
),
]
api = MockEnapterAPI(
devices=devices,
latest_telemetry_unavailable=True,
)
api = MockEnapterAPI(devices=devices)
app = core.ApplicationServer(api)

result = await app.search_devices(
Expand All @@ -558,6 +519,7 @@ async def test_search_devices_full_view_with_unavailable_latest_telemetry(

assert len(result) == 1
assert result[0].active_alerts == []
assert result[0].active_alerts_total == 0

async def test_search_devices_full_view_requires_site_or_device_id(self) -> None:
api = MockEnapterAPI(devices=[])
Expand Down Expand Up @@ -605,6 +567,7 @@ async def test_search_devices_full_view_allows_device_id_without_site_id(
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
properties={"p1": "v1"},
active_alerts=[],
manifest=manifest,
),
core.DeviceDTO(
Expand All @@ -614,6 +577,7 @@ async def test_search_devices_full_view_allows_device_id_without_site_id(
type=domain.DeviceType.NATIVE,
connectivity=domain.ConnectivityStatus.ONLINE,
properties={"p1": "v2"},
active_alerts=[],
manifest=manifest,
),
]
Expand Down
Loading
Loading