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.
- {dosync-0.6.0 → dosync-0.6.2}/PKG-INFO +1 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/__init__.py +1 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/__init__.py +19 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/homeassistant.py +2 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/matter.py +2 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/mqtt.py +1 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/notifications.py +1 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/shelly.py +2 -1
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/wiz.py +12 -2
- {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers_ssdp.py +23 -16
- {dosync-0.6.0 → dosync-0.6.2}/dosync/server.py +27 -4
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/PKG-INFO +1 -1
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_adapters.py +63 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_server.py +18 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_transport_discoverers.py +54 -0
- {dosync-0.6.0 → dosync-0.6.2}/LICENSE +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/README.md +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapter_drafting.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/ble.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/declarative.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/adapters/mavlink.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/audit_backup.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/auth.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/auth_fastapi.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/cert_signing.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/certify.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/cli.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/composite_operations.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/config_reference.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/dashboard.html +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/db.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/declarative.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/device_arbiter.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/discoverers_mdns.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/discovery.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/ed25519_pure.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/__init__.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/3d-printer.yaml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/air-conditioner.yaml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/building-lighting.json +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/industrial-conveyor.yaml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/light-generic.yaml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/examples/declarative/television.yaml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/executor.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/geo.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/hub.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/hub_monitor.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/lightweight.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/manage.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/mcp_server.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/metrics.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/models.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/operation_guards.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/operation_supervisor.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/operations.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/paths.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/plugins.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/policies.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/policy_config.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/py.typed +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/reconciler.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/route_composer.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/security.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/spec_coverage.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/templates/adapter-draft-prompt.md +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync/validation.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/SOURCES.txt +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/dependency_links.txt +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/entry_points.txt +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/requires.txt +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/dosync.egg-info/top_level.txt +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/pyproject.toml +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/setup.cfg +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_adapter_drafting.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_archive.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_backup.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_chain_integrity.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_audit_provenance.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_auth.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_ble_adapter.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_certification_honesty.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_claim_state_machine.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_composite_operations.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_composite_orchestration.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_kind_db.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_kind_endpoint.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_composition_routing.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_dashboard_tells_the_truth.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_db.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_declarative_adapters.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_declarative_quarantine.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_deployment_env_contract.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_deployment_layout.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_health.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_heartbeat.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_device_identity.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_direct_action_governance.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_discovery_adoption.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_documentation_is_navigable.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_drone_policies.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_ed25519_pure.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_emergency_preemption.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_env_loading_is_explicit.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_evaluation_metrics.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_event_loop_migration.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_explain_consistency.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_explain_resolve_parity.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_geo.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_ha_bridge_hygiene.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_hub_monitor.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_idempotency.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_independent_observation.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_integration_suite.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_lightweight_heartbeat.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_adapter.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_channels.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_listener.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_return_home.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_single_reader.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mavlink_telemetry_closure.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mcp_dynamic_intents.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_mcp_partial_progress.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_metrics.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_models.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_multihub_endpoints.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_no_operator_data.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_notification_templates.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operation_guards.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operation_supervisor.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_endpoints.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_persistence.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_operations_wiring.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_panel_polish_2026_07_21.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_policies.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_policy_config.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_public_claims.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_pushed_verification.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_quickstart_is_runnable.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_reachability_cause.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_recall_benchmark_postpolicy.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_reconciler.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolution_wiring.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolver_scoring.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_resolver_semantics.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_route_composer.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_sensor_kind.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_simulation_is_declared.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_telemetry_bridge.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_third_party_adapters.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_universal_intent_contract.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_validation.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_validation_integration.py +0 -0
- {dosync-0.6.0 → dosync-0.6.2}/tests/test_wiring_audit.py +0 -0
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
#
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|