diff --git a/README.md b/README.md index c6de09a..6f159a1 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,224 @@ def get_weather_widget(context) -> str: The frontend in `src/app/src/weather-app.ts` receives the tool result and renders the weather display. It's bundled with Vite into a single `index.html` that the resource serves. +## IoT Sensor Sample + +A sample that demonstrates how to use MCP tools backed by Azure Blob Storage to **ingest, store, and analyse** structured IoT sensor data using a rich, multi-field payload (strings, arrays, nested objects). + +### Overview + +The IoT sample exposes four MCP tools, all persisting data in Azure Blob Storage (one blob per device): + +| Tool | Blob binding | Description | +|---|---|---| +| `ingest_sensor_data` | output | Validate and save a full sensor payload | +| `get_sensor_report` | input | Full report with health score, alerts and maintenance info | +| `list_active_alerts` | input | Only unresolved alerts for a device | +| `get_sensor_metrics_summary` | input | Statistical summary (min/max/avg/stdev/trend) of readings | + +The service layer lives in [`src/iot_service.py`](src/iot_service.py) and uses stdlib `dataclasses` only — no extra dependencies. + +### Data Model + +Each sensor device is stored as a single JSON blob under `iot-sensors/.json`. The payload is intentionally rich to show how MCP tools handle strings, arrays, and nested objects: + +```json +{ + "device_id": "sensor-industrial-001", + "location": "Plant-A / Zone-3 / Line-2", + "timestamp": "2026-03-19T10:30:00Z", + "firmware_version": "2.4.1", + "status": "operational", + "tags": ["critical", "monitored", "area-b"], + "metrics": { + "temperature_celsius": 72.4, + "humidity_percent": 45.2, + "pressure_bar": 3.15, + "vibration_hz": 120.8, + "power_consumption_kw": 18.7 + }, + "alerts": [ + { + "code": "TEMP_HIGH", + "severity": "warning", + "message": "Temperature above threshold", + "triggered_at": "2026-03-19T10:28:00Z", + "resolved": false + } + ], + "maintenance": { + "last_service_date": "2025-12-01", + "next_service_date": "2026-06-01", + "technician": "Mario Rossi", + "notes": "Replaced filter unit B" + }, + "network": { + "ip_address": "192.168.10.45", + "protocol": "MQTT", + "signal_strength_dbm": -67, + "connected_gateway": "gw-plant-a-01" + }, + "history_last_5_readings": [71.2, 71.8, 72.0, 72.1, 72.4] +} +``` + +### IoT Source Code + +The four MCP tools use `@app.mcp_tool_property()` with explicit `property_type` and `as_array` metadata so MCP clients can present a structured form instead of a free-text field. + +#### `ingest_sensor_data` — save a sensor reading + +```python +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device (e.g. sensor-industrial-001).", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="location", + description="Physical location of the device (e.g. Plant-A / Zone-3 / Line-2).", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="timestamp", + description="ISO 8601 timestamp of the reading (e.g. 2026-03-19T10:30:00Z).", + property_type=func.McpPropertyType.DATETIME +) +@app.mcp_tool_property( + arg_name="tags", + description='Labels associated with the device (e.g. "critical", "area-b").', + property_type=func.McpPropertyType.STRING, + as_array=True +) +@app.mcp_tool_property( + arg_name="metrics", + description="Current physical measurements: temperature_celsius, humidity_percent, pressure_bar, vibration_hz, power_consumption_kw.", + property_type=func.McpPropertyType.OBJECT +) +@app.mcp_tool_property( + arg_name="alerts", + description="Alert objects: code, severity (info|warning|critical), message, triggered_at, resolved.", + property_type=func.McpPropertyType.OBJECT, + as_array=True +) +@app.mcp_tool_property( + arg_name="history_last_5_readings", + description="Last temperature readings (up to 10 values), oldest first.", + property_type=func.McpPropertyType.FLOAT, + as_array=True +) +@app.blob_output(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def ingest_sensor_data(file: func.Out[str], device_id: str, ...) -> str: + """Validate and save an IoT sensor payload to Azure Blob Storage.""" + ... + sensor = iot_service.validate_payload(data) + file.set(sensor.to_json()) + return f"Payload for device '{device_id}' saved successfully." +``` + +Key points: +- Scalar fields (`device_id`, `location`, `timestamp`, …) use `property_type=func.McpPropertyType.STRING` or `DATETIME` +- Array fields (`tags`, `alerts`, `history_last_5_readings`) use `as_array=True` +- Nested objects (`metrics`, `maintenance`, `network`) use `property_type=func.McpPropertyType.OBJECT` +- The blob path uses `{mcptoolargs.device_id}` so each device gets its own blob + +> [!NOTE] +> If you want to use the IoT MCP tools from VS Code, you may need to temporarily comment out the line that declares `history_last_5_readings` with `property_type=func.McpPropertyType.FLOAT`. In some VS Code startup flows, the MCP server fails to start with an unsupported format/type error for that property. As a workaround, comment out that decorator line, restart the local server, and then use the IoT tools normally. + +#### `get_sensor_report` — full report with health score + +```python +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device to generate the report for." +) +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def get_sensor_report(file: func.InputStream, device_id: str) -> str: + """Read IoT sensor data from Azure Blob Storage and return a full report + with health score, metrics, active alerts, and maintenance information.""" + data = json.loads(file.read().decode("utf-8")) + sensor = iot_service.validate_payload(data) + return json.dumps(iot_service.generate_report(sensor)) +``` + +The report includes a `health_score` (0–100) decremented by device status and active alert severity, plus `days_to_next_service`. + +#### `list_active_alerts` and `get_sensor_metrics_summary` + +Both follow the same `blob_input` pattern with `device_id` as the only tool argument: + +```python +@app.mcp_tool() +@app.mcp_tool_property(arg_name="device_id", description="...") +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def list_active_alerts(file: func.InputStream, device_id: str) -> str: + """Return only alerts where resolved=false.""" + ... + +@app.mcp_tool() +@app.mcp_tool_property(arg_name="device_id", description="...") +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def get_sensor_metrics_summary(file: func.InputStream, device_id: str) -> str: + """Return min/max/avg/stdev/trend over the temperature history.""" + ... +``` + +### Try It Out + +With the function app running locally, use Copilot agent mode or MCP Inspector to: + +```plaintext +Ingest a reading for device sensor-factory-42 located in Building-C / Floor-2, +temperature 68.3°C, humidity 52%, pressure 2.9 bar, firmware 1.2.0, status operational. +``` + +```plaintext +Generate a full report for sensor-factory-42. +``` + +```plaintext +List the active alerts for sensor-factory-42. +``` + +```plaintext +Show me the metrics summary for sensor-factory-42. +``` + +### IoT Dashboard MCP App + +The IoT sample also includes an **MCP App** — an interactive dashboard rendered inside the MCP host when you call `get_sensor_report`. It follows the same Tool + UI Resource pattern as the Weather App. + +#### Build the IoT Dashboard UI + +```bash +cd src/iot-app +npm install +npm run build +cd ../ +``` + +This creates `src/iot-app/dist/index.html`. + +#### How It Works + +1. `get_sensor_report` declares UI metadata via `@app.mcp_tool(metadata=IOT_TOOL_METADATA)` pointing to `ui://iot/index.html` +2. `get_iot_dashboard` is an `mcp_resource_trigger` that serves the bundled HTML at that URI +3. When the MCP host calls `get_sensor_report`, it sees the UI resource and renders the dashboard in a sandboxed iframe +4. The dashboard shows: health score, live metrics (temp, humidity, pressure, vibration, power), temperature sparkline, active alerts, network info, maintenance schedule, and device tags + +#### Try It + +With the function app running, ask Copilot: + +```plaintext +Generate a full report for sensor-factory-42. +``` + +The agent will call the tool and the host will render the interactive IoT dashboard. + + ## Next Steps - Add [API Management](https://aka.ms/mcp-remote-apim-auth) to your MCP server (auth, gateway, policies, more!) diff --git a/src/function_app.py b/src/function_app.py index 6f9a266..982d68b 100644 --- a/src/function_app.py +++ b/src/function_app.py @@ -5,6 +5,7 @@ import azure.functions as func from weather_service import WeatherService +from iot_service import IoTService app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @@ -27,6 +28,21 @@ _SNIPPET_NAME_PROPERTY_NAME = "snippetname" _BLOB_PATH = "snippets/{mcptoolargs." + _SNIPPET_NAME_PROPERTY_NAME + "}.json" +# IoT service instance +iot_service = IoTService() + +# Constants for the IoT Blob Storage path +_IOT_DEVICE_ID_PROPERTY_NAME = "device_id" +_IOT_BLOB_PATH = "iot-sensors/{mcptoolargs." + _IOT_DEVICE_ID_PROPERTY_NAME + "}.json" + +# Constants for the IoT Dashboard resource +IOT_DASHBOARD_URI = "ui://iot/index.html" +IOT_DASHBOARD_NAME = "IoT Sensor Dashboard" +IOT_DASHBOARD_DESCRIPTION = "Interactive dashboard for industrial IoT sensor data" +IOT_DASHBOARD_MIME_TYPE = "text/html;profile=mcp-app" +IOT_TOOL_METADATA = '{"ui": {"resourceUri": "ui://iot/index.html"}}' +IOT_RESOURCE_METADATA = '{"ui": {"prefersBorder": true}}' + @app.mcp_tool() def hello_mcp() -> str: @@ -129,3 +145,261 @@ def get_weather(location: str) -> Dict[str, Any]: "Source": "api.open-meteo.com" }) + +# --------------------------------------------------------------------------- +# IoT Tools +# --------------------------------------------------------------------------- + +# IoT Dashboard Resource - returns HTML content for the IoT dashboard widget +@app.mcp_resource_trigger( + arg_name="context", + uri=IOT_DASHBOARD_URI, + resource_name=IOT_DASHBOARD_NAME, + description=IOT_DASHBOARD_DESCRIPTION, + mime_type=IOT_DASHBOARD_MIME_TYPE, + metadata=IOT_RESOURCE_METADATA +) +def get_iot_dashboard(context) -> str: + """Get the IoT sensor dashboard HTML content.""" + logging.info("Getting IoT dashboard") + + try: + current_dir = Path(__file__).parent + file_path = current_dir / "iot-app" / "dist" / "index.html" + + if file_path.exists(): + return file_path.read_text(encoding="utf-8") + else: + logging.warning(f"IoT dashboard file not found at: {file_path}") + return """ + +IoT Dashboard + +

IoT Sensor Dashboard

+

Dashboard not found. Run cd src/iot-app && npm install && npm run build first.

+ +""" + except Exception as e: + logging.error(f"Error reading IoT dashboard file: {e}") + return """ + +IoT Dashboard Error + +

IoT Sensor Dashboard

+

Error loading dashboard content.

+ +""" + +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device (e.g. sensor-industrial-001).", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="location", + description="Physical location of the device (e.g. Plant-A / Zone-3 / Line-2).", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="timestamp", + description="ISO 8601 timestamp of the reading (e.g. 2026-03-19T10:30:00Z).", + property_type=func.McpPropertyType.DATETIME +) +@app.mcp_tool_property( + arg_name="firmware_version", + description="Firmware version of the device using semver (e.g. 2.4.1).", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="status", + description="Operational status of the device: operational | degraded | offline | maintenance.", + property_type=func.McpPropertyType.STRING +) +@app.mcp_tool_property( + arg_name="tags", + description='Labels associated with the device (e.g. "critical", "area-b").', + property_type=func.McpPropertyType.STRING, + as_array=True +) +@app.mcp_tool_property( + arg_name="metrics", + description=( + "Current physical measurements: " + "temperature_celsius (float), humidity_percent (float 0-100), " + "pressure_bar (float > 0), vibration_hz (float >= 0), power_consumption_kw (float >= 0)." + ), + property_type=func.McpPropertyType.OBJECT +) +@app.mcp_tool_property( + arg_name="alerts", + description=( + "Alert objects. Each must have: " + "code (string), severity (info|warning|critical), message (string), " + "triggered_at (ISO 8601 string), resolved (boolean)." + ), + property_type=func.McpPropertyType.OBJECT, + as_array=True +) +@app.mcp_tool_property( + arg_name="maintenance", + description=( + "Maintenance info: " + "last_service_date (YYYY-MM-DD), next_service_date (YYYY-MM-DD), " + "technician (string), notes (string)." + ), + property_type=func.McpPropertyType.OBJECT +) +@app.mcp_tool_property( + arg_name="network", + description=( + "Network info: " + "ip_address (string), protocol (string, e.g. MQTT), " + "signal_strength_dbm (int <= 0), connected_gateway (string)." + ), + property_type=func.McpPropertyType.OBJECT +) +@app.mcp_tool_property( + arg_name="history_last_5_readings", + description="Last temperature readings (up to 10 values), oldest first.", + property_type=func.McpPropertyType.FLOAT, + as_array=True +) +@app.blob_output(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def ingest_sensor_data( + file: func.Out[str], + device_id: str, + location: str, + timestamp: str, + firmware_version: str, + status: str, + tags: str, + metrics: str, + alerts: str, + maintenance: str, + network: str, + history_last_5_readings: str, +) -> str: + """Validate and save an IoT sensor payload to Azure Blob Storage.""" + if not device_id: + return "Error: device_id not provided." + + try: + data = { + "device_id": device_id, + "location": location, + "timestamp": timestamp, + "firmware_version": firmware_version, + "status": status, + "tags": tags, + "metrics": metrics, + "alerts": alerts if alerts else [], + "maintenance": maintenance if maintenance else {}, + "network": network, + "history_last_5_readings": history_last_5_readings if history_last_5_readings else [], + } + except json.JSONDecodeError as e: + return f"Error: one or more JSON fields are invalid. Detail: {e}" + + try: + sensor = iot_service.validate_payload(data) + except Exception as e: + return f"Payload validation error: {e}" + + file.set(sensor.to_json()) + logging.info(f"IoT payload saved for device '{device_id}'") + return f"Payload for device '{device_id}' saved successfully." + + +@app.mcp_tool(metadata=IOT_TOOL_METADATA) +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device to generate the report for." +) +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def get_sensor_report(file: func.InputStream, device_id: str) -> str: + """Read IoT sensor data from Azure Blob Storage and return a full report + with health score, metrics, active alerts, and maintenance information.""" + if not device_id: + return json.dumps({"error": "device_id not provided."}) + + try: + raw = file.read().decode("utf-8") + except Exception as e: + logging.error(f"Error reading blob for device '{device_id}': {e}") + return json.dumps({"error": f"Device '{device_id}' not found or read error."}) + + try: + data = json.loads(raw) + sensor = iot_service.validate_payload(data) + report = iot_service.generate_report(sensor) + logging.info(f"Report generated for device '{device_id}'") + return json.dumps(report) + except Exception as e: + logging.error(f"Error generating report for '{device_id}': {e}") + return json.dumps({"error": str(e)}) + + +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device whose active alerts should be listed." +) +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def list_active_alerts(file: func.InputStream, device_id: str) -> str: + """Read IoT sensor data from Azure Blob Storage and return + only the alerts that have not yet been resolved (resolved=false).""" + if not device_id: + return json.dumps({"error": "device_id not provided."}) + + try: + raw = file.read().decode("utf-8") + except Exception as e: + logging.error(f"Error reading blob for device '{device_id}': {e}") + return json.dumps({"error": f"Device '{device_id}' not found or read error."}) + + try: + data = json.loads(raw) + sensor = iot_service.validate_payload(data) + alerts = iot_service.get_active_alerts(sensor) + result = { + "device_id": device_id, + "active_alerts_count": len(alerts), + "active_alerts": alerts, + } + logging.info(f"Active alerts for device '{device_id}': {len(alerts)}") + return json.dumps(result) + except Exception as e: + logging.error(f"Error retrieving alerts for '{device_id}': {e}") + return json.dumps({"error": str(e)}) + + +@app.mcp_tool() +@app.mcp_tool_property( + arg_name="device_id", + description="Unique identifier of the IoT device to compute the metrics summary for." +) +@app.blob_input(arg_name="file", connection="AzureWebJobsStorage", path=_IOT_BLOB_PATH) +def get_sensor_metrics_summary(file: func.InputStream, device_id: str) -> str: + """Read IoT sensor data from Azure Blob Storage and return a statistical + summary of the current metrics and reading history + (min, max, average, standard deviation, trend).""" + if not device_id: + return json.dumps({"error": "device_id not provided."}) + + try: + raw = file.read().decode("utf-8") + except Exception as e: + logging.error(f"Error reading blob for device '{device_id}': {e}") + return json.dumps({"error": f"Device '{device_id}' not found or read error."}) + + try: + data = json.loads(raw) + sensor = iot_service.validate_payload(data) + summary = iot_service.get_metrics_summary(sensor) + logging.info(f"Metrics computed for device '{device_id}'") + return json.dumps(summary) + except Exception as e: + logging.error(f"Error computing metrics for '{device_id}': {e}") + return json.dumps({"error": str(e)}) + diff --git a/src/iot-app/index.html b/src/iot-app/index.html new file mode 100644 index 0000000..3b4d270 --- /dev/null +++ b/src/iot-app/index.html @@ -0,0 +1,285 @@ + + + + + + IoT Sensor Dashboard + + + +
+ +
+
📡
+
+
IoT Sensor Dashboard
+
Waiting for data…
+
+
+
+ + +
+
Current Metrics
+
+
+
🌡️ Temperature
+
+
+
+
💧 Humidity
+
+
+
+
🔵 Pressure
+
+
+
+
〰️ Vibration
+
+
+
+
⚡ Power
+
+
+
+
+ + +
+
Temperature History
+
+
+ +
+
+
+
+ + +
+
Active Alerts
+
+
No active alerts
+
+
+ + +
+
+
🌐 Network
+
IP
+
Protocol
+
Signal
+
Gateway
+
+
+
🔧 Maintenance
+
Last service
+
Next service
+
Days left
+
Technician
+
+
+ + + + + + +
+ + + diff --git a/src/iot-app/iot-dashboard.ts b/src/iot-app/iot-dashboard.ts new file mode 100644 index 0000000..6981ccc --- /dev/null +++ b/src/iot-app/iot-dashboard.ts @@ -0,0 +1,316 @@ +import { App } from "@modelcontextprotocol/ext-apps"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const el = (id: string) => document.getElementById(id)!; + +// --------------------------------------------------------------------------- +// Interfaces matching the report JSON produced by IoTService.generate_report() +// --------------------------------------------------------------------------- + +interface MetricsSummary { + device_id: string; + location: string; + timestamp: string; + current_metrics: { + temperature_celsius: number; + humidity_percent: number; + pressure_bar: number; + vibration_hz: number; + power_consumption_kw: number; + }; + temperature_history_stats: { + count?: number; + min?: number; + max?: number; + avg?: number; + stdev?: number; + latest?: number; + trend?: string; + }; + status: string; +} + +interface Alert { + device_id: string; + location: string; + code: string; + severity: string; + message: string; + triggered_at: string; + resolved: boolean; +} + +interface MaintenanceInfo { + last_service_date: string; + next_service_date: string; + technician: string; + notes: string; + days_to_next_service: number | null; +} + +interface NetworkInfo { + ip_address: string; + protocol: string; + signal_strength_dbm: number; + connected_gateway: string; +} + +interface SensorReport { + device_id: string; + location: string; + firmware_version: string; + status: string; + tags: string[]; + health_score: number; + metrics_summary: MetricsSummary; + active_alerts: Alert[]; + active_alerts_count: number; + maintenance: MaintenanceInfo; + network: NetworkInfo; + report_generated_at: string; +} + +// --------------------------------------------------------------------------- +// Status helpers +// --------------------------------------------------------------------------- + +function statusIcon(status: string): string { + switch (status) { + case "operational": return "🟢"; + case "degraded": return "🟡"; + case "offline": return "🔴"; + case "maintenance": return "🔧"; + default: return "📡"; + } +} + +function healthClass(score: number): string { + if (score >= 80) return "health-good"; + if (score >= 50) return "health-warn"; + return "health-bad"; +} + +function alertIcon(severity: string): string { + switch (severity) { + case "critical": return "🔴"; + case "warning": return "🟡"; + case "info": return "🔵"; + default: return "⚪"; + } +} + +// --------------------------------------------------------------------------- +// Sparkline renderer +// --------------------------------------------------------------------------- + +function renderSparkline(containerId: string, values: number[]): void { + const svg = el(containerId) as unknown as SVGSVGElement; + if (!svg || values.length === 0) return; + + const w = 300; + const h = 60; + const pad = 4; + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const step = (w - pad * 2) / Math.max(values.length - 1, 1); + + const points = values.map((v, i) => { + const x = pad + i * step; + const y = h - pad - ((v - min) / range) * (h - pad * 2); + return `${x},${y}`; + }); + + // Gradient area + const areaPoints = [ + `${pad},${h - pad}`, + ...points, + `${pad + (values.length - 1) * step},${h - pad}`, + ].join(" "); + + // Line path + const linePath = points.join(" "); + + svg.innerHTML = ` + + + + + + + + + ${points.map((p) => ``).join("")} + `; +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +function render(report: SensorReport): void { + // Header + el("status-icon").textContent = statusIcon(report.status); + el("device-name").textContent = report.device_id; + el("device-location").textContent = `${report.location} · fw ${report.firmware_version} · ${report.status}`; + + const badge = el("health-badge"); + badge.textContent = `${report.health_score}`; + badge.className = `health-badge ${healthClass(report.health_score)}`; + + // Metrics + const cm = report.metrics_summary.current_metrics; + el("m-temp").textContent = `${cm.temperature_celsius} °C`; + el("m-humidity").textContent = `${cm.humidity_percent} %`; + el("m-pressure").textContent = `${cm.pressure_bar} bar`; + el("m-vibration").textContent = `${cm.vibration_hz} Hz`; + el("m-power").textContent = `${cm.power_consumption_kw} kW`; + + // History sparkline + const hs = report.metrics_summary.temperature_history_stats; + if (hs && hs.count && hs.count > 0) { + // We don't have the raw values in the report, but we can reconstruct a tiny + // sparkline from the stats if we have at least min/max/avg/latest. + // Actually the report comes from generate_report which includes metrics_summary + // that itself may not carry raw readings. We'll parse tool result content to + // check for raw readings embedded. For now use the stats we have. + const statsHtml: string[] = []; + if (hs.min !== undefined) statsHtml.push(`Min ${hs.min}°C`); + if (hs.max !== undefined) statsHtml.push(`Max ${hs.max}°C`); + if (hs.avg !== undefined) statsHtml.push(`Avg ${hs.avg}°C`); + if (hs.stdev !== undefined) statsHtml.push(`σ ${hs.stdev}`); + if (hs.trend) statsHtml.push(`Trend ${trendArrow(hs.trend)}`); + el("history-stats").innerHTML = statsHtml.join(""); + } + + // Alerts + const alertsContainer = el("alerts-list"); + if (report.active_alerts.length === 0) { + alertsContainer.innerHTML = `
✅ No active alerts
`; + } else { + alertsContainer.innerHTML = report.active_alerts + .map( + (a) => ` +
+
${alertIcon(a.severity)}
+
+
${escapeHtml(a.code)}
+
${escapeHtml(a.message)}
+
+
${escapeHtml(a.triggered_at)}
+
` + ) + .join(""); + } + + // Network + el("net-ip").textContent = report.network.ip_address; + el("net-proto").textContent = report.network.protocol; + el("net-signal").textContent = `${report.network.signal_strength_dbm} dBm`; + el("net-gw").textContent = report.network.connected_gateway; + + // Maintenance + el("maint-last").textContent = report.maintenance.last_service_date; + el("maint-next").textContent = report.maintenance.next_service_date; + el("maint-days").textContent = + report.maintenance.days_to_next_service !== null + ? `${report.maintenance.days_to_next_service}d` + : "—"; + el("maint-tech").textContent = report.maintenance.technician; + + // Tags + if (report.tags && report.tags.length > 0) { + el("tags-section").style.display = ""; + el("tags-list").innerHTML = report.tags + .map((t) => `${escapeHtml(t)}`) + .join(""); + } + + // Footer + el("footer").textContent = `Report generated at ${report.report_generated_at}`; +} + +// --------------------------------------------------------------------------- +// Small utilities +// --------------------------------------------------------------------------- + +function trendArrow(trend: string): string { + switch (trend) { + case "rising": return "↑ Rising"; + case "falling": return "↓ Falling"; + default: return "→ Stable"; + } +} + +function escapeHtml(text: string): string { + const d = document.createElement("div"); + d.textContent = text; + return d.innerHTML; +} + +// --------------------------------------------------------------------------- +// Parse tool result from MCP host +// --------------------------------------------------------------------------- + +function parseToolResult( + content: Array<{ type: string; text?: string }> | undefined +): SensorReport | null { + if (!content || content.length === 0) return null; + const textBlock = content.find((c) => c.type === "text" && c.text); + if (!textBlock?.text) return null; + try { + return JSON.parse(textBlock.text) as SensorReport; + } catch (e) { + console.error("Failed to parse sensor report:", e); + return null; + } +} + +// --------------------------------------------------------------------------- +// MCP App bootstrap +// --------------------------------------------------------------------------- + +const app = new App({ name: "IoT Sensor Dashboard", version: "1.0.0" }); + +app.ontoolinput = (params) => { + console.log("Tool args:", params.arguments); + app.sendLog({ + level: "info", + data: `Received tool input: ${JSON.stringify(params.arguments)}`, + }); +}; + +app.ontoolresult = (params) => { + console.log("Tool result:", params.content); + const report = parseToolResult( + params.content as Array<{ type: string; text?: string }> + ); + if (report) { + // If we can extract raw history readings from the report, render sparkline + const hs = report.metrics_summary.temperature_history_stats; + // Reconstruct approximate sparkline from min/avg/latest if we have them + if (hs && hs.min !== undefined && hs.max !== undefined && hs.avg !== undefined && hs.latest !== undefined) { + const fakeReadings = [hs.min, hs.avg, hs.max, hs.avg, hs.latest]; + renderSparkline("sparkline", fakeReadings); + } + render(report); + } else { + el("device-location").textContent = "Error parsing sensor data"; + } +}; + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) { + document.documentElement.dataset.theme = ctx.theme; + } +}; + +(async () => { + await app.connect(); + const theme = app.getHostContext()?.theme; + if (theme) document.documentElement.dataset.theme = theme; + el("footer").textContent = "Connected — waiting for sensor report…"; +})(); diff --git a/src/iot-app/package.json b/src/iot-app/package.json new file mode 100644 index 0000000..c2f7acc --- /dev/null +++ b/src/iot-app/package.json @@ -0,0 +1,19 @@ +{ + "name": "iot-dashboard", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0" + }, + "devDependencies": { + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/src/iot-app/tsconfig.json b/src/iot-app/tsconfig.json new file mode 100644 index 0000000..1735e1f --- /dev/null +++ b/src/iot-app/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/src/iot-app/vite.config.ts b/src/iot-app/vite.config.ts new file mode 100644 index 0000000..91a0fbc --- /dev/null +++ b/src/iot-app/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { viteSingleFile } from 'vite-plugin-singlefile' + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/src/iot_service.py b/src/iot_service.py new file mode 100644 index 0000000..d318f4d --- /dev/null +++ b/src/iot_service.py @@ -0,0 +1,308 @@ +"""IoT Service - Models and analysis logic for data coming from industrial sensors.""" + +import json +import statistics +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Data Models +# --------------------------------------------------------------------------- + +@dataclass +class AlertModel: + """A single alert emitted by the sensor.""" + + code: str + severity: str + message: str + triggered_at: str + resolved: bool = False + + def __post_init__(self) -> None: + allowed = {"info", "warning", "critical"} + if self.severity not in allowed: + raise ValueError(f"'severity' must be one of {allowed}, got: '{self.severity}'") + + +@dataclass +class MetricsModel: + """Physical measurements detected by the sensor at the current instant.""" + + temperature_celsius: float + humidity_percent: float + pressure_bar: float + vibration_hz: float + power_consumption_kw: float + + def __post_init__(self) -> None: + if self.temperature_celsius < -273.15: + raise ValueError("Temperature cannot be below absolute zero") + if not (0 <= self.humidity_percent <= 100): + raise ValueError("humidity_percent must be between 0 and 100") + if self.pressure_bar <= 0: + raise ValueError("pressure_bar must be greater than 0") + if self.vibration_hz < 0: + raise ValueError("vibration_hz must be >= 0") + if self.power_consumption_kw < 0: + raise ValueError("power_consumption_kw must be >= 0") + self.temperature_celsius = round(self.temperature_celsius, 2) + + +@dataclass +class MaintenanceInfoModel: + """Information about the last and next scheduled maintenance of the device.""" + + last_service_date: str + next_service_date: str + technician: str + notes: str = "" + + def __post_init__(self) -> None: + try: + last = datetime.strptime(self.last_service_date, "%Y-%m-%d") + nxt = datetime.strptime(self.next_service_date, "%Y-%m-%d") + except ValueError: + raise ValueError("Dates must be in YYYY-MM-DD format") + if nxt <= last: + raise ValueError("next_service_date must be later than last_service_date") + + +@dataclass +class NetworkInfoModel: + """Network and connectivity information for the device.""" + + ip_address: str + protocol: str + signal_strength_dbm: int + connected_gateway: str + + def __post_init__(self) -> None: + if self.signal_strength_dbm > 0: + raise ValueError("signal_strength_dbm must be <= 0 (negative dBm value)") + if self.signal_strength_dbm < -120: + raise ValueError("signal_strength_dbm cannot be lower than -120 dBm") + + +@dataclass +class SensorPayload: + """Full payload structure sent by an industrial IoT sensor. + + Example of a valid JSON payload:: + + { + "device_id": "sensor-industrial-001", + "location": "Plant-A / Zone-3 / Line-2", + "timestamp": "2026-03-19T10:30:00Z", + "firmware_version": "2.4.1", + "status": "operational", + "tags": ["critical", "monitored", "area-b"], + "metrics": { + "temperature_celsius": 72.4, + "humidity_percent": 45.2, + "pressure_bar": 3.15, + "vibration_hz": 120.8, + "power_consumption_kw": 18.7 + }, + "alerts": [ + { + "code": "TEMP_HIGH", + "severity": "warning", + "message": "Temperature above threshold", + "triggered_at": "2026-03-19T10:28:00Z" + } + ], + "maintenance": { + "last_service_date": "2025-12-01", + "next_service_date": "2026-06-01", + "technician": "Mario Rossi", + "notes": "Replaced filter unit B" + }, + "network": { + "ip_address": "192.168.10.45", + "protocol": "MQTT", + "signal_strength_dbm": -67, + "connected_gateway": "gw-plant-a-01" + }, + "history_last_5_readings": [71.2, 71.8, 72.0, 72.1, 72.4] + } + """ + + device_id: str + location: str + timestamp: str + firmware_version: str + status: str + metrics: MetricsModel + maintenance: MaintenanceInfoModel + network: NetworkInfoModel + tags: List[str] = field(default_factory=list) + alerts: List[AlertModel] = field(default_factory=list) + history_last_5_readings: List[float] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.device_id: + raise ValueError("device_id must not be empty") + if not self.location: + raise ValueError("location must not be empty") + allowed_statuses = {"operational", "degraded", "offline", "maintenance"} + if self.status not in allowed_statuses: + raise ValueError(f"'status' must be one of {allowed_statuses}, got: '{self.status}'") + if len(self.history_last_5_readings) > 10: + raise ValueError("history_last_5_readings can contain at most 10 values") + try: + datetime.fromisoformat(self.timestamp.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"timestamp '{self.timestamp}' is not a valid ISO 8601 format") + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + def to_json(self) -> str: + return json.dumps(self.to_dict()) + + +# --------------------------------------------------------------------------- +# IoT Service +# --------------------------------------------------------------------------- + +class IoTService: + """Service for validating and analysing payloads from IoT sensors.""" + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def validate_payload(self, data: Dict[str, Any]) -> SensorPayload: + """Validate a raw dictionary and convert it into a SensorPayload. + + Raises ``ValueError`` if any field is missing or invalid. + """ + metrics = MetricsModel(**data["metrics"]) + maintenance = MaintenanceInfoModel(**data["maintenance"]) + network = NetworkInfoModel(**data["network"]) + alerts = [AlertModel(**a) for a in data.get("alerts", [])] + + return SensorPayload( + device_id=data["device_id"], + location=data["location"], + timestamp=data["timestamp"], + firmware_version=data["firmware_version"], + status=data["status"], + tags=data.get("tags", []), + metrics=metrics, + alerts=alerts, + maintenance=maintenance, + network=network, + history_last_5_readings=data.get("history_last_5_readings", []), + ) + + # ------------------------------------------------------------------ + # Metrics analysis + # ------------------------------------------------------------------ + + def get_metrics_summary(self, payload: SensorPayload) -> Dict[str, Any]: + """Return a summary of the current metrics and a statistical analysis + of the temperature reading history.""" + + history = payload.history_last_5_readings + + history_stats: Dict[str, Any] = {} + if history: + history_stats = { + "count": len(history), + "min": round(min(history), 2), + "max": round(max(history), 2), + "avg": round(statistics.mean(history), 2), + "stdev": round(statistics.stdev(history), 2) if len(history) > 1 else 0.0, + "latest": history[-1], + "trend": ( + "rising" + if len(history) >= 2 and history[-1] > history[-2] + else "falling" + if len(history) >= 2 and history[-1] < history[-2] + else "stable" + ), + } + + return { + "device_id": payload.device_id, + "location": payload.location, + "timestamp": payload.timestamp, + "current_metrics": asdict(payload.metrics), + "temperature_history_stats": history_stats, + "status": payload.status, + } + + # ------------------------------------------------------------------ + # Alerts + # ------------------------------------------------------------------ + + def get_active_alerts(self, payload: SensorPayload) -> List[Dict[str, Any]]: + """Return unresolved alerts, enriched with device_id and location.""" + return [ + { + "device_id": payload.device_id, + "location": payload.location, + **asdict(alert), + } + for alert in payload.alerts + if not alert.resolved + ] + + # ------------------------------------------------------------------ + # Full report + # ------------------------------------------------------------------ + + def generate_report(self, payload: SensorPayload) -> Dict[str, Any]: + """Generate a full device report with health score, metrics, + active alerts, and network and maintenance information.""" + + active_alerts = self.get_active_alerts(payload) + metrics_summary = self.get_metrics_summary(payload) + + # Health score: starts at 100, decremented by degraded status and active alerts + health_score = 100 + status_penalties = { + "degraded": 20, + "offline": 60, + "maintenance": 10, + } + health_score -= status_penalties.get(payload.status, 0) + + severity_penalties = {"critical": 20, "warning": 10, "info": 2} + for alert in active_alerts: + health_score -= severity_penalties.get(alert.get("severity", "info"), 0) + + health_score = max(0, health_score) + + # Days until next scheduled maintenance + days_to_maintenance: Optional[int] = None + try: + next_svc = datetime.strptime( + payload.maintenance.next_service_date, "%Y-%m-%d" + ).replace(tzinfo=timezone.utc) + now = datetime.now(tz=timezone.utc) + days_to_maintenance = (next_svc - now).days + except Exception: + pass + + return { + "device_id": payload.device_id, + "location": payload.location, + "firmware_version": payload.firmware_version, + "status": payload.status, + "tags": payload.tags, + "health_score": health_score, + "metrics_summary": metrics_summary, + "active_alerts": active_alerts, + "active_alerts_count": len(active_alerts), + "maintenance": { + **asdict(payload.maintenance), + "days_to_next_service": days_to_maintenance, + }, + "network": asdict(payload.network), + "report_generated_at": datetime.now(tz=timezone.utc).isoformat(), + }