dosync 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/mcp_server.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — MCP Server
|
|
3
|
+
====================
|
|
4
|
+
Expone el hub DoSync como un servidor MCP (Model Context Protocol).
|
|
5
|
+
|
|
6
|
+
Con esto, cualquier LLM que soporte MCP (Claude, ChatGPT, Cursor, etc.)
|
|
7
|
+
puede controlar el hogar via DoSync sin configuración adicional.
|
|
8
|
+
|
|
9
|
+
Uso:
|
|
10
|
+
# Arrancar como servidor MCP standalone (stdio — para Claude Desktop)
|
|
11
|
+
PYTHONPATH=. python3 dosync/mcp_server.py
|
|
12
|
+
|
|
13
|
+
# Con autenticación
|
|
14
|
+
DOSYNC_TOKEN=<tu-token> PYTHONPATH=. python3 dosync/mcp_server.py
|
|
15
|
+
|
|
16
|
+
Configuración en Claude Desktop (~/.config/claude/claude_desktop_config.json):
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"dosync": {
|
|
20
|
+
"command": "python3",
|
|
21
|
+
"args": ["/ruta/a/dosync/dosync/mcp_server.py"],
|
|
22
|
+
"env": {
|
|
23
|
+
"DOSYNC_TOKEN": "<tu-token>",
|
|
24
|
+
"DOSYNC_HUB_URL": "http://localhost:47200"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Herramientas expuestas al LLM:
|
|
31
|
+
dosync_fire_intent — ejecutar una intención semántica
|
|
32
|
+
dosync_list_devices — listar dispositivos registrados
|
|
33
|
+
dosync_get_status — estado del hub + ocupación
|
|
34
|
+
dosync_send_event — enviar un evento de dispositivo
|
|
35
|
+
dosync_get_audit_log — últimas entradas del audit log
|
|
36
|
+
dosync_get_scenarios — listar escenarios disponibles
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
import asyncio
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import sys
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger("dosync.mcp")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import mcp.server.stdio
|
|
51
|
+
import mcp.types as types
|
|
52
|
+
from mcp.server import Server
|
|
53
|
+
from mcp.server.models import InitializationOptions
|
|
54
|
+
MCP_AVAILABLE = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
MCP_AVAILABLE = False
|
|
57
|
+
print("mcp not installed. Run: pip install mcp", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
import httpx
|
|
62
|
+
HTTP_AVAILABLE = True
|
|
63
|
+
except ImportError:
|
|
64
|
+
HTTP_AVAILABLE = False
|
|
65
|
+
|
|
66
|
+
# ── Configuración ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
HUB_URL = os.environ.get("DOSYNC_HUB_URL", "http://localhost:47200")
|
|
69
|
+
HUB_TOKEN = os.environ.get("DOSYNC_TOKEN", "")
|
|
70
|
+
CA_CERT = os.environ.get("DOSYNC_CA_CERT", None)
|
|
71
|
+
|
|
72
|
+
# Polling timeout for async intents.
|
|
73
|
+
# Derived from DOSYNC_INTENT_TIMEOUT + 3s network margin.
|
|
74
|
+
# emergency default: 5 + 3 = 8s, info/alert default: 10 + 3 = 13s
|
|
75
|
+
_EMERGENCY_HUB_TIMEOUT = float(os.environ.get("DOSYNC_INTENT_TIMEOUT", "5"))
|
|
76
|
+
_DEFAULT_HUB_TIMEOUT = float(os.environ.get("DOSYNC_INTENT_TIMEOUT", "10"))
|
|
77
|
+
MCP_EMERGENCY_TIMEOUT = _EMERGENCY_HUB_TIMEOUT + 3
|
|
78
|
+
MCP_DEFAULT_TIMEOUT = _DEFAULT_HUB_TIMEOUT + 3
|
|
79
|
+
POLL_INTERVAL = 1.0 # seconds between polls
|
|
80
|
+
|
|
81
|
+
# ── HTTP helper ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async def hub_request(method: str, path: str, body: dict = None) -> dict:
|
|
84
|
+
"""Llama a la API REST del hub DoSync."""
|
|
85
|
+
headers = {"Content-Type": "application/json"}
|
|
86
|
+
if HUB_TOKEN:
|
|
87
|
+
headers["Authorization"] = f"Bearer {HUB_TOKEN}"
|
|
88
|
+
|
|
89
|
+
if not HTTP_AVAILABLE:
|
|
90
|
+
# Fallback sin httpx — usar urllib
|
|
91
|
+
import urllib.request
|
|
92
|
+
import urllib.error
|
|
93
|
+
data = json.dumps(body).encode() if body else None
|
|
94
|
+
req = urllib.request.Request(
|
|
95
|
+
HUB_URL + path, data=data, headers=headers, method=method
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
99
|
+
return json.loads(resp.read())
|
|
100
|
+
except urllib.error.HTTPError as e:
|
|
101
|
+
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
|
|
102
|
+
except Exception as e:
|
|
103
|
+
return {"error": str(e)}
|
|
104
|
+
|
|
105
|
+
async with httpx.AsyncClient(verify=CA_CERT if CA_CERT else True) as client:
|
|
106
|
+
try:
|
|
107
|
+
if method == "GET":
|
|
108
|
+
r = await client.get(HUB_URL + path, headers=headers, timeout=10)
|
|
109
|
+
else:
|
|
110
|
+
r = await client.post(HUB_URL + path, headers=headers,
|
|
111
|
+
json=body, timeout=10)
|
|
112
|
+
r.raise_for_status()
|
|
113
|
+
return r.json()
|
|
114
|
+
except httpx.HTTPStatusError as e:
|
|
115
|
+
return {"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return {"error": str(e)}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fmt(data: dict) -> str:
|
|
121
|
+
"""Formatea la respuesta del hub para el LLM."""
|
|
122
|
+
if "error" in data:
|
|
123
|
+
return f"Error: {data['error']}"
|
|
124
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── MCP Server ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
server = Server("dosync-hub")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _intent_property_schema() -> dict:
|
|
133
|
+
"""Build the JSON-schema fragment for the `intent` argument by reading the
|
|
134
|
+
intent classes the hub declares. Returns a property dict either with a live
|
|
135
|
+
`enum` (hub reachable) or a plain string (hub unreachable — degrade gracefully;
|
|
136
|
+
the hub validates anyway). Also surfaces, in the description, which intents are
|
|
137
|
+
compositions so the AI knows they need geographic context."""
|
|
138
|
+
base_desc = "Clase de intención semántica (declarada en el hub)"
|
|
139
|
+
try:
|
|
140
|
+
listing = await hub_request("GET", "/v1/intent-classes")
|
|
141
|
+
classes = listing.get("intent_classes") if isinstance(listing, dict) else None
|
|
142
|
+
if classes:
|
|
143
|
+
names = [c.get("name") for c in classes if c.get("name")]
|
|
144
|
+
# Note which ones are compositions — they need structured context.
|
|
145
|
+
composites = [c.get("name") for c in classes
|
|
146
|
+
if c.get("name") and c.get("composition_kind")]
|
|
147
|
+
desc = base_desc
|
|
148
|
+
if composites:
|
|
149
|
+
desc += (". Composition intents " + ", ".join(sorted(composites))
|
|
150
|
+
+ " require geographic context (e.g. center=[lat,lon], "
|
|
151
|
+
"radius_m, altitude_m) passed in the 'context' object.")
|
|
152
|
+
if names:
|
|
153
|
+
return {"type": "string", "description": desc, "enum": sorted(names)}
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
# Fallback: hub unreachable. Free-form string — the hub validates on fire.
|
|
157
|
+
return {"type": "string",
|
|
158
|
+
"description": base_desc + " (hub no consultado — el hub validará)"}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@server.list_tools()
|
|
162
|
+
async def list_tools() -> list[types.Tool]:
|
|
163
|
+
"""Define las herramientas disponibles para el LLM."""
|
|
164
|
+
# Read the available intent classes from the hub — the single source of truth.
|
|
165
|
+
# The hub declares them in /v1/intent-classes; the MCP reflects that rather than
|
|
166
|
+
# carrying its own hardcoded copy (which inevitably diverges — that is how
|
|
167
|
+
# inspect_area was invisible to the AI). This is the protocol's "everything is
|
|
168
|
+
# declared" principle applied to the AI layer: a new intent declared on the hub
|
|
169
|
+
# appears here with no code change. The enum is only a guide for the AI; the hub
|
|
170
|
+
# is the real validator (it returns 422 for an unregistered intent), so if the
|
|
171
|
+
# hub is unreachable when describing tools we degrade gracefully to a free-form
|
|
172
|
+
# string and the AI can still fire a known intent by name.
|
|
173
|
+
intent_schema = await _intent_property_schema()
|
|
174
|
+
return [
|
|
175
|
+
|
|
176
|
+
types.Tool(
|
|
177
|
+
name="dosync_fire_intent",
|
|
178
|
+
description=(
|
|
179
|
+
"Ejecuta una intención semántica en el hub DoSync. "
|
|
180
|
+
"El hub resuelve automáticamente qué dispositivos actúan y cómo, "
|
|
181
|
+
"basándose en sus capacidades declaradas. "
|
|
182
|
+
"Usar para: emergencias, rutinas, control del ambiente, notificaciones."
|
|
183
|
+
),
|
|
184
|
+
inputSchema={
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {
|
|
187
|
+
"intent": intent_schema,
|
|
188
|
+
"urgency": {
|
|
189
|
+
"type": "string",
|
|
190
|
+
"description": "Urgency level: info=routine, alert=high-priority (action likely), emergency=bypass all policies (immediate execution)",
|
|
191
|
+
"enum": ["info", "warning", "alert", "emergency"],
|
|
192
|
+
"default": "info",
|
|
193
|
+
},
|
|
194
|
+
"context": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"description": (
|
|
197
|
+
"Structured context passed to the intent's resolver. "
|
|
198
|
+
"Free-form by design — each intent reads what it needs. "
|
|
199
|
+
"Composition intents (e.g. inspect_area) need geographic "
|
|
200
|
+
"fields: device_id (the vehicle), center=[lat,lon], "
|
|
201
|
+
"radius_m, altitude_m. Home intents may use location, "
|
|
202
|
+
"message, etc. The hub and resolver interpret it."
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
"subject": {
|
|
206
|
+
"type": "string",
|
|
207
|
+
"description": "Sujeto de la intención (ej: 'abuela', 'niños')",
|
|
208
|
+
},
|
|
209
|
+
"message": {
|
|
210
|
+
"type": "string",
|
|
211
|
+
"description": "Mensaje a incluir en notificaciones",
|
|
212
|
+
},
|
|
213
|
+
"location": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"description": "Ubicación relevante (ej: 'dormitorio', 'sala')",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"required": ["intent"],
|
|
219
|
+
},
|
|
220
|
+
),
|
|
221
|
+
|
|
222
|
+
types.Tool(
|
|
223
|
+
name="dosync_list_devices",
|
|
224
|
+
description=(
|
|
225
|
+
"Lista todos los dispositivos registrados en el hub DoSync "
|
|
226
|
+
"con sus capacidades, tags, y estado del adapter."
|
|
227
|
+
),
|
|
228
|
+
inputSchema={
|
|
229
|
+
"type": "object",
|
|
230
|
+
"properties": {
|
|
231
|
+
"filter_tag": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"description": "Filtrar por tag (ej: 'emergency', 'light', 'sensor')",
|
|
234
|
+
},
|
|
235
|
+
"emergency_only": {
|
|
236
|
+
"type": "boolean",
|
|
237
|
+
"description": "Mostrar solo dispositivos con emergency_capable=true",
|
|
238
|
+
"default": False,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
|
|
244
|
+
types.Tool(
|
|
245
|
+
name="dosync_get_status",
|
|
246
|
+
description=(
|
|
247
|
+
"Obtiene el estado actual del hub DoSync: "
|
|
248
|
+
"cantidad de dispositivos, ocupación del hogar, "
|
|
249
|
+
"integridad del audit log, y estadísticas de la base de datos."
|
|
250
|
+
),
|
|
251
|
+
inputSchema={
|
|
252
|
+
"type": "object",
|
|
253
|
+
"properties": {},
|
|
254
|
+
},
|
|
255
|
+
),
|
|
256
|
+
|
|
257
|
+
types.Tool(
|
|
258
|
+
name="dosync_send_event",
|
|
259
|
+
description=(
|
|
260
|
+
"Envía un evento desde un dispositivo al hub. "
|
|
261
|
+
"Usar para simular eventos de sensores (caída detectada, "
|
|
262
|
+
"movimiento, avería, etc.) o para integrar dispositivos "
|
|
263
|
+
"que no tienen adapter nativo."
|
|
264
|
+
),
|
|
265
|
+
inputSchema={
|
|
266
|
+
"type": "object",
|
|
267
|
+
"properties": {
|
|
268
|
+
"device_id": {
|
|
269
|
+
"type": "string",
|
|
270
|
+
"description": "ID del dispositivo que emite el evento",
|
|
271
|
+
},
|
|
272
|
+
"event_id": {
|
|
273
|
+
"type": "string",
|
|
274
|
+
"description": "Tipo de evento (ej: 'fall_detected', 'malfunction', 'motion')",
|
|
275
|
+
},
|
|
276
|
+
"severity": {
|
|
277
|
+
"type": "string",
|
|
278
|
+
"description": "Event severity: info=normal, warning=notable, alert=requires attention, emergency=critical",
|
|
279
|
+
"enum": ["info", "warning", "alert", "emergency"],
|
|
280
|
+
"default": "info",
|
|
281
|
+
},
|
|
282
|
+
"data": {
|
|
283
|
+
"type": "object",
|
|
284
|
+
"description": "Datos adicionales del evento",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
"required": ["device_id", "event_id"],
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
|
|
291
|
+
types.Tool(
|
|
292
|
+
name="dosync_get_audit_log",
|
|
293
|
+
description=(
|
|
294
|
+
"Obtiene las últimas entradas del audit log del hub. "
|
|
295
|
+
"El log es tamper-evident: cada entrada está encadenada "
|
|
296
|
+
"con SHA-256. Incluye verificación de integridad."
|
|
297
|
+
),
|
|
298
|
+
inputSchema={
|
|
299
|
+
"type": "object",
|
|
300
|
+
"properties": {
|
|
301
|
+
"last_n": {
|
|
302
|
+
"type": "integer",
|
|
303
|
+
"description": "Cantidad de entradas a mostrar (default: 10)",
|
|
304
|
+
"default": 10,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
),
|
|
309
|
+
|
|
310
|
+
types.Tool(
|
|
311
|
+
name="dosync_get_scenarios",
|
|
312
|
+
description=(
|
|
313
|
+
"Devuelve la lista de escenarios disponibles en DoSync "
|
|
314
|
+
"con descripción de cuándo usar cada uno."
|
|
315
|
+
),
|
|
316
|
+
inputSchema={
|
|
317
|
+
"type": "object",
|
|
318
|
+
"properties": {},
|
|
319
|
+
},
|
|
320
|
+
),
|
|
321
|
+
|
|
322
|
+
types.Tool(
|
|
323
|
+
name="dosync_control_device",
|
|
324
|
+
description=(
|
|
325
|
+
"Control directo de un dispositivo específico. "
|
|
326
|
+
"Usar cuando el usuario pide controlar un dispositivo concreto: "
|
|
327
|
+
"'apagá las luces', 'poné las luces en rojo', 'subí el brillo', etc. "
|
|
328
|
+
"Para luces WiZ soporta: turn_on, turn_off, set_brightness (0-100), "
|
|
329
|
+
"set_color (r/g/b 0-255), set_color_temp (kelvin 2200-6500). "
|
|
330
|
+
"Para apagar TODAS las luces, llamar con device_id='all_lights'."
|
|
331
|
+
),
|
|
332
|
+
inputSchema={
|
|
333
|
+
"type": "object",
|
|
334
|
+
"properties": {
|
|
335
|
+
"device_id": {
|
|
336
|
+
"type": "string",
|
|
337
|
+
"description": "ID del dispositivo o 'all_lights' para todas las luces",
|
|
338
|
+
},
|
|
339
|
+
"action": {
|
|
340
|
+
"type": "string",
|
|
341
|
+
"description": "Acción a ejecutar",
|
|
342
|
+
"enum": ["turn_on", "turn_off", "set_brightness",
|
|
343
|
+
"set_color", "set_color_temp", "set_effect"],
|
|
344
|
+
},
|
|
345
|
+
"brightness": {
|
|
346
|
+
"type": "integer",
|
|
347
|
+
"description": "Brillo 0-100 (para set_brightness o turn_on)",
|
|
348
|
+
},
|
|
349
|
+
"r": {"type": "integer", "description": "Rojo 0-255"},
|
|
350
|
+
"g": {"type": "integer", "description": "Verde 0-255"},
|
|
351
|
+
"b": {"type": "integer", "description": "Azul 0-255"},
|
|
352
|
+
"kelvin": {
|
|
353
|
+
"type": "integer",
|
|
354
|
+
"description": "Temperatura de color en Kelvin (2200-6500)",
|
|
355
|
+
},
|
|
356
|
+
"effect": {
|
|
357
|
+
"type": "string",
|
|
358
|
+
"description": "Efecto Ambilight. Valores: FOLLOW_COLOR: HOT_LAVA, FOLLOW_COLOR: DEEP_WATER, FOLLOW_COLOR: FRESH_NATURE, FOLLOW_VIDEO: STANDARD, FOLLOW_VIDEO: VIVID, FOLLOW_AUDIO: ENERGY_ADAPTIVE_BRIGHTNESS, Mode: lounge",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
"required": ["device_id", "action"],
|
|
362
|
+
},
|
|
363
|
+
),
|
|
364
|
+
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@server.call_tool()
|
|
369
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
370
|
+
"""Ejecuta una herramienta y retorna el resultado al LLM."""
|
|
371
|
+
|
|
372
|
+
# ── dosync_fire_intent ────────────────────────────────────────────────────
|
|
373
|
+
if name == "dosync_fire_intent":
|
|
374
|
+
intent = arguments.get("intent")
|
|
375
|
+
urgency = arguments.get("urgency", "info")
|
|
376
|
+
subject = arguments.get("subject")
|
|
377
|
+
message = arguments.get("message", "")
|
|
378
|
+
location = arguments.get("location", "")
|
|
379
|
+
# Arbitrary structured context the AI fills per intent (center/radius_m/
|
|
380
|
+
# altitude_m for a composition, etc.). Passed through to the hub as-is.
|
|
381
|
+
ctx = dict(arguments.get("context") or {})
|
|
382
|
+
|
|
383
|
+
# Merge the home-automation convenience fields into context for backward
|
|
384
|
+
# compatibility, without overwriting anything the AI put in `context`.
|
|
385
|
+
for k, v in (("message", message), ("location", location)):
|
|
386
|
+
if v and k not in ctx:
|
|
387
|
+
ctx[k] = v
|
|
388
|
+
ctx.setdefault("trigger", "mcp_client")
|
|
389
|
+
|
|
390
|
+
body = {
|
|
391
|
+
"intent": intent,
|
|
392
|
+
"urgency": urgency,
|
|
393
|
+
"subject": subject,
|
|
394
|
+
"source": "mcp",
|
|
395
|
+
"context": ctx,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
# Fire async — returns intent_id immediately, no blocking
|
|
399
|
+
fire_result = await hub_request("POST", "/v1/intent/async", body)
|
|
400
|
+
|
|
401
|
+
if "error" in fire_result:
|
|
402
|
+
return [types.TextContent(type="text",
|
|
403
|
+
text=f"❌ Error ejecutando intent '{intent}': {fire_result['error']}")]
|
|
404
|
+
|
|
405
|
+
intent_id = fire_result.get("intent_id")
|
|
406
|
+
if not intent_id:
|
|
407
|
+
return [types.TextContent(type="text",
|
|
408
|
+
text=f"❌ Hub no retornó intent_id para '{intent}'")]
|
|
409
|
+
|
|
410
|
+
# Poll until completed or timeout
|
|
411
|
+
# Timeout = DOSYNC_INTENT_TIMEOUT + 3s network margin
|
|
412
|
+
poll_timeout = MCP_EMERGENCY_TIMEOUT if urgency == "emergency" else MCP_DEFAULT_TIMEOUT
|
|
413
|
+
import time as _mcp_time
|
|
414
|
+
deadline = _mcp_time.monotonic() + poll_timeout
|
|
415
|
+
result = None
|
|
416
|
+
|
|
417
|
+
while _mcp_time.monotonic() < deadline:
|
|
418
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
419
|
+
poll = await hub_request("GET", f"/v1/intent/{intent_id}")
|
|
420
|
+
if "error" in poll:
|
|
421
|
+
break
|
|
422
|
+
if poll.get("status") != "pending":
|
|
423
|
+
result = poll
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
# Polling timeout — make one final aggressive attempt before giving up
|
|
427
|
+
if result is None:
|
|
428
|
+
for _ in range(3):
|
|
429
|
+
await asyncio.sleep(1.5)
|
|
430
|
+
final_poll = await hub_request("GET", f"/v1/intent/{intent_id}")
|
|
431
|
+
if not final_poll.get("error") and final_poll.get("status") != "pending":
|
|
432
|
+
result = final_poll
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
# Still no result before the deadline — but "no final result" is NOT "no
|
|
436
|
+
# information". MCP-V13: read the partial progress the hub has been
|
|
437
|
+
# publishing and report what ALREADY happened, instead of an opaque
|
|
438
|
+
# "still processing". In an emergency, "8 actions already executed, 2
|
|
439
|
+
# devices still pending" is the difference between a useful answer and a
|
|
440
|
+
# blind one.
|
|
441
|
+
if result is None:
|
|
442
|
+
last = await hub_request("GET", f"/v1/intent/{intent_id}")
|
|
443
|
+
partial = (last or {}).get("partial") or {}
|
|
444
|
+
done = partial.get("results", [])
|
|
445
|
+
n_done = partial.get("actions_completed", len(done))
|
|
446
|
+
ok_done = [r for r in done if r.get("success")]
|
|
447
|
+
|
|
448
|
+
text = f"⏳ Intent '{intent}' [{urgency}] accepted — still executing after "
|
|
449
|
+
text += f"{poll_timeout + 4.5:.0f}s, partial result below\n"
|
|
450
|
+
if n_done:
|
|
451
|
+
text += f" Already completed: {len(ok_done)}/{n_done} action(s) succeeded so far\n"
|
|
452
|
+
for r in ok_done[:8]:
|
|
453
|
+
text += f" ✓ {r.get('device_id')} — {r.get('action')}\n"
|
|
454
|
+
slow = [r for r in done if not r.get("success")]
|
|
455
|
+
if slow:
|
|
456
|
+
text += f" Still pending / unreachable: {len(slow)} device(s)\n"
|
|
457
|
+
else:
|
|
458
|
+
text += f" No actions have completed yet — the hub is still resolving or every device is slow.\n"
|
|
459
|
+
text += f" Intent ID: {intent_id} — poll GET /v1/intent/{{id}} for the final result.\n"
|
|
460
|
+
return [types.TextContent(type="text", text=text)]
|
|
461
|
+
|
|
462
|
+
actions = result.get("actions_taken", 0)
|
|
463
|
+
failed = result.get("failed_devices", [])
|
|
464
|
+
aborted = result.get("aborted_devices", [])
|
|
465
|
+
results_list = result.get("results", [])
|
|
466
|
+
intent_status = result.get("status", "unknown")
|
|
467
|
+
|
|
468
|
+
# Determine display icon based on what actually happened:
|
|
469
|
+
# - success / partial with some actions → ✅
|
|
470
|
+
# - partial with 0 actions (all unreachable) → ⚠️ with clear explanation
|
|
471
|
+
# - failed / blocked → ❌
|
|
472
|
+
if intent_status == "success" or (intent_status in ("partial", "partial_abort") and actions > 0):
|
|
473
|
+
icon = "✅"
|
|
474
|
+
elif intent_status in ("partial", "partial_abort", "failed") and failed:
|
|
475
|
+
icon = "⚠️" # devices unreachable — protocol worked, hardware was off
|
|
476
|
+
else:
|
|
477
|
+
icon = "❌"
|
|
478
|
+
|
|
479
|
+
text = f"{icon} Intent '{intent}' [{urgency}] ejecutado\n"
|
|
480
|
+
text += f" Acciones completadas: {actions}\n"
|
|
481
|
+
|
|
482
|
+
if failed:
|
|
483
|
+
text += f" Sin respuesta ({len(failed)} dispositivos físicamente apagados) — excluidos automáticamente por ~30 min\n"
|
|
484
|
+
if aborted:
|
|
485
|
+
text += f" Cancelados por FailurePolicy: {len(aborted)} dispositivos\n"
|
|
486
|
+
|
|
487
|
+
critical = [r for r in results_list
|
|
488
|
+
if r.get("success") and r.get("action") in
|
|
489
|
+
("unlock","alarm","call","notify","turn_on","set_brightness")]
|
|
490
|
+
if critical:
|
|
491
|
+
text += "\nAcciones críticas ejecutadas:\n"
|
|
492
|
+
for r in critical[:8]:
|
|
493
|
+
resp = r.get("response", {})
|
|
494
|
+
status_val = resp.get("status","ok") if isinstance(resp, dict) else "ok"
|
|
495
|
+
text += f" ✓ [{r['device_id']}] {r['action']} → {status_val}\n"
|
|
496
|
+
|
|
497
|
+
return [types.TextContent(type="text", text=text)]
|
|
498
|
+
elif name == "dosync_list_devices":
|
|
499
|
+
filter_tag = arguments.get("filter_tag", "")
|
|
500
|
+
emergency_only = arguments.get("emergency_only", False)
|
|
501
|
+
|
|
502
|
+
result = await hub_request("GET", "/v1/devices")
|
|
503
|
+
|
|
504
|
+
if "error" in result:
|
|
505
|
+
return [types.TextContent(type="text", text=f"Error: {result['error']}")]
|
|
506
|
+
|
|
507
|
+
devices = result.get("devices", [])
|
|
508
|
+
|
|
509
|
+
# Aplicar filtros
|
|
510
|
+
if filter_tag:
|
|
511
|
+
devices = [d for d in devices if filter_tag in d.get("tags", [])]
|
|
512
|
+
if emergency_only:
|
|
513
|
+
devices = [d for d in devices if d.get("emergency_capable")]
|
|
514
|
+
|
|
515
|
+
if not devices:
|
|
516
|
+
return [types.TextContent(type="text",
|
|
517
|
+
text="No se encontraron dispositivos con los filtros aplicados.")]
|
|
518
|
+
|
|
519
|
+
text = f"📡 {len(devices)} dispositivo(s) registrado(s):\n\n"
|
|
520
|
+
for d in devices:
|
|
521
|
+
emerg = "🚨 " if d.get("emergency_capable") else " "
|
|
522
|
+
adapter = d.get("adapter") or "simulated"
|
|
523
|
+
tags = ", ".join(d.get("tags", []))
|
|
524
|
+
caps = d.get("capabilities", {})
|
|
525
|
+
acts = [a["type"] for a in caps.get("actuators", [])]
|
|
526
|
+
text += f"{emerg}{d['device_name']} [{d['device_id']}]\n"
|
|
527
|
+
text += f" Adapter: {adapter}\n"
|
|
528
|
+
text += f" Tags: {tags}\n"
|
|
529
|
+
if acts:
|
|
530
|
+
text += f" Acciones: {', '.join(acts)}\n"
|
|
531
|
+
text += "\n"
|
|
532
|
+
|
|
533
|
+
return [types.TextContent(type="text", text=text)]
|
|
534
|
+
|
|
535
|
+
# ── dosync_get_status ─────────────────────────────────────────────────────
|
|
536
|
+
elif name == "dosync_get_status":
|
|
537
|
+
result = await hub_request("GET", "/v1/status")
|
|
538
|
+
|
|
539
|
+
if "error" in result:
|
|
540
|
+
return [types.TextContent(type="text", text=f"Error: {result['error']}")]
|
|
541
|
+
|
|
542
|
+
occupied = result.get("occupied", False)
|
|
543
|
+
ws_clients = result.get("ws_connections", 0)
|
|
544
|
+
db = result.get("db", {})
|
|
545
|
+
integrity = result.get("audit_integrity", True)
|
|
546
|
+
|
|
547
|
+
text = f"🏠 DoSync Hub v{result.get('version', '?')}\n\n"
|
|
548
|
+
text += f" Protocolo: {result.get('protocol', '?')}\n"
|
|
549
|
+
text += f" Dispositivos: {result.get('devices', 0)}\n"
|
|
550
|
+
text += f" Ocupación: {'Sí, hay alguien en casa' if occupied else 'Casa vacía'}\n"
|
|
551
|
+
text += f" Audit log: {result.get('audit_entries', 0)} entradas "
|
|
552
|
+
text += f"({'✓ íntegro' if integrity else '✗ comprometido'})\n"
|
|
553
|
+
text += f" WS clientes: {ws_clients}\n"
|
|
554
|
+
text += f" DB: {db.get('db_size_kb', '?')} KB en {db.get('db_path', '?')}\n"
|
|
555
|
+
if result.get("family_profile"):
|
|
556
|
+
text += f" Perfil: Familia {result['family_profile']}\n"
|
|
557
|
+
|
|
558
|
+
return [types.TextContent(type="text", text=text)]
|
|
559
|
+
|
|
560
|
+
# ── dosync_send_event ─────────────────────────────────────────────────────
|
|
561
|
+
elif name == "dosync_send_event":
|
|
562
|
+
body = {
|
|
563
|
+
"device_id": arguments.get("device_id"),
|
|
564
|
+
"event_id": arguments.get("event_id"),
|
|
565
|
+
"severity": arguments.get("severity", "info"),
|
|
566
|
+
"data": arguments.get("data", {}),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
result = await hub_request("POST", "/v1/event", body)
|
|
570
|
+
|
|
571
|
+
if "error" in result:
|
|
572
|
+
text = f"❌ Error: {result['error']}"
|
|
573
|
+
else:
|
|
574
|
+
text = (f"✅ Evento recibido por el hub:\n"
|
|
575
|
+
f" Dispositivo: {result.get('device_id')}\n"
|
|
576
|
+
f" Evento: {result.get('event_id')}\n"
|
|
577
|
+
f" Severidad: {result.get('severity')}\n")
|
|
578
|
+
|
|
579
|
+
return [types.TextContent(type="text", text=text)]
|
|
580
|
+
|
|
581
|
+
# ── dosync_get_audit_log ──────────────────────────────────────────────────
|
|
582
|
+
elif name == "dosync_get_audit_log":
|
|
583
|
+
last_n = arguments.get("last_n", 10)
|
|
584
|
+
result = await hub_request("GET", "/v1/audit")
|
|
585
|
+
|
|
586
|
+
if "error" in result:
|
|
587
|
+
return [types.TextContent(type="text", text=f"Error: {result['error']}")]
|
|
588
|
+
|
|
589
|
+
entries = result.get("entries", [])
|
|
590
|
+
integrity = result.get("integrity", True)
|
|
591
|
+
total = result.get("count", 0)
|
|
592
|
+
|
|
593
|
+
text = f"📋 Audit Log DoSync\n"
|
|
594
|
+
text += f" Total: {total} entradas | "
|
|
595
|
+
text += f"Integridad: {'✓ íntegra' if integrity else '✗ comprometida'}\n\n"
|
|
596
|
+
|
|
597
|
+
# Mostrar las últimas N
|
|
598
|
+
for entry in list(reversed(entries))[:last_n]:
|
|
599
|
+
kind = entry.get("type", "?")
|
|
600
|
+
hash_ = entry.get("hash", "")[:10]
|
|
601
|
+
extra = ""
|
|
602
|
+
if kind == "intent_executed":
|
|
603
|
+
extra = f" → {entry.get('intent')} [{entry.get('urgency')}]"
|
|
604
|
+
elif kind == "device_event":
|
|
605
|
+
extra = f" → {entry.get('device_id')}: {entry.get('event_id')}"
|
|
606
|
+
elif kind == "phase_executed":
|
|
607
|
+
extra = f" → fase '{entry.get('phase')}'"
|
|
608
|
+
elif kind == "presence_updated":
|
|
609
|
+
conf = entry.get('occ_confidence', 0)
|
|
610
|
+
extra = f" → occupied={entry.get('occupied')} conf={conf:.0%}"
|
|
611
|
+
text += f" [{hash_}] {kind}{extra}\n"
|
|
612
|
+
|
|
613
|
+
return [types.TextContent(type="text", text=text)]
|
|
614
|
+
|
|
615
|
+
# ── dosync_get_scenarios ──────────────────────────────────────────────────
|
|
616
|
+
elif name == "dosync_get_scenarios":
|
|
617
|
+
text = """🏠 Escenarios disponibles en DoSync:
|
|
618
|
+
|
|
619
|
+
EMERGENCIAS (urgency=emergency):
|
|
620
|
+
ensure_safety — Alguien se cayó, hay peligro. Abre puertas, llama emergencias, activa alarma.
|
|
621
|
+
(+ send_event) — Enviar evento smoke_detected para emergencia de incendio (3 fases).
|
|
622
|
+
|
|
623
|
+
NOTIFICACIONES (urgency=warning/info):
|
|
624
|
+
notify — Push information to any target (people, displays, channels).
|
|
625
|
+
alert_anomaly — Consumo eléctrico anormal, algo inesperado detectado.
|
|
626
|
+
remind_chore — Recordar una tarea doméstica (lavarropas terminó, etc.).
|
|
627
|
+
|
|
628
|
+
ENERGÍA:
|
|
629
|
+
save_energy — Nadie en casa, activar modo ahorro (luces, clima).
|
|
630
|
+
away_mode — Todos salieron, armar seguridad y bajar consumo.
|
|
631
|
+
|
|
632
|
+
RUTINAS:
|
|
633
|
+
morning_routine — Buenos días: persianas, cafetera, clima.
|
|
634
|
+
bedtime_routine — Hora de dormir: atenuar luces, bajar persianas.
|
|
635
|
+
# Domain-specific intents (e.g. children arrival, shift change) can be registered via POST /v1/intent-classes
|
|
636
|
+
|
|
637
|
+
AMBIENTE:
|
|
638
|
+
set_environment — Ajustar luces, temperatura, persianas.
|
|
639
|
+
control_access — Bloquear/desbloquear puertas.
|
|
640
|
+
monitor_health — Activar monitoreo continuo de una persona.
|
|
641
|
+
|
|
642
|
+
INFORMACIÓN:
|
|
643
|
+
report_status — Leer el estado de todos los sensores.
|
|
644
|
+
|
|
645
|
+
Niveles de urgencia:
|
|
646
|
+
info — rutinas, recordatorios
|
|
647
|
+
warning — alertas no críticas
|
|
648
|
+
alert — situación que requiere atención
|
|
649
|
+
emergency — actúa inmediatamente sin confirmación
|
|
650
|
+
"""
|
|
651
|
+
return [types.TextContent(type="text", text=text)]
|
|
652
|
+
|
|
653
|
+
# ── dosync_control_device ─────────────────────────────────────────────────
|
|
654
|
+
elif name == "dosync_control_device":
|
|
655
|
+
device_id = arguments.get("device_id")
|
|
656
|
+
action = arguments.get("action")
|
|
657
|
+
params = {}
|
|
658
|
+
if "brightness" in arguments: params["brightness"] = arguments["brightness"]
|
|
659
|
+
if "r" in arguments: params["r"] = arguments["r"]
|
|
660
|
+
if "g" in arguments: params["g"] = arguments["g"]
|
|
661
|
+
if "b" in arguments: params["b"] = arguments["b"]
|
|
662
|
+
if "kelvin" in arguments: params["kelvin"] = arguments["kelvin"]
|
|
663
|
+
if "effect" in arguments: params["effect"] = arguments["effect"]
|
|
664
|
+
|
|
665
|
+
# all_lights: get all light devices and control them
|
|
666
|
+
if device_id == "all_lights":
|
|
667
|
+
devices_result = await hub_request("GET", "/v1/devices")
|
|
668
|
+
if "error" in devices_result:
|
|
669
|
+
return [types.TextContent(type="text",
|
|
670
|
+
text=f"Error obteniendo dispositivos: {devices_result['error']}")]
|
|
671
|
+
|
|
672
|
+
light_devices = [
|
|
673
|
+
d["device_id"] for d in devices_result.get("devices", [])
|
|
674
|
+
if any(t in d.get("tags", []) for t in ["light", "wiz"])
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
if not light_devices:
|
|
678
|
+
return [types.TextContent(type="text",
|
|
679
|
+
text="No se encontraron dispositivos de luz registrados.")]
|
|
680
|
+
|
|
681
|
+
results = []
|
|
682
|
+
for did in light_devices:
|
|
683
|
+
body = {"device_id": did, "action": action, "params": params}
|
|
684
|
+
r = await hub_request("POST", "/v1/device/action", body)
|
|
685
|
+
icon = "✓" if r.get("success") and not r.get("error") else "✗"
|
|
686
|
+
results.append(f" {icon} {did}")
|
|
687
|
+
|
|
688
|
+
icon_on = "✅" if action == "turn_on" else "🌑"
|
|
689
|
+
text = f"{icon_on} {action} en {len(light_devices)} luces:\n"
|
|
690
|
+
text += "\n".join(results)
|
|
691
|
+
return [types.TextContent(type="text", text=text)]
|
|
692
|
+
|
|
693
|
+
# Single device
|
|
694
|
+
body = {"device_id": device_id, "action": action, "params": params}
|
|
695
|
+
result = await hub_request("POST", "/v1/device/action", body)
|
|
696
|
+
|
|
697
|
+
if result.get("error"):
|
|
698
|
+
text = f"❌ Error: {result['error']}"
|
|
699
|
+
elif result.get("success"):
|
|
700
|
+
text = f"✅ {device_id}: {action} ejecutado correctamente"
|
|
701
|
+
else:
|
|
702
|
+
text = f"⚠️ {device_id}: {action} falló"
|
|
703
|
+
|
|
704
|
+
return [types.TextContent(type="text", text=text)]
|
|
705
|
+
|
|
706
|
+
else:
|
|
707
|
+
return [types.TextContent(
|
|
708
|
+
type="text",
|
|
709
|
+
text=f"Herramienta desconocida: {name}",
|
|
710
|
+
)]
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
async def main():
|
|
716
|
+
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
|
|
717
|
+
|
|
718
|
+
if not HUB_TOKEN:
|
|
719
|
+
log.warning(
|
|
720
|
+
"DOSYNC_TOKEN no configurado — los requests al hub pueden fallar "
|
|
721
|
+
"si la autenticación está habilitada. "
|
|
722
|
+
"Usar: DOSYNC_AUTH=false para deshabilitar, o "
|
|
723
|
+
"DOSYNC_TOKEN=<token> para autenticar."
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
727
|
+
from mcp.server.models import InitializationOptions
|
|
728
|
+
from mcp.server.lowlevel.server import NotificationOptions
|
|
729
|
+
caps = server.get_capabilities(
|
|
730
|
+
notification_options=NotificationOptions(),
|
|
731
|
+
experimental_capabilities={},
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
await server.run(
|
|
735
|
+
read_stream,
|
|
736
|
+
write_stream,
|
|
737
|
+
InitializationOptions(
|
|
738
|
+
server_name="dosync-hub",
|
|
739
|
+
server_version="0.1.0",
|
|
740
|
+
capabilities=caps,
|
|
741
|
+
),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
if __name__ == "__main__":
|
|
746
|
+
asyncio.run(main())
|