dosync 0.6.0__tar.gz → 0.6.2__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.2}/PKG-INFO +1 -1
  2. {dosync-0.6.0 → dosync-0.6.2}/dosync/__init__.py +1 -1
  3. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/__init__.py +19 -0
  4. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/homeassistant.py +2 -1
  5. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/matter.py +2 -1
  6. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/mqtt.py +1 -1
  7. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/notifications.py +1 -1
  8. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/shelly.py +2 -1
  9. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/wiz.py +12 -2
  10. {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers_ssdp.py +23 -16
  11. {dosync-0.6.0 → dosync-0.6.2}/dosync/server.py +27 -4
  12. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/PKG-INFO +1 -1
  13. {dosync-0.6.0 → dosync-0.6.2}/tests/test_adapters.py +63 -0
  14. {dosync-0.6.0 → dosync-0.6.2}/tests/test_server.py +18 -0
  15. {dosync-0.6.0 → dosync-0.6.2}/tests/test_transport_discoverers.py +54 -0
  16. {dosync-0.6.0 → dosync-0.6.2}/LICENSE +0 -0
  17. {dosync-0.6.0 → dosync-0.6.2}/README.md +0 -0
  18. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapter_drafting.py +0 -0
  19. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/ble.py +0 -0
  20. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/declarative.py +0 -0
  21. {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/mavlink.py +0 -0
  22. {dosync-0.6.0 → dosync-0.6.2}/dosync/audit_backup.py +0 -0
  23. {dosync-0.6.0 → dosync-0.6.2}/dosync/auth.py +0 -0
  24. {dosync-0.6.0 → dosync-0.6.2}/dosync/auth_fastapi.py +0 -0
  25. {dosync-0.6.0 → dosync-0.6.2}/dosync/cert_signing.py +0 -0
  26. {dosync-0.6.0 → dosync-0.6.2}/dosync/certify.py +0 -0
  27. {dosync-0.6.0 → dosync-0.6.2}/dosync/cli.py +0 -0
  28. {dosync-0.6.0 → dosync-0.6.2}/dosync/composite_operations.py +0 -0
  29. {dosync-0.6.0 → dosync-0.6.2}/dosync/config_reference.py +0 -0
  30. {dosync-0.6.0 → dosync-0.6.2}/dosync/dashboard.html +0 -0
  31. {dosync-0.6.0 → dosync-0.6.2}/dosync/db.py +0 -0
  32. {dosync-0.6.0 → dosync-0.6.2}/dosync/declarative.py +0 -0
  33. {dosync-0.6.0 → dosync-0.6.2}/dosync/device_arbiter.py +0 -0
  34. {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers.py +0 -0
  35. {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers_mdns.py +0 -0
  36. {dosync-0.6.0 → dosync-0.6.2}/dosync/discovery.py +0 -0
  37. {dosync-0.6.0 → dosync-0.6.2}/dosync/ed25519_pure.py +0 -0
  38. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/__init__.py +0 -0
  39. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/3d-printer.yaml +0 -0
  40. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/air-conditioner.yaml +0 -0
  41. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/building-lighting.json +0 -0
  42. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
  43. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/light-generic.yaml +0 -0
  44. {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/television.yaml +0 -0
  45. {dosync-0.6.0 → dosync-0.6.2}/dosync/executor.py +0 -0
  46. {dosync-0.6.0 → dosync-0.6.2}/dosync/geo.py +0 -0
  47. {dosync-0.6.0 → dosync-0.6.2}/dosync/hub.py +0 -0
  48. {dosync-0.6.0 → dosync-0.6.2}/dosync/hub_monitor.py +0 -0
  49. {dosync-0.6.0 → dosync-0.6.2}/dosync/lightweight.py +0 -0
  50. {dosync-0.6.0 → dosync-0.6.2}/dosync/manage.py +0 -0
  51. {dosync-0.6.0 → dosync-0.6.2}/dosync/mcp_server.py +0 -0
  52. {dosync-0.6.0 → dosync-0.6.2}/dosync/metrics.py +0 -0
  53. {dosync-0.6.0 → dosync-0.6.2}/dosync/models.py +0 -0
  54. {dosync-0.6.0 → dosync-0.6.2}/dosync/operation_guards.py +0 -0
  55. {dosync-0.6.0 → dosync-0.6.2}/dosync/operation_supervisor.py +0 -0
  56. {dosync-0.6.0 → dosync-0.6.2}/dosync/operations.py +0 -0
  57. {dosync-0.6.0 → dosync-0.6.2}/dosync/paths.py +0 -0
  58. {dosync-0.6.0 → dosync-0.6.2}/dosync/plugins.py +0 -0
  59. {dosync-0.6.0 → dosync-0.6.2}/dosync/policies.py +0 -0
  60. {dosync-0.6.0 → dosync-0.6.2}/dosync/policy_config.py +0 -0
  61. {dosync-0.6.0 → dosync-0.6.2}/dosync/py.typed +0 -0
  62. {dosync-0.6.0 → dosync-0.6.2}/dosync/reconciler.py +0 -0
  63. {dosync-0.6.0 → dosync-0.6.2}/dosync/route_composer.py +0 -0
  64. {dosync-0.6.0 → dosync-0.6.2}/dosync/security.py +0 -0
  65. {dosync-0.6.0 → dosync-0.6.2}/dosync/spec_coverage.py +0 -0
  66. {dosync-0.6.0 → dosync-0.6.2}/dosync/templates/adapter-draft-prompt.md +0 -0
  67. {dosync-0.6.0 → dosync-0.6.2}/dosync/validation.py +0 -0
  68. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/SOURCES.txt +0 -0
  69. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/dependency_links.txt +0 -0
  70. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/entry_points.txt +0 -0
  71. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/requires.txt +0 -0
  72. {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/top_level.txt +0 -0
  73. {dosync-0.6.0 → dosync-0.6.2}/pyproject.toml +0 -0
  74. {dosync-0.6.0 → dosync-0.6.2}/setup.cfg +0 -0
  75. {dosync-0.6.0 → dosync-0.6.2}/tests/test_adapter_drafting.py +0 -0
  76. {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_archive.py +0 -0
  77. {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_backup.py +0 -0
  78. {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_chain_integrity.py +0 -0
  79. {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_provenance.py +0 -0
  80. {dosync-0.6.0 → dosync-0.6.2}/tests/test_auth.py +0 -0
  81. {dosync-0.6.0 → dosync-0.6.2}/tests/test_ble_adapter.py +0 -0
  82. {dosync-0.6.0 → dosync-0.6.2}/tests/test_certification_honesty.py +0 -0
  83. {dosync-0.6.0 → dosync-0.6.2}/tests/test_claim_state_machine.py +0 -0
  84. {dosync-0.6.0 → dosync-0.6.2}/tests/test_composite_operations.py +0 -0
  85. {dosync-0.6.0 → dosync-0.6.2}/tests/test_composite_orchestration.py +0 -0
  86. {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_kind_db.py +0 -0
  87. {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_kind_endpoint.py +0 -0
  88. {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_routing.py +0 -0
  89. {dosync-0.6.0 → dosync-0.6.2}/tests/test_dashboard_tells_the_truth.py +0 -0
  90. {dosync-0.6.0 → dosync-0.6.2}/tests/test_db.py +0 -0
  91. {dosync-0.6.0 → dosync-0.6.2}/tests/test_declarative_adapters.py +0 -0
  92. {dosync-0.6.0 → dosync-0.6.2}/tests/test_declarative_quarantine.py +0 -0
  93. {dosync-0.6.0 → dosync-0.6.2}/tests/test_deployment_env_contract.py +0 -0
  94. {dosync-0.6.0 → dosync-0.6.2}/tests/test_deployment_layout.py +0 -0
  95. {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_health.py +0 -0
  96. {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_heartbeat.py +0 -0
  97. {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_identity.py +0 -0
  98. {dosync-0.6.0 → dosync-0.6.2}/tests/test_direct_action_governance.py +0 -0
  99. {dosync-0.6.0 → dosync-0.6.2}/tests/test_discovery_adoption.py +0 -0
  100. {dosync-0.6.0 → dosync-0.6.2}/tests/test_documentation_is_navigable.py +0 -0
  101. {dosync-0.6.0 → dosync-0.6.2}/tests/test_drone_policies.py +0 -0
  102. {dosync-0.6.0 → dosync-0.6.2}/tests/test_ed25519_pure.py +0 -0
  103. {dosync-0.6.0 → dosync-0.6.2}/tests/test_emergency_preemption.py +0 -0
  104. {dosync-0.6.0 → dosync-0.6.2}/tests/test_env_loading_is_explicit.py +0 -0
  105. {dosync-0.6.0 → dosync-0.6.2}/tests/test_evaluation_metrics.py +0 -0
  106. {dosync-0.6.0 → dosync-0.6.2}/tests/test_event_loop_migration.py +0 -0
  107. {dosync-0.6.0 → dosync-0.6.2}/tests/test_explain_consistency.py +0 -0
  108. {dosync-0.6.0 → dosync-0.6.2}/tests/test_explain_resolve_parity.py +0 -0
  109. {dosync-0.6.0 → dosync-0.6.2}/tests/test_geo.py +0 -0
  110. {dosync-0.6.0 → dosync-0.6.2}/tests/test_ha_bridge_hygiene.py +0 -0
  111. {dosync-0.6.0 → dosync-0.6.2}/tests/test_hub_monitor.py +0 -0
  112. {dosync-0.6.0 → dosync-0.6.2}/tests/test_idempotency.py +0 -0
  113. {dosync-0.6.0 → dosync-0.6.2}/tests/test_independent_observation.py +0 -0
  114. {dosync-0.6.0 → dosync-0.6.2}/tests/test_integration_suite.py +0 -0
  115. {dosync-0.6.0 → dosync-0.6.2}/tests/test_lightweight_heartbeat.py +0 -0
  116. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_adapter.py +0 -0
  117. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_channels.py +0 -0
  118. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_listener.py +0 -0
  119. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_return_home.py +0 -0
  120. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_single_reader.py +0 -0
  121. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_telemetry_closure.py +0 -0
  122. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mcp_dynamic_intents.py +0 -0
  123. {dosync-0.6.0 → dosync-0.6.2}/tests/test_mcp_partial_progress.py +0 -0
  124. {dosync-0.6.0 → dosync-0.6.2}/tests/test_metrics.py +0 -0
  125. {dosync-0.6.0 → dosync-0.6.2}/tests/test_models.py +0 -0
  126. {dosync-0.6.0 → dosync-0.6.2}/tests/test_multihub_endpoints.py +0 -0
  127. {dosync-0.6.0 → dosync-0.6.2}/tests/test_no_operator_data.py +0 -0
  128. {dosync-0.6.0 → dosync-0.6.2}/tests/test_notification_templates.py +0 -0
  129. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operation_guards.py +0 -0
  130. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operation_supervisor.py +0 -0
  131. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations.py +0 -0
  132. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_endpoints.py +0 -0
  133. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_persistence.py +0 -0
  134. {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_wiring.py +0 -0
  135. {dosync-0.6.0 → dosync-0.6.2}/tests/test_panel_polish_2026_07_21.py +0 -0
  136. {dosync-0.6.0 → dosync-0.6.2}/tests/test_policies.py +0 -0
  137. {dosync-0.6.0 → dosync-0.6.2}/tests/test_policy_config.py +0 -0
  138. {dosync-0.6.0 → dosync-0.6.2}/tests/test_public_claims.py +0 -0
  139. {dosync-0.6.0 → dosync-0.6.2}/tests/test_pushed_verification.py +0 -0
  140. {dosync-0.6.0 → dosync-0.6.2}/tests/test_quickstart_is_runnable.py +0 -0
  141. {dosync-0.6.0 → dosync-0.6.2}/tests/test_reachability_cause.py +0 -0
  142. {dosync-0.6.0 → dosync-0.6.2}/tests/test_recall_benchmark_postpolicy.py +0 -0
  143. {dosync-0.6.0 → dosync-0.6.2}/tests/test_reconciler.py +0 -0
  144. {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolution_wiring.py +0 -0
  145. {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolver_scoring.py +0 -0
  146. {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolver_semantics.py +0 -0
  147. {dosync-0.6.0 → dosync-0.6.2}/tests/test_route_composer.py +0 -0
  148. {dosync-0.6.0 → dosync-0.6.2}/tests/test_sensor_kind.py +0 -0
  149. {dosync-0.6.0 → dosync-0.6.2}/tests/test_simulation_is_declared.py +0 -0
  150. {dosync-0.6.0 → dosync-0.6.2}/tests/test_telemetry_bridge.py +0 -0
  151. {dosync-0.6.0 → dosync-0.6.2}/tests/test_third_party_adapters.py +0 -0
  152. {dosync-0.6.0 → dosync-0.6.2}/tests/test_universal_intent_contract.py +0 -0
  153. {dosync-0.6.0 → dosync-0.6.2}/tests/test_validation.py +0 -0
  154. {dosync-0.6.0 → dosync-0.6.2}/tests/test_validation_integration.py +0 -0
  155. {dosync-0.6.0 → dosync-0.6.2}/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.2
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.2"
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()
@@ -12,7 +12,8 @@ import json
12
12
  import os
13
13
  import re
14
14
  from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
15
- from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
15
+ from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
16
+ PlainTextResponse)
16
17
  from fastapi.staticfiles import StaticFiles
17
18
  from pydantic import BaseModel, field_validator
18
19
 
@@ -933,7 +934,26 @@ async def lifespan(app: FastAPI):
933
934
  log.warning("Could not flush the audit head on shutdown: %s", _fh_e)
934
935
  log.info("DoSync Hub shutting down")
935
936
 
937
+ class _UTF8JSONResponse(JSONResponse):
938
+ """`application/json; charset=utf-8`, said out loud rather than assumed.
939
+
940
+ JSON is UTF-8 by definition and FastAPI therefore omits the charset — which
941
+ is correct and, for one widely used client, useless. Windows PowerShell 5.1
942
+ falls back to Latin-1 when a response does not declare one, so an operator
943
+ following this project's own Windows instructions saw
944
+ `read-only status query â device has no sensors` where an em dash belonged,
945
+ and would see the same for every accented character a deployment uses in a
946
+ device name.
947
+
948
+ Being right about the specification while a reader gets mojibake is the
949
+ kind of correctness this project has decided is not worth much.
950
+ """
951
+
952
+ media_type = "application/json; charset=utf-8"
953
+
954
+
936
955
  app = FastAPI(
956
+ default_response_class=_UTF8JSONResponse,
937
957
  title="DoSync Hub",
938
958
  description=(
939
959
  "DoSync Protocol — REST API\n\n"
@@ -2480,7 +2500,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2480
2500
  found.extend(await discover_wiz(timeout=5.0))
2481
2501
  searched.append("wiz (udp broadcast)")
2482
2502
  except Exception as e:
2483
- log.info("WiZ scan did not run: %s", e)
2503
+ logging.getLogger("dosync.server").info(
2504
+ "WiZ scan did not run: %s", e)
2484
2505
  skipped.append("wiz (udp broadcast)")
2485
2506
  else:
2486
2507
  skipped.append("wiz (udp broadcast — pywizlight not installed)")
@@ -2497,7 +2518,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2497
2518
  found.extend(await adapter.discover(timeout=5.0))
2498
2519
  searched.append(name)
2499
2520
  except Exception as e:
2500
- 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",
2501
2523
  name, type(e).__name__, e)
2502
2524
  skipped.append(f"{name} — failed: {type(e).__name__}")
2503
2525
 
@@ -2516,7 +2538,8 @@ async def scan_devices(auth: str = Depends(require_auth)):
2516
2538
  # transport absent from BOTH, so a reader could not tell it had
2517
2539
  # been attempted at all — and the adapter loop above still does
2518
2540
  # exactly that, which is the same defect waiting to be noticed.
2519
- log.warning("Discovery via %s did not run: %s: %s",
2541
+ logging.getLogger("dosync.server").warning(
2542
+ "Discovery via %s did not run: %s: %s",
2520
2543
  discoverer.name, type(e).__name__, e)
2521
2544
  skipped.append(f"{label} — failed: {type(e).__name__}")
2522
2545
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dosync
3
- Version: 0.6.0
3
+ Version: 0.6.2
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().")
@@ -199,3 +199,21 @@ def test_the_shipped_gpio_adapter_uses_the_current_endpoint():
199
199
  src = (REPO / "gpio_adapter.py").read_text()
200
200
  assert 'hub_post("/v1/intent/async"' in src
201
201
  assert 'hub_post("/v1/intent"' not in src
202
+
203
+
204
+ def test_json_responses_declare_utf8():
205
+ """`read-only status query â device has no sensors to read`.
206
+
207
+ That is what a Windows operator saw following this project's own
208
+ instructions. JSON is UTF-8 by definition, so FastAPI omits the charset —
209
+ correct, and useless for Windows PowerShell 5.1, which falls back to
210
+ Latin-1 when a response does not declare one. Every em dash and every
211
+ accented character in a device name arrives mangled.
212
+
213
+ The README documents `Invoke-RestMethod` as the way to call this API from
214
+ Windows, so the tool it recommends was the one showing broken text.
215
+ """
216
+ response = client.get("/v1/status")
217
+ assert "charset=utf-8" in response.headers.get("content-type", "").lower(), (
218
+ "JSON responses do not declare their charset, so clients that guess "
219
+ "wrongly — PowerShell 5.1 among them — mangle every non-ASCII byte")
@@ -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