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.
@@ -0,0 +1,409 @@
1
+ """
2
+ DoSync MQTT Adapter
3
+ ===================
4
+ Layer 1 transport: MQTT broker as the communication channel.
5
+
6
+ Enables devices communicating via MQTT to participate in DoSync intents
7
+ without HTTP, without REST, and without internet — just a local MQTT broker.
8
+ Ideal for microcontrollers (ESP32, Arduino), Tasmota devices, Zigbee2MQTT,
9
+ and any sensor network that speaks MQTT natively.
10
+
11
+ Configuration (environment variables):
12
+ DOSYNC_MQTT_BROKER localhost Broker hostname or IP
13
+ DOSYNC_MQTT_PORT 1883 Broker port (8883 for TLS)
14
+ DOSYNC_MQTT_USER Optional username
15
+ DOSYNC_MQTT_PASSWORD Optional password
16
+ DOSYNC_MQTT_PREFIX dosync Topic prefix
17
+ DOSYNC_MQTT_QOS 1 QoS level (0, 1, or 2)
18
+
19
+ Topic structure:
20
+ {prefix}/devices/{device_id}/commands Hub → device (action)
21
+ {prefix}/devices/{device_id}/events Device → hub (sensor event)
22
+ {prefix}/devices/{device_id}/status Device → hub (state, retained)
23
+ {prefix}/devices/{device_id}/register Device → hub (self-registration)
24
+ {prefix}/hub/status Hub heartbeat + LWT
25
+
26
+ Command payload (hub → device):
27
+ {"action": "turn_on", "params": {...}, "urgency": "info", "timestamp": 0.0}
28
+
29
+ Event payload (device → hub):
30
+ {"event_id": "motion_detected", "severity": "alert", "data": {...}}
31
+
32
+ Registration payload (retained, device → hub):
33
+ Full CapabilityManifest JSON — see spec/schemas/capability-manifest.schema.json
34
+
35
+ Install broker on Pi:
36
+ sudo apt-get install -y mosquitto mosquitto-clients
37
+ sudo systemctl enable mosquitto && sudo systemctl start mosquitto
38
+
39
+ Install Python library:
40
+ pip install paho-mqtt
41
+ """
42
+
43
+ import asyncio
44
+ import json
45
+ import logging
46
+ import os
47
+ import time
48
+
49
+ from . import DoSyncAdapter
50
+ from ..models import ActionResult, DeviceAction, Urgency
51
+
52
+ log = logging.getLogger("dosync.adapters.mqtt")
53
+
54
+ # ── Configuration ─────────────────────────────────────────────────────────────
55
+
56
+ BROKER = os.environ.get("DOSYNC_MQTT_BROKER", "localhost")
57
+ PORT = int(os.environ.get("DOSYNC_MQTT_PORT", "1883"))
58
+ USER = os.environ.get("DOSYNC_MQTT_USER", "")
59
+ PASSWORD = os.environ.get("DOSYNC_MQTT_PASSWORD", "")
60
+ PREFIX = os.environ.get("DOSYNC_MQTT_PREFIX", "dosync")
61
+ QOS = int(os.environ.get("DOSYNC_MQTT_QOS", "1"))
62
+ # Shared secret for device registration authorization.
63
+ # If set, devices MUST include {"dosync_secret": "<value>"} in their register payload.
64
+ # Devices that do not present the correct secret are silently rejected.
65
+ # Set this to a random string and configure all devices with the same value.
66
+ MQTT_SECRET = os.environ.get("DOSYNC_MQTT_SECRET", "")
67
+
68
+ # ── Optional import ───────────────────────────────────────────────────────────
69
+
70
+ try:
71
+ import paho.mqtt.client as _mqtt
72
+ _PAHO_AVAILABLE = True
73
+ except ImportError:
74
+ _PAHO_AVAILABLE = False
75
+ log.warning(
76
+ "paho-mqtt not installed — MQTTAdapter disabled. "
77
+ "Install with: pip install paho-mqtt"
78
+ )
79
+
80
+
81
+ # ── Adapter ───────────────────────────────────────────────────────────────────
82
+
83
+ class MQTTAdapter(DoSyncAdapter):
84
+ """
85
+ DoSync adapter for MQTT devices.
86
+
87
+ Publishes DeviceActions as MQTT command messages and subscribes to
88
+ device topics to receive events and self-registrations.
89
+
90
+ Integrates paho-mqtt (threaded) with DoSync's asyncio event loop
91
+ via asyncio.run_coroutine_threadsafe.
92
+ """
93
+
94
+ def __init__(self, hub=None):
95
+ self._hub = hub
96
+ self._client = None
97
+ self._connected = False
98
+ self._loop: asyncio.AbstractEventLoop | None = None
99
+
100
+ @property
101
+ def adapter_name(self) -> str:
102
+ return "mqtt"
103
+
104
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
105
+
106
+ async def connect(self, config: dict | None = None) -> None:
107
+ """Connect to the MQTT broker and start background loop."""
108
+ if not _PAHO_AVAILABLE:
109
+ log.warning("MQTTAdapter: paho-mqtt not installed — skipping connect")
110
+ return
111
+
112
+ self._loop = asyncio.get_running_loop()
113
+
114
+ client_id = f"dosync-hub-{int(time.time()) % 10000}"
115
+ self._client = _mqtt.Client(
116
+ _mqtt.CallbackAPIVersion.VERSION2,
117
+ client_id=client_id,
118
+ )
119
+
120
+ if USER:
121
+ self._client.username_pw_set(USER, PASSWORD)
122
+
123
+ # Last Will Testament — signals hub went offline unexpectedly
124
+ self._client.will_set(
125
+ f"{PREFIX}/hub/status",
126
+ json.dumps({"status": "offline", "timestamp": time.time()}),
127
+ qos=QOS,
128
+ retain=True,
129
+ )
130
+
131
+ # Exponential backoff on reconnect: 1s → 120s with jitter (paho built-in)
132
+ self._client.reconnect_delay_set(min_delay=1, max_delay=120)
133
+
134
+ self._client.on_connect = self._on_connect
135
+ self._client.on_message = self._on_message
136
+ self._client.on_disconnect = self._on_disconnect
137
+
138
+ try:
139
+ self._client.connect_async(BROKER, PORT, keepalive=60)
140
+ self._client.loop_start()
141
+ log.info("MQTTAdapter: connecting to %s:%d (prefix=%s)", BROKER, PORT, PREFIX)
142
+ except Exception as exc:
143
+ log.warning("MQTTAdapter: connect failed (%s) — MQTT transport disabled", exc)
144
+
145
+ async def disconnect(self) -> None:
146
+ """Disconnect from broker and stop the paho loop."""
147
+ if self._client:
148
+ if self._connected:
149
+ self._client.publish(
150
+ f"{PREFIX}/hub/status",
151
+ json.dumps({"status": "offline", "timestamp": time.time()}),
152
+ qos=QOS,
153
+ retain=True,
154
+ )
155
+ self._client.loop_stop()
156
+ self._client.disconnect()
157
+ self._connected = False
158
+ log.info("MQTTAdapter: disconnected")
159
+
160
+ # ── paho callbacks (run in paho thread) ───────────────────────────────────
161
+
162
+ def _on_connect(self, client, userdata, flags, reason_code, properties=None):
163
+ if reason_code == 0:
164
+ self._connected = True
165
+ # Subscribe to all device sub-topics
166
+ for suffix in ("events", "register", "status"):
167
+ client.subscribe(f"{PREFIX}/devices/+/{suffix}", qos=QOS)
168
+ # Announce hub online
169
+ client.publish(
170
+ f"{PREFIX}/hub/status",
171
+ json.dumps({"status": "online", "timestamp": time.time()}),
172
+ qos=QOS,
173
+ retain=True,
174
+ )
175
+ log.info(
176
+ "MQTTAdapter: connected to %s:%d — subscribed to %s/devices/+/{events,register,status}",
177
+ BROKER, PORT, PREFIX,
178
+ )
179
+ else:
180
+ log.warning("MQTTAdapter: connection refused — reason_code=%s", reason_code)
181
+
182
+ def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties=None):
183
+ self._connected = False
184
+ log.warning("MQTTAdapter: disconnected (reason_code=%s)", reason_code)
185
+
186
+ def _on_message(self, client, userdata, msg):
187
+ """Bridge paho thread → asyncio event loop."""
188
+ if self._loop is None or self._loop.is_closed():
189
+ return
190
+ asyncio.run_coroutine_threadsafe(
191
+ self._dispatch(msg.topic, msg.payload),
192
+ self._loop,
193
+ )
194
+
195
+ # ── Message dispatch (asyncio thread) ────────────────────────────────────
196
+
197
+ async def _dispatch(self, topic: str, payload: bytes) -> None:
198
+ try:
199
+ data = json.loads(payload)
200
+ except (json.JSONDecodeError, UnicodeDecodeError):
201
+ log.warning("MQTTAdapter: invalid JSON on topic %s", topic)
202
+ return
203
+
204
+ # topic = {prefix}/devices/{device_id}/{msg_type}
205
+ parts = topic.split("/")
206
+ if len(parts) < 4:
207
+ return
208
+ device_id = parts[-2]
209
+ msg_type = parts[-1]
210
+
211
+ if msg_type == "register": await self._handle_registration(device_id, data)
212
+ elif msg_type == "events": await self._handle_event(device_id, data)
213
+ elif msg_type == "status": self._handle_status(device_id, data)
214
+
215
+ async def _handle_registration(self, device_id: str, data: dict) -> None:
216
+ """Auto-register a device that published its manifest to the register topic.
217
+
218
+ Handles both payload formats:
219
+ - Flat: sensors/actuators/events as top-level lists
220
+ - Nested: capabilities: {sensors, actuators, events, context_signals}
221
+ Converts all dict items to their proper dataclass types before constructing
222
+ the CapabilityManifest.
223
+ """
224
+ # Validate shared secret before accepting any registration
225
+ if MQTT_SECRET:
226
+ presented = data.get("dosync_secret", "")
227
+ if presented != MQTT_SECRET:
228
+ log.warning(
229
+ "MQTTAdapter: rejected registration from '%s' — invalid or missing secret. "
230
+ "Set DOSYNC_MQTT_SECRET on both hub and device.", device_id
231
+ )
232
+ return
233
+
234
+ if self._hub is None:
235
+ log.warning("MQTTAdapter: received registration from %s but hub is not set", device_id)
236
+ return
237
+ try:
238
+ import dataclasses
239
+ from ..models import (
240
+ CapabilityManifest, SensorSpec, ActuatorSpec, EventSpec,
241
+ DeviceCategory, CertTier, Urgency, Severity,
242
+ )
243
+
244
+ d = dict(data)
245
+ d["adapter"] = "mqtt"
246
+ d["device_id"] = device_id
247
+ d.setdefault("dosync_version", "0.1")
248
+
249
+ # Flatten nested capabilities → top-level lists (public API format)
250
+ caps = d.pop("capabilities", {})
251
+ d.setdefault("sensors", caps.get("sensors", []))
252
+ d.setdefault("actuators", caps.get("actuators", []))
253
+ d.setdefault("events", caps.get("events", []))
254
+ d.setdefault("context_signals", caps.get("context_signals", []))
255
+
256
+ # Convert list items from dicts to typed dataclasses
257
+ d["sensors"] = [SensorSpec(**s) if isinstance(s, dict) else s for s in d["sensors"]]
258
+ d["actuators"] = [ActuatorSpec(**a) if isinstance(a, dict) else a for a in d["actuators"]]
259
+ d["events"] = [
260
+ EventSpec(
261
+ id=e["id"],
262
+ severity=Severity(e.get("severity", "info")),
263
+ description=e.get("description", ""),
264
+ ) if isinstance(e, dict) else e
265
+ for e in d["events"]
266
+ ]
267
+
268
+ # Handle enum fields
269
+ if isinstance(d.get("category"), str):
270
+ try: d["category"] = DeviceCategory(d["category"])
271
+ except ValueError: d["category"] = DeviceCategory.HYBRID
272
+ if isinstance(d.get("cert_tier"), str):
273
+ try: d["cert_tier"] = CertTier(d["cert_tier"])
274
+ except ValueError: d.pop("cert_tier", None)
275
+
276
+ # Remove any fields unknown to CapabilityManifest
277
+ valid = {f.name for f in dataclasses.fields(CapabilityManifest)}
278
+ d = {k: v for k, v in d.items() if k in valid}
279
+
280
+ manifest = CapabilityManifest(**d)
281
+ self._hub.register_device(manifest)
282
+ log.info("MQTTAdapter: auto-registered device '%s'", device_id)
283
+
284
+ # Acknowledge registration
285
+ if self._client and self._connected:
286
+ self._client.publish(
287
+ f"{PREFIX}/devices/{device_id}/ack",
288
+ json.dumps({"registered": True, "hub_timestamp": time.time()}),
289
+ qos=QOS,
290
+ )
291
+ except Exception as exc:
292
+ log.warning("MQTTAdapter: failed to register device '%s': %s", device_id, exc)
293
+
294
+ async def _handle_event(self, device_id: str, data: dict) -> None:
295
+ """Forward a device event to the hub so it reaches the audit log AND the
296
+ event stream (WebSocket broadcast) that autonomous agents subscribe to.
297
+
298
+ Previously this called a non-existent `process_event` guarded by hasattr,
299
+ so MQTT device events were silently dropped and never reached an agent."""
300
+ if self._hub is None:
301
+ return
302
+ try:
303
+ from ..models import DeviceEvent, Severity
304
+ try:
305
+ severity = Severity(data.get("severity", "info"))
306
+ except ValueError:
307
+ severity = Severity("info")
308
+ event = DeviceEvent(
309
+ device_id=device_id,
310
+ event_id=data.get("event_id", "mqtt_event"),
311
+ severity=severity,
312
+ data=data.get("data", {}),
313
+ )
314
+ await self._hub.receive_event(event)
315
+ log.debug("MQTTAdapter: event '%s' from '%s'", event.event_id, device_id)
316
+ except Exception as exc:
317
+ log.warning("MQTTAdapter: failed to process event from '%s': %s", device_id, exc)
318
+
319
+ def _handle_status(self, device_id: str, data: dict) -> None:
320
+ """Update in-memory state cache for StateAwareResolver."""
321
+ log.debug("MQTTAdapter: status update from '%s': %s", device_id, data)
322
+
323
+ # ── Execute (called by AdapterExecutor) ───────────────────────────────────
324
+
325
+ async def execute(self, action: DeviceAction, urgency: Urgency) -> ActionResult:
326
+ """Publish a command to the device's MQTT command topic."""
327
+ if not _PAHO_AVAILABLE:
328
+ return ActionResult(
329
+ device_id=action.device_id, action=action.action,
330
+ success=False, error="paho-mqtt not installed",
331
+ )
332
+ if not self._connected:
333
+ return ActionResult(
334
+ device_id=action.device_id, action=action.action,
335
+ success=False, error="MQTT broker not connected",
336
+ )
337
+
338
+ topic = f"{PREFIX}/devices/{action.device_id}/commands"
339
+ payload = json.dumps({
340
+ "action": action.action,
341
+ "params": action.params,
342
+ "urgency": urgency.value,
343
+ "timestamp": time.time(),
344
+ })
345
+
346
+ try:
347
+ info = self._client.publish(topic, payload, qos=QOS)
348
+ # Non-blocking for QoS 0; brief wait for QoS 1/2
349
+ if QOS > 0:
350
+ info.wait_for_publish(timeout=2.0)
351
+ log.debug("MQTTAdapter: published '%s' → %s", action.action, topic)
352
+ return ActionResult(
353
+ device_id=action.device_id,
354
+ action=action.action,
355
+ success=True,
356
+ response={"topic": topic, "qos": QOS, "mid": info.mid},
357
+ )
358
+ except Exception as exc:
359
+ log.warning("MQTTAdapter: publish failed for '%s': %s", action.device_id, exc)
360
+ return ActionResult(
361
+ device_id=action.device_id, action=action.action,
362
+ success=False, error=str(exc),
363
+ )
364
+
365
+ # ── Helpers ───────────────────────────────────────────────────────────────
366
+
367
+ @property
368
+ def is_connected(self) -> bool:
369
+ return self._connected
370
+
371
+ @property
372
+ def broker(self) -> str:
373
+ return f"{BROKER}:{PORT}"
374
+
375
+ def publish_raw(self, topic: str, payload: dict, retain: bool = False) -> None:
376
+ """Publish an arbitrary message — for hub-initiated announcements."""
377
+ if self._client and self._connected:
378
+ self._client.publish(topic, json.dumps(payload), qos=QOS, retain=retain)
379
+
380
+ def clear_device_registration(self, device_id: str) -> None:
381
+ """Clear the retained MQTT registration message for a device.
382
+
383
+ Publishes an empty payload (byte string b"") to the device's register
384
+ topic with retain=True. This is the correct MQTT mechanism for deleting
385
+ a retained message — the broker removes it and future subscribers will
386
+ not receive a stale registration.
387
+
388
+ Called automatically when DELETE /v1/devices/{device_id} is invoked.
389
+ """
390
+ if not (self._client and self._connected):
391
+ log.debug("MQTTAdapter: cannot clear registration for '%s' — not connected", device_id)
392
+ return
393
+ topic = f"{PREFIX}/devices/{device_id}/register"
394
+ self._client.publish(topic, payload=b"", qos=QOS, retain=True)
395
+ log.info("MQTTAdapter: cleared retained registration for device '%s'", device_id)
396
+
397
+ def purge_stale_device_topics(self, active_device_ids: list[str]) -> None:
398
+ """Clear retained registrations for devices no longer in the hub registry.
399
+ Call on hub startup after registry is restored from DB.
400
+ Not called automatically — invoke explicitly if needed.
401
+ """
402
+ # Cannot enumerate retained messages from paho — no-op without external tooling
403
+ # This is a known limitation of MQTT: retained message cleanup requires
404
+ # knowing which topics exist. Subscribe and filter is the only approach.
405
+ log.debug(
406
+ "MQTTAdapter: purge_stale_device_topics called (%d active devices) "
407
+ "— manual cleanup only; use 'mosquitto_sub -t dosync/# -v' to audit.",
408
+ len(active_device_ids),
409
+ )
@@ -0,0 +1,153 @@
1
+ """
2
+ DoSync — Notification Adapter (SMS via Twilio)
3
+ ===============================================
4
+ Envía SMS cuando se disparan intents de emergencia o alertas.
5
+
6
+ Uso:
7
+ # Agregar al servidor como middleware de notificaciones
8
+ from dosync.adapters.notifications import NotificationAdapter
9
+ notifier = NotificationAdapter()
10
+ await notifier.notify_emergency(intent, context)
11
+
12
+ Variables de entorno (.env):
13
+ TWILIO_ACCOUNT_SID — Account SID de Twilio
14
+ TWILIO_AUTH_TOKEN — Auth Token de Twilio
15
+ TWILIO_FROM — Número Twilio (+1XXXXXXXXXX)
16
+ DOSYNC_EMERGENCY_CONTACT — Número destino (+54XXXXXXXXXX)
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import logging
21
+ import os
22
+
23
+ log = logging.getLogger("dosync.notifications")
24
+
25
+ # Cargar .env si existe
26
+ try:
27
+ from pathlib import Path
28
+ env_file = Path(__file__).parent.parent.parent / ".env"
29
+ if env_file.exists():
30
+ for line in env_file.read_text().splitlines():
31
+ if "=" in line and not line.startswith("#"):
32
+ key, val = line.split("=", 1)
33
+ os.environ.setdefault(key.strip(), val.strip())
34
+ except Exception:
35
+ pass
36
+
37
+ TWILIO_SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
38
+ TWILIO_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
39
+ TWILIO_FROM = os.environ.get("TWILIO_FROM", "")
40
+ EMERGENCY_TO = os.environ.get("DOSYNC_EMERGENCY_CONTACT", "")
41
+
42
+ # Intents que disparan notificaciones
43
+ EMERGENCY_INTENTS = {"ensure_safety", "alert_anomaly", "notify"}
44
+ WARNING_INTENTS = {"report_status", "remind_chore"}
45
+
46
+
47
+ class NotificationAdapter:
48
+ adapter_name = "notifications"
49
+ """Sends SMS via Twilio for critical DoSync intents."""
50
+
51
+ def __init__(self):
52
+ self._available = bool(TWILIO_SID and TWILIO_TOKEN and TWILIO_FROM)
53
+ if not self._available:
54
+ log.warning("Twilio not configured — SMS notifications disabled")
55
+ else:
56
+ log.info("NotificationAdapter ready — SMS to %s", EMERGENCY_TO or "?")
57
+
58
+ def _get_client(self):
59
+ try:
60
+ from twilio.rest import Client
61
+ return Client(TWILIO_SID, TWILIO_TOKEN)
62
+ except ImportError:
63
+ log.error("twilio not installed — run: pip install twilio")
64
+ return None
65
+
66
+ def _build_message(self, intent: str, urgency: str, context: dict) -> str:
67
+ """Build the SMS message body for the given intent."""
68
+ location = context.get("location", "")
69
+ trigger = context.get("trigger", "")
70
+ temp = context.get("temperature")
71
+
72
+ if intent == "ensure_safety":
73
+ loc = f" en {location}" if location else ""
74
+ member = context.get("member", "")
75
+ if member:
76
+ return (
77
+ f"DoSync — {member} llegaron a casa.\n"
78
+ f"El sensor de movimiento los detecto y el hogar respondio automaticamente."
79
+ )
80
+ return (
81
+ f"DOSYNC EMERGENCIA{loc}\n"
82
+ f"Se detecto una situacion de emergencia en el hogar.\n"
83
+ f"El sistema activo el protocolo de seguridad.\n"
84
+ f"Verificar inmediatamente. Llamar al 107 (SAME) si es necesario."
85
+ )
86
+ elif intent == "alert_anomaly" and temp:
87
+ return (
88
+ f"DOSYNC ALERTA\n"
89
+ f"Temperatura anormal: {temp}C\n"
90
+ f"Verificar el hogar."
91
+ )
92
+ elif intent == "report_status" and trigger == "motion_detected":
93
+ loc = f" en {location}" if location else ""
94
+ return f"DOSYNC INFO\nMovimiento detectado{loc}."
95
+ elif intent == "notify":
96
+ msg = context.get("message", "Notification from DoSync")
97
+ return f"DOSYNC\n{msg}"
98
+ else:
99
+ return f"DOSYNC {urgency.upper()}\nIntent: {intent}"
100
+
101
+ async def execute(self, action, urgency):
102
+ from ..models import ActionResult
103
+ params = action.params or {}
104
+ message = params.get('message', 'Notificación DoSync')
105
+ try:
106
+ await self.notify(
107
+ intent=action.action,
108
+ urgency=urgency.value if hasattr(urgency, 'value') else str(urgency),
109
+ context={'message': message},
110
+ to=None
111
+ )
112
+ return ActionResult(device_id=action.device_id, action=action.action, success=True, response={'status': 'sent'})
113
+ except Exception as e:
114
+ return ActionResult(device_id=action.device_id, action=action.action, success=False, error=str(e))
115
+
116
+ async def notify(self, intent: str, urgency: str, context: dict,
117
+ to: str = None) -> bool:
118
+ """Send an SMS. Returns True on success."""
119
+ if not self._available:
120
+ log.warning("SMS not sent — Twilio not configured")
121
+ return False
122
+
123
+ destination = to or EMERGENCY_TO
124
+ if not destination:
125
+ log.warning("SMS not sent — no destination number configured")
126
+ return False
127
+
128
+ # Solo notificar para intents relevantes
129
+ if intent not in EMERGENCY_INTENTS and urgency not in ("emergency", "alert"):
130
+ return False
131
+
132
+ client = self._get_client()
133
+ if not client:
134
+ return False
135
+
136
+ message = self._build_message(intent, urgency, context)
137
+
138
+ try:
139
+ msg = client.messages.create(
140
+ body=message,
141
+ from_=TWILIO_FROM,
142
+ to=destination,
143
+ )
144
+ log.info("SMS sent to %s — SID: %s", destination, msg.sid)
145
+ return True
146
+ except Exception as e:
147
+ log.error("SMS failed: %s", e)
148
+ return False
149
+
150
+ async def notify_emergency(self, intent: str, context: dict,
151
+ to: str = None) -> bool:
152
+ """Shortcut para notificaciones de emergencia."""
153
+ return await self.notify(intent, "emergency", context, to)