dosync 0.6.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (155) hide show
  1. {dosync-0.6.0 → dosync-0.6.1}/PKG-INFO +1 -1
  2. {dosync-0.6.0 → dosync-0.6.1}/dosync/__init__.py +1 -1
  3. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/__init__.py +19 -0
  4. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/homeassistant.py +2 -1
  5. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/matter.py +2 -1
  6. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/mqtt.py +1 -1
  7. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/notifications.py +1 -1
  8. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/shelly.py +2 -1
  9. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/wiz.py +12 -2
  10. {dosync-0.6.0 → dosync-0.6.1}/dosync/discoverers_ssdp.py +23 -16
  11. {dosync-0.6.0 → dosync-0.6.1}/dosync/server.py +6 -3
  12. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/PKG-INFO +1 -1
  13. {dosync-0.6.0 → dosync-0.6.1}/tests/test_adapters.py +63 -0
  14. {dosync-0.6.0 → dosync-0.6.1}/tests/test_transport_discoverers.py +54 -0
  15. {dosync-0.6.0 → dosync-0.6.1}/LICENSE +0 -0
  16. {dosync-0.6.0 → dosync-0.6.1}/README.md +0 -0
  17. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapter_drafting.py +0 -0
  18. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/ble.py +0 -0
  19. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/declarative.py +0 -0
  20. {dosync-0.6.0 → dosync-0.6.1}/dosync/adapters/mavlink.py +0 -0
  21. {dosync-0.6.0 → dosync-0.6.1}/dosync/audit_backup.py +0 -0
  22. {dosync-0.6.0 → dosync-0.6.1}/dosync/auth.py +0 -0
  23. {dosync-0.6.0 → dosync-0.6.1}/dosync/auth_fastapi.py +0 -0
  24. {dosync-0.6.0 → dosync-0.6.1}/dosync/cert_signing.py +0 -0
  25. {dosync-0.6.0 → dosync-0.6.1}/dosync/certify.py +0 -0
  26. {dosync-0.6.0 → dosync-0.6.1}/dosync/cli.py +0 -0
  27. {dosync-0.6.0 → dosync-0.6.1}/dosync/composite_operations.py +0 -0
  28. {dosync-0.6.0 → dosync-0.6.1}/dosync/config_reference.py +0 -0
  29. {dosync-0.6.0 → dosync-0.6.1}/dosync/dashboard.html +0 -0
  30. {dosync-0.6.0 → dosync-0.6.1}/dosync/db.py +0 -0
  31. {dosync-0.6.0 → dosync-0.6.1}/dosync/declarative.py +0 -0
  32. {dosync-0.6.0 → dosync-0.6.1}/dosync/device_arbiter.py +0 -0
  33. {dosync-0.6.0 → dosync-0.6.1}/dosync/discoverers.py +0 -0
  34. {dosync-0.6.0 → dosync-0.6.1}/dosync/discoverers_mdns.py +0 -0
  35. {dosync-0.6.0 → dosync-0.6.1}/dosync/discovery.py +0 -0
  36. {dosync-0.6.0 → dosync-0.6.1}/dosync/ed25519_pure.py +0 -0
  37. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/__init__.py +0 -0
  38. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/3d-printer.yaml +0 -0
  39. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/air-conditioner.yaml +0 -0
  40. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/building-lighting.json +0 -0
  41. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
  42. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/light-generic.yaml +0 -0
  43. {dosync-0.6.0 → dosync-0.6.1}/dosync/examples/declarative/television.yaml +0 -0
  44. {dosync-0.6.0 → dosync-0.6.1}/dosync/executor.py +0 -0
  45. {dosync-0.6.0 → dosync-0.6.1}/dosync/geo.py +0 -0
  46. {dosync-0.6.0 → dosync-0.6.1}/dosync/hub.py +0 -0
  47. {dosync-0.6.0 → dosync-0.6.1}/dosync/hub_monitor.py +0 -0
  48. {dosync-0.6.0 → dosync-0.6.1}/dosync/lightweight.py +0 -0
  49. {dosync-0.6.0 → dosync-0.6.1}/dosync/manage.py +0 -0
  50. {dosync-0.6.0 → dosync-0.6.1}/dosync/mcp_server.py +0 -0
  51. {dosync-0.6.0 → dosync-0.6.1}/dosync/metrics.py +0 -0
  52. {dosync-0.6.0 → dosync-0.6.1}/dosync/models.py +0 -0
  53. {dosync-0.6.0 → dosync-0.6.1}/dosync/operation_guards.py +0 -0
  54. {dosync-0.6.0 → dosync-0.6.1}/dosync/operation_supervisor.py +0 -0
  55. {dosync-0.6.0 → dosync-0.6.1}/dosync/operations.py +0 -0
  56. {dosync-0.6.0 → dosync-0.6.1}/dosync/paths.py +0 -0
  57. {dosync-0.6.0 → dosync-0.6.1}/dosync/plugins.py +0 -0
  58. {dosync-0.6.0 → dosync-0.6.1}/dosync/policies.py +0 -0
  59. {dosync-0.6.0 → dosync-0.6.1}/dosync/policy_config.py +0 -0
  60. {dosync-0.6.0 → dosync-0.6.1}/dosync/py.typed +0 -0
  61. {dosync-0.6.0 → dosync-0.6.1}/dosync/reconciler.py +0 -0
  62. {dosync-0.6.0 → dosync-0.6.1}/dosync/route_composer.py +0 -0
  63. {dosync-0.6.0 → dosync-0.6.1}/dosync/security.py +0 -0
  64. {dosync-0.6.0 → dosync-0.6.1}/dosync/spec_coverage.py +0 -0
  65. {dosync-0.6.0 → dosync-0.6.1}/dosync/templates/adapter-draft-prompt.md +0 -0
  66. {dosync-0.6.0 → dosync-0.6.1}/dosync/validation.py +0 -0
  67. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/SOURCES.txt +0 -0
  68. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/dependency_links.txt +0 -0
  69. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/entry_points.txt +0 -0
  70. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/requires.txt +0 -0
  71. {dosync-0.6.0 → dosync-0.6.1}/dosync.egg-info/top_level.txt +0 -0
  72. {dosync-0.6.0 → dosync-0.6.1}/pyproject.toml +0 -0
  73. {dosync-0.6.0 → dosync-0.6.1}/setup.cfg +0 -0
  74. {dosync-0.6.0 → dosync-0.6.1}/tests/test_adapter_drafting.py +0 -0
  75. {dosync-0.6.0 → dosync-0.6.1}/tests/test_audit_archive.py +0 -0
  76. {dosync-0.6.0 → dosync-0.6.1}/tests/test_audit_backup.py +0 -0
  77. {dosync-0.6.0 → dosync-0.6.1}/tests/test_audit_chain_integrity.py +0 -0
  78. {dosync-0.6.0 → dosync-0.6.1}/tests/test_audit_provenance.py +0 -0
  79. {dosync-0.6.0 → dosync-0.6.1}/tests/test_auth.py +0 -0
  80. {dosync-0.6.0 → dosync-0.6.1}/tests/test_ble_adapter.py +0 -0
  81. {dosync-0.6.0 → dosync-0.6.1}/tests/test_certification_honesty.py +0 -0
  82. {dosync-0.6.0 → dosync-0.6.1}/tests/test_claim_state_machine.py +0 -0
  83. {dosync-0.6.0 → dosync-0.6.1}/tests/test_composite_operations.py +0 -0
  84. {dosync-0.6.0 → dosync-0.6.1}/tests/test_composite_orchestration.py +0 -0
  85. {dosync-0.6.0 → dosync-0.6.1}/tests/test_composition_kind_db.py +0 -0
  86. {dosync-0.6.0 → dosync-0.6.1}/tests/test_composition_kind_endpoint.py +0 -0
  87. {dosync-0.6.0 → dosync-0.6.1}/tests/test_composition_routing.py +0 -0
  88. {dosync-0.6.0 → dosync-0.6.1}/tests/test_dashboard_tells_the_truth.py +0 -0
  89. {dosync-0.6.0 → dosync-0.6.1}/tests/test_db.py +0 -0
  90. {dosync-0.6.0 → dosync-0.6.1}/tests/test_declarative_adapters.py +0 -0
  91. {dosync-0.6.0 → dosync-0.6.1}/tests/test_declarative_quarantine.py +0 -0
  92. {dosync-0.6.0 → dosync-0.6.1}/tests/test_deployment_env_contract.py +0 -0
  93. {dosync-0.6.0 → dosync-0.6.1}/tests/test_deployment_layout.py +0 -0
  94. {dosync-0.6.0 → dosync-0.6.1}/tests/test_device_health.py +0 -0
  95. {dosync-0.6.0 → dosync-0.6.1}/tests/test_device_heartbeat.py +0 -0
  96. {dosync-0.6.0 → dosync-0.6.1}/tests/test_device_identity.py +0 -0
  97. {dosync-0.6.0 → dosync-0.6.1}/tests/test_direct_action_governance.py +0 -0
  98. {dosync-0.6.0 → dosync-0.6.1}/tests/test_discovery_adoption.py +0 -0
  99. {dosync-0.6.0 → dosync-0.6.1}/tests/test_documentation_is_navigable.py +0 -0
  100. {dosync-0.6.0 → dosync-0.6.1}/tests/test_drone_policies.py +0 -0
  101. {dosync-0.6.0 → dosync-0.6.1}/tests/test_ed25519_pure.py +0 -0
  102. {dosync-0.6.0 → dosync-0.6.1}/tests/test_emergency_preemption.py +0 -0
  103. {dosync-0.6.0 → dosync-0.6.1}/tests/test_env_loading_is_explicit.py +0 -0
  104. {dosync-0.6.0 → dosync-0.6.1}/tests/test_evaluation_metrics.py +0 -0
  105. {dosync-0.6.0 → dosync-0.6.1}/tests/test_event_loop_migration.py +0 -0
  106. {dosync-0.6.0 → dosync-0.6.1}/tests/test_explain_consistency.py +0 -0
  107. {dosync-0.6.0 → dosync-0.6.1}/tests/test_explain_resolve_parity.py +0 -0
  108. {dosync-0.6.0 → dosync-0.6.1}/tests/test_geo.py +0 -0
  109. {dosync-0.6.0 → dosync-0.6.1}/tests/test_ha_bridge_hygiene.py +0 -0
  110. {dosync-0.6.0 → dosync-0.6.1}/tests/test_hub_monitor.py +0 -0
  111. {dosync-0.6.0 → dosync-0.6.1}/tests/test_idempotency.py +0 -0
  112. {dosync-0.6.0 → dosync-0.6.1}/tests/test_independent_observation.py +0 -0
  113. {dosync-0.6.0 → dosync-0.6.1}/tests/test_integration_suite.py +0 -0
  114. {dosync-0.6.0 → dosync-0.6.1}/tests/test_lightweight_heartbeat.py +0 -0
  115. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_adapter.py +0 -0
  116. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_channels.py +0 -0
  117. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_listener.py +0 -0
  118. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_return_home.py +0 -0
  119. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_single_reader.py +0 -0
  120. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mavlink_telemetry_closure.py +0 -0
  121. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mcp_dynamic_intents.py +0 -0
  122. {dosync-0.6.0 → dosync-0.6.1}/tests/test_mcp_partial_progress.py +0 -0
  123. {dosync-0.6.0 → dosync-0.6.1}/tests/test_metrics.py +0 -0
  124. {dosync-0.6.0 → dosync-0.6.1}/tests/test_models.py +0 -0
  125. {dosync-0.6.0 → dosync-0.6.1}/tests/test_multihub_endpoints.py +0 -0
  126. {dosync-0.6.0 → dosync-0.6.1}/tests/test_no_operator_data.py +0 -0
  127. {dosync-0.6.0 → dosync-0.6.1}/tests/test_notification_templates.py +0 -0
  128. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operation_guards.py +0 -0
  129. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operation_supervisor.py +0 -0
  130. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operations.py +0 -0
  131. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operations_endpoints.py +0 -0
  132. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operations_persistence.py +0 -0
  133. {dosync-0.6.0 → dosync-0.6.1}/tests/test_operations_wiring.py +0 -0
  134. {dosync-0.6.0 → dosync-0.6.1}/tests/test_panel_polish_2026_07_21.py +0 -0
  135. {dosync-0.6.0 → dosync-0.6.1}/tests/test_policies.py +0 -0
  136. {dosync-0.6.0 → dosync-0.6.1}/tests/test_policy_config.py +0 -0
  137. {dosync-0.6.0 → dosync-0.6.1}/tests/test_public_claims.py +0 -0
  138. {dosync-0.6.0 → dosync-0.6.1}/tests/test_pushed_verification.py +0 -0
  139. {dosync-0.6.0 → dosync-0.6.1}/tests/test_quickstart_is_runnable.py +0 -0
  140. {dosync-0.6.0 → dosync-0.6.1}/tests/test_reachability_cause.py +0 -0
  141. {dosync-0.6.0 → dosync-0.6.1}/tests/test_recall_benchmark_postpolicy.py +0 -0
  142. {dosync-0.6.0 → dosync-0.6.1}/tests/test_reconciler.py +0 -0
  143. {dosync-0.6.0 → dosync-0.6.1}/tests/test_resolution_wiring.py +0 -0
  144. {dosync-0.6.0 → dosync-0.6.1}/tests/test_resolver_scoring.py +0 -0
  145. {dosync-0.6.0 → dosync-0.6.1}/tests/test_resolver_semantics.py +0 -0
  146. {dosync-0.6.0 → dosync-0.6.1}/tests/test_route_composer.py +0 -0
  147. {dosync-0.6.0 → dosync-0.6.1}/tests/test_sensor_kind.py +0 -0
  148. {dosync-0.6.0 → dosync-0.6.1}/tests/test_server.py +0 -0
  149. {dosync-0.6.0 → dosync-0.6.1}/tests/test_simulation_is_declared.py +0 -0
  150. {dosync-0.6.0 → dosync-0.6.1}/tests/test_telemetry_bridge.py +0 -0
  151. {dosync-0.6.0 → dosync-0.6.1}/tests/test_third_party_adapters.py +0 -0
  152. {dosync-0.6.0 → dosync-0.6.1}/tests/test_universal_intent_contract.py +0 -0
  153. {dosync-0.6.0 → dosync-0.6.1}/tests/test_validation.py +0 -0
  154. {dosync-0.6.0 → dosync-0.6.1}/tests/test_validation_integration.py +0 -0
  155. {dosync-0.6.0 → dosync-0.6.1}/tests/test_wiring_audit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: The semantic layer between AI agents and physical devices
5
5
  Author-email: Rodrigo Giuliani <rgiuliani@dosync.dev>
6
6
  License-Expression: Apache-2.0
@@ -13,5 +13,5 @@ The two numbers move independently on purpose:
13
13
  __version__ this implementation of the hub (semver)
14
14
  __protocol_version__ the wire contract other implementations must match
15
15
  """
16
- __version__ = "0.6.0"
16
+ __version__ = "0.6.1"
17
17
  __protocol_version__ = "0.4"
@@ -29,6 +29,25 @@ log = logging.getLogger("dosync.adapters")
29
29
 
30
30
  # ── Base interface every adapter must implement ───────────────────────────────
31
31
 
32
+ def failure_reason(exc: BaseException) -> str:
33
+ """A reason an operator can act on, even when the exception has no text.
34
+
35
+ `error=str(e)` was the pattern in six adapters, and it produced
36
+ `"error": ""` on real hardware: a WiZ bulb powered off at the wall raised an
37
+ exception whose `str()` is empty, so the hub reported that the action failed
38
+ and said nothing else. The log line ended at the colon.
39
+
40
+ An exception type is not a diagnosis, but it is not nothing either — and a
41
+ blank field is strictly worse than "TimeoutError with no message", because
42
+ the second at least tells the reader where to look.
43
+ """
44
+ text = str(exc).strip()
45
+ if text:
46
+ return text
47
+ return (f"{type(exc).__name__} with no message — the device did not "
48
+ "answer, or answered in a way the adapter could not read.")
49
+
50
+
32
51
  class DoSyncAdapter(ABC):
33
52
  """
34
53
  Base interface for physical device adapters.
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
41
41
  from ..hub import DoSyncHub
42
42
 
43
43
  from ..adapters import DoSyncAdapter
44
+ from . import failure_reason
44
45
  from ..models import (
45
46
  ActionResult, ActuatorSpec, CapabilityManifest,
46
47
  CertTier, DeviceAction, DeviceCategory, EventSpec,
@@ -610,7 +611,7 @@ class HABridge(DoSyncAdapter):
610
611
  device_id=action.device_id,
611
612
  action=action.action,
612
613
  success=False,
613
- error=str(e),
614
+ error=failure_reason(e),
614
615
  )
615
616
 
616
617
  async def get_state(self, device_id: str) -> dict | None:
@@ -39,6 +39,7 @@ from typing import Optional
39
39
 
40
40
  from ..adapters import DoSyncAdapter
41
41
  from ..models import ActionResult, DeviceAction, Urgency
42
+ from . import failure_reason
42
43
 
43
44
  log = logging.getLogger("dosync.adapters.matter")
44
45
 
@@ -257,7 +258,7 @@ class MatterAdapter(DoSyncAdapter):
257
258
  action=action.action,
258
259
  success=False,
259
260
  response=None,
260
- error=str(e),
261
+ error=failure_reason(e),
261
262
  )
262
263
 
263
264
  def _call_ha(self, device_type: str,
@@ -359,7 +359,7 @@ class MQTTAdapter(DoSyncAdapter):
359
359
  log.warning("MQTTAdapter: publish failed for '%s': %s", action.device_id, exc)
360
360
  return ActionResult(
361
361
  device_id=action.device_id, action=action.action,
362
- success=False, error=str(exc),
362
+ success=False, error=failure_reason(exc),
363
363
  )
364
364
 
365
365
  # ── Helpers ───────────────────────────────────────────────────────────────
@@ -199,7 +199,7 @@ class NotificationAdapter(DoSyncAdapter):
199
199
  )
200
200
  return ActionResult(device_id=action.device_id, action=action.action, success=True, response={'status': 'sent'})
201
201
  except Exception as e:
202
- return ActionResult(device_id=action.device_id, action=action.action, success=False, error=str(e))
202
+ return ActionResult(device_id=action.device_id, action=action.action, success=False, error=failure_reason(e))
203
203
 
204
204
  async def notify(self, intent: str, urgency: str, context: dict,
205
205
  to: str = None) -> bool:
@@ -34,6 +34,7 @@ from typing import Optional
34
34
 
35
35
  from ..adapters import DoSyncAdapter
36
36
  from ..models import ActionResult, DeviceAction, Urgency
37
+ from . import failure_reason
37
38
 
38
39
  log = logging.getLogger("dosync.adapters.shelly")
39
40
 
@@ -305,7 +306,7 @@ class ShellyAdapter(DoSyncAdapter):
305
306
  action=action.action,
306
307
  success=False,
307
308
  response=None,
308
- error=str(e),
309
+ error=failure_reason(e),
309
310
  )
310
311
 
311
312
  async def _execute_http(self, ip: str, gen: int,
@@ -248,12 +248,22 @@ class WiZAdapter(DoSyncAdapter):
248
248
  success=False,
249
249
  error="WiZ timeout — device unreachable",
250
250
  )
251
- log.error("WiZ error %s @ %s: %s", action.device_id, ip, e)
251
+ # `str(e)` alone is not a reason. pywizlight raises exceptions whose
252
+ # text is empty — a bulb that was powered off at the wall returned
253
+ # `success: false, error: ""`, and the log line ended at the colon.
254
+ # The operator was told the action failed and nothing else, which is
255
+ # the same failure this project fixed in the verification panel: an
256
+ # exception carrying no message must not become a blank field.
257
+ reason = str(e).strip() or (
258
+ f"{type(e).__name__} with no message — the bulb did not answer. "
259
+ "It is usually powered off at the wall, on another network, or "
260
+ "busy with another controller.")
261
+ log.error("WiZ error %s @ %s: %s", action.device_id, ip, reason)
252
262
  return ActionResult(
253
263
  device_id=action.device_id,
254
264
  action=action.action,
255
265
  success=False,
256
- error=str(e),
266
+ error=reason,
257
267
  )
258
268
 
259
269
  async def get_state(self, device_id: str) -> dict | None:
@@ -254,15 +254,25 @@ class SSDPDiscoverer:
254
254
  # loops, so discovery behaves the same wherever the hub runs.
255
255
  queue: asyncio.Queue = asyncio.Queue()
256
256
 
257
- def _readable() -> None:
258
- try:
259
- queue.put_nowait(sock.recvfrom(4096))
260
- except (BlockingIOError, InterruptedError):
261
- pass
262
- except OSError as exc:
263
- log.debug("SSDP read on port %s failed: %s", port, exc)
264
-
265
- loop.add_reader(sock.fileno(), _readable)
257
+ class _Collector(asyncio.DatagramProtocol):
258
+ """Hands each datagram to the queue the reader below drains."""
259
+
260
+ def datagram_received(self, data: bytes, addr) -> None:
261
+ queue.put_nowait((data, addr))
262
+
263
+ def error_received(self, exc: Exception) -> None:
264
+ log.debug("SSDP socket error on port %s: %s", port, exc)
265
+
266
+ # `create_datagram_endpoint` is the one UDP mechanism all three event
267
+ # loops this hub runs on actually implement. The two before it each
268
+ # worked on the loop they were written against and raised
269
+ # NotImplementedError on another: `sock_recvfrom` on uvloop, which
270
+ # uvicorn[standard] installs on Linux, and then `add_reader` on
271
+ # Windows, whose ProactorEventLoop has no reader registration for
272
+ # sockets at all. Both were found in production rather than by the
273
+ # suite, because the suite runs on one loop and there are three.
274
+ transport, _ = await loop.create_datagram_endpoint(
275
+ _Collector, sock=sock)
266
276
  deadline = loop.time() + timeout
267
277
  try:
268
278
  while loop.time() < deadline:
@@ -324,10 +334,7 @@ class SSDPDiscoverer:
324
334
  or headers.get("st") or ""),
325
335
  )
326
336
  finally:
327
- # Both, in this order: a reader left registered on a closed fd
328
- # makes the loop spin on a descriptor nobody owns any more.
329
- try:
330
- loop.remove_reader(sock.fileno())
331
- except (OSError, ValueError):
332
- pass
333
- sock.close()
337
+ # The transport owns the socket once the endpoint is created —
338
+ # closing it here closes both, and closing the socket separately
339
+ # would pull it out from under the loop.
340
+ transport.close()
@@ -2480,7 +2480,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2480
2480
  found.extend(await discover_wiz(timeout=5.0))
2481
2481
  searched.append("wiz (udp broadcast)")
2482
2482
  except Exception as e:
2483
- log.info("WiZ scan did not run: %s", e)
2483
+ logging.getLogger("dosync.server").info(
2484
+ "WiZ scan did not run: %s", e)
2484
2485
  skipped.append("wiz (udp broadcast)")
2485
2486
  else:
2486
2487
  skipped.append("wiz (udp broadcast — pywizlight not installed)")
@@ -2497,7 +2498,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2497
2498
  found.extend(await adapter.discover(timeout=5.0))
2498
2499
  searched.append(name)
2499
2500
  except Exception as e:
2500
- log.warning("Discovery via %s did not run: %s: %s",
2501
+ logging.getLogger("dosync.server").warning(
2502
+ "Discovery via %s did not run: %s: %s",
2501
2503
  name, type(e).__name__, e)
2502
2504
  skipped.append(f"{name} — failed: {type(e).__name__}")
2503
2505
 
@@ -2516,7 +2518,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2516
2518
  # transport absent from BOTH, so a reader could not tell it had
2517
2519
  # been attempted at all — and the adapter loop above still does
2518
2520
  # exactly that, which is the same defect waiting to be noticed.
2519
- log.warning("Discovery via %s did not run: %s: %s",
2521
+ logging.getLogger("dosync.server").warning(
2522
+ "Discovery via %s did not run: %s: %s",
2520
2523
  discoverer.name, type(e).__name__, e)
2521
2524
  skipped.append(f"{label} — failed: {type(e).__name__}")
2522
2525
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: The semantic layer between AI agents and physical devices
5
5
  Author-email: Rodrigo Giuliani <rgiuliani@dosync.dev>
6
6
  License-Expression: Apache-2.0
@@ -166,3 +166,66 @@ def test_ha_bridge_skips_all_wiz_entities():
166
166
  m = bridge._state_to_manifest(
167
167
  {"entity_id": "light.ambient_strip", "attributes": {}, "state": "on"})
168
168
  assert m is not None and m.device_id == "ha-light-ambient_strip"
169
+
170
+
171
+ # ── An action that failed must say why (2026-08-29) ────────────────────────
172
+
173
+ def test_a_failure_with_no_message_still_reports_a_reason():
174
+ """`"error": ""` came back from real hardware.
175
+
176
+ A WiZ bulb powered off at the wall raised an exception whose `str()` is
177
+ empty. Six adapters did `error=str(e)`, so the hub reported
178
+ `success: false, error: ""` and the log line ended at the colon — the
179
+ operator was told the action failed and nothing else.
180
+
181
+ An exception type is not a diagnosis, but a blank field is strictly worse:
182
+ the type at least says where to look.
183
+ """
184
+ from dosync.adapters import failure_reason
185
+
186
+ class Silent(Exception):
187
+ def __str__(self) -> str:
188
+ return ""
189
+
190
+ reason = failure_reason(Silent())
191
+ assert reason, "a failure with no message still yields nothing"
192
+ assert "Silent" in reason, "the exception type is not named"
193
+ assert "did not answer" in reason
194
+
195
+ # A real message is passed through untouched.
196
+ assert failure_reason(ValueError("connection refused")) == "connection refused"
197
+
198
+
199
+ def test_no_adapter_puts_a_bare_exception_into_the_error_field():
200
+ """The pattern was in six adapters at once, so a shared helper replaced it
201
+ rather than six separate patches — and this keeps the seventh from
202
+ reintroducing it."""
203
+ from pathlib import Path
204
+
205
+ repo = Path(__file__).resolve().parent.parent
206
+ import ast
207
+
208
+ offenders = []
209
+ for path in (repo / "dosync" / "adapters").glob("*.py"):
210
+ source = path.read_text(encoding="utf-8")
211
+ # Parsed, not grepped. Scanning the text tripped on the helper's own
212
+ # docstring, which quotes the pattern to explain what it replaces —
213
+ # the fourth time today a check has failed on the prose justifying it.
214
+ tree = ast.parse(source)
215
+ docstrings = set()
216
+ for node in ast.walk(tree):
217
+ if isinstance(node, (ast.Module, ast.ClassDef,
218
+ ast.FunctionDef, ast.AsyncFunctionDef)):
219
+ doc = ast.get_docstring(node, clean=False)
220
+ if doc:
221
+ docstrings.update(doc.splitlines())
222
+ for i, line in enumerate(source.splitlines(), 1):
223
+ stripped = line.strip()
224
+ if stripped.startswith("#") or line in docstrings or stripped in {
225
+ d.strip() for d in docstrings}:
226
+ continue
227
+ if "error=str(e)" in stripped or "error=str(exc)" in stripped:
228
+ offenders.append(f"{path.name}:{i}")
229
+ assert not offenders, (
230
+ "these adapters put a raw exception into the error field, which is the "
231
+ f"empty string for some libraries: {offenders}. Use failure_reason().")
@@ -594,3 +594,57 @@ def test_a_discoverer_that_fails_is_never_reported_as_searched():
594
594
  # a reader unable to tell it had been attempted.
595
595
  assert scan.count("failed: {type(e).__name__}") >= 2, \
596
596
  "a failing discoverer or adapter still vanishes from both lists"
597
+
598
+
599
+ def test_ssdp_uses_the_one_udp_mechanism_every_loop_implements():
600
+ """Two fixes in a row each worked on the loop they were written against.
601
+
602
+ `sock_recvfrom` worked on stock asyncio and raised NotImplementedError on
603
+ uvloop, which `uvicorn[standard]` installs — SSDP was dead in production for
604
+ days while every test passed. The replacement, `add_reader`, worked on both
605
+ of those and raised NotImplementedError on Windows, whose ProactorEventLoop
606
+ has no reader registration for sockets: a clean install could not scan at
607
+ all.
608
+
609
+ `create_datagram_endpoint` is defined on `BaseEventLoop`, which every loop
610
+ here inherits, rather than on the abstract base where the other two are
611
+ stubs that raise. The suite runs on one loop and there are three, so the
612
+ mechanism has to be one that cannot vary.
613
+ """
614
+ source = (REPO / "dosync" / "discoverers_ssdp.py").read_text(encoding="utf-8")
615
+ assert "create_datagram_endpoint" in source, \
616
+ "SSDP does not use the UDP mechanism all three event loops implement"
617
+ # Executable lines only. The comments explain WHY the other two mechanisms
618
+ # are unusable and name them to do it — scanning the whole file made this
619
+ # trip on the very text that documents the fix, which is the third time
620
+ # today a check has failed on the prose that justifies it.
621
+ code = "\n".join(l for l in source.splitlines()
622
+ if l.strip() and not l.lstrip().startswith("#"))
623
+ for loop_specific in ("add_reader", "sock_recvfrom"):
624
+ assert f"loop.{loop_specific}(" not in code, \
625
+ f"SSDP still calls loop.{loop_specific}, which raises " \
626
+ "NotImplementedError on at least one loop this hub runs on"
627
+
628
+
629
+ def test_the_scan_error_handler_does_not_itself_raise():
630
+ """The handler written to record a failed discoverer was broken.
631
+
632
+ `log.warning(...)` in `server.py`, where no name `log` exists — every other
633
+ line there calls `logging.getLogger("dosync.server")`. So a discoverer
634
+ raising turned into `NameError` inside the `except` block, and a failure the
635
+ code was written to absorb became a 500 that took the whole scan down.
636
+
637
+ It had been latent since before this handler existed: the previous version
638
+ called `log.info` on the same lines and was never reached, because until
639
+ Windows no discoverer had failed in production.
640
+ """
641
+ source = (REPO / "dosync" / "server.py").read_text(encoding="utf-8")
642
+ scan = source[source.index('@app.get("/v1/discovery/scan"'):]
643
+ scan = scan[:scan.index("\n@app.")]
644
+ for line in scan.splitlines():
645
+ stripped = line.strip()
646
+ if stripped.startswith("#"):
647
+ continue
648
+ assert not stripped.startswith("log."), (
649
+ f"the scan calls a bare `log` that server.py does not define — "
650
+ f"this raises NameError from inside an except block: {stripped[:60]}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes