Стабилизация подключения K1 после сна и ожидания Bluetooth

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 16:24:05 +03:00
parent 5ebc9fc27c
commit c041a569f6
9 changed files with 393 additions and 29 deletions
@@ -120,6 +120,7 @@ async def _device_ap_activation_session_impl(
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
connect_timeout_seconds: float,
captured_device: CapturedDiscoveredDevice | None,
recovery_device_session_id: str | None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None,
@@ -137,6 +138,8 @@ async def _device_ap_activation_session_impl(
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if connect_timeout_seconds <= 0:
raise ValueError("connect_timeout_seconds must be positive")
if poll_interval_seconds <= 0:
raise ValueError("poll_interval_seconds must be positive")
if write_mode not in ("auto", "with_response", "without_response"):
@@ -229,7 +232,7 @@ async def _device_ap_activation_session_impl(
progress.operation_stage = operation_stage
try:
client = await client_stack.enter_async_context(
BleakClient(device, timeout=timeout_seconds, pair=False)
BleakClient(device, timeout=connect_timeout_seconds, pair=False)
)
except Exception as exc:
if active_captured_device is not None:
@@ -447,6 +450,7 @@ async def device_ap_activation_session(
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
connect_timeout_seconds: float | None = None,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
@@ -459,15 +463,26 @@ async def device_ap_activation_session(
)
if recovery_device_session_id == "":
raise ValueError("recovery_device_session_id must not be empty")
resolved_connect_timeout_seconds = (
timeout_seconds
if connect_timeout_seconds is None
else connect_timeout_seconds
)
if resolved_connect_timeout_seconds <= 0:
raise ValueError("connect_timeout_seconds must be positive")
progress = BleOperationProgress(operation_stage="resolution")
async with run_ble_operation_session(
"ap-enable",
hard_setup_timeout_seconds=(timeout_seconds + BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS),
hard_setup_timeout_seconds=(
resolved_connect_timeout_seconds
+ BLE_AP_ENABLE_HARD_TIMEOUT_GRACE_SECONDS
),
operation=lambda operation_progress: _device_ap_activation_session_impl(
device_macos_uuid,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
connect_timeout_seconds=resolved_connect_timeout_seconds,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
@@ -484,6 +499,7 @@ async def activate_device_ap_once(
poll_interval_seconds: float = 0.5,
write_mode: WriteMode = "auto",
*,
connect_timeout_seconds: float | None = None,
captured_device: CapturedDiscoveredDevice | None = None,
recovery_device_session_id: str | None = None,
on_write_dispatch: Callable[[WifiStatus, ResolvedWriteMode], None] | None = None,
@@ -499,6 +515,7 @@ async def activate_device_ap_once(
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
write_mode=write_mode,
connect_timeout_seconds=connect_timeout_seconds,
captured_device=captured_device,
recovery_device_session_id=recovery_device_session_id,
on_write_dispatch=on_write_dispatch,
@@ -452,6 +452,12 @@ _LocalStopRetirementDisposition = Literal[
# LixelGo-parity discovery attempt at the old forty-second boundary.
CONNECTION_VERIFY_HARD_TIMEOUT_SECONDS = 125.0
CONNECTION_VERIFY_EXACT_UUID_SCAN_TIMEOUT_SECONDS = 30.0
# The reviewed 15-second Quick Connect timeout starts only after the AP-enable
# write while Mission Core waits for byte 51. Opening the one CoreBluetooth
# session is a separate pre-write boundary. Give that single pending connect
# the same bounded K1-appearance window as exact UUID observation; never turn
# this into a second connect, scan or AP-enable write.
QUICK_CONNECT_BLE_CONNECT_TIMEOUT_SECONDS = 30.0
# A saved Quick Connect verification changes only the controller's Wi-Fi
# association. The K1 AP/profile pair was already durably established by the
# reviewed BLE activation flow, so this recovery path must neither rediscover
@@ -7326,6 +7332,11 @@ class XgridsK1CompatibilityService:
stop_operation_id=prepared_stop_operation_id,
)
self._retire_ephemeral_connection_binding_on_proven_loss(application_control_session)
# Proven-loss reduction may atomically move the existing capture
# producer from LIVE to RECONNECTING. Re-sample that local fact before
# acquisition reduction so a START-active sleep/wake recovery cannot
# be overtaken by the stale pre-wake LIVE snapshot captured above.
runtime = self.runtime.snapshot()
# Proven-loss retirement may synchronously reset a terminal control
# owner to idle. Continue the same atomic snapshot from that factual
# state instead of returning the stale pre-retirement failure row.
@@ -11414,6 +11425,9 @@ class XgridsK1CompatibilityService:
async with device_ap_activation_session(
request.device_id,
timeout_seconds=15.0,
connect_timeout_seconds=(
QUICK_CONNECT_BLE_CONNECT_TIMEOUT_SECONDS
),
# The accepted macOS / K1 FW 3.0.2 transport is one
# write-with-response frame. Do not reinterpret the live
# characteristic metadata into another write mode here.
@@ -27774,6 +27788,42 @@ class XgridsK1CompatibilityService:
provisioning remains forbidden until fresh READY evidence resolves it.
"""
# A suspend may let the data socket resume while the older control
# request loses only its trailing read-only response. Admit the same
# reviewed recovery used for a dead MQTT loop, but only after the
# executor reached exact initialized SCANNING and before any STOP was
# attempted. The recovery admission independently requires the
# resolved composite START and active durable checkpoint; otherwise it
# returns false and the existing fail-closed reduction remains intact.
post_start_failure = application_control_session.get("failure")
post_start_read_only_timeout = bool(
application_control_session.get("state") == "failed"
and isinstance(post_start_failure, Mapping)
and post_start_failure.get("reason_code") == "mqtt_response_timeout"
and post_start_failure.get("failed_phase") == "initializing"
and post_start_failure.get("dialogue_stage") == "start-active-observed"
and post_start_failure.get("modeling_command_attempted") is True
and post_start_failure.get("stop_command_attempted") is False
)
if post_start_read_only_timeout and self._request_active_stream_recovery_for_local_loss(
"mqtt_response_timeout"
):
with self._lock:
self._connection_loss_signature = None
self._connection_loss_evidence_token = None
self._connection_loss_confirmation_count = 0
logger.info(
"K1 post-START read-only timeout entered active stream recovery",
extra={
"event_code": "k1_post_start_read_only_timeout_recovery_wake",
"reason_code": "mqtt_response_timeout",
"automatic_command_retry": False,
"device_command_performed": False,
"network_mutation_performed": False,
},
)
return
# A composite-confirmed active START owns the sole automatic exception:
# preserve its exact topology while the data owner performs a bounded,
# inspection-only rebind. This path never authorizes a command retry.
@@ -277,6 +277,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._bootstrap_complete = False
self._command_complete = False
self._dialogue_stage = "new"
self._start_active_confirmed = False
self._start_complete = False
self._active_session_adopted = False
self._stop_attempted = False
@@ -493,6 +494,7 @@ class PhysicalAcceptanceDialogueExecutor:
permit: PhysicalAcceptancePermit,
checkpoint: OperatorDialogueCheckpoint,
dispatch_guard: Callable[[], None] | None = None,
start_active_observer: Callable[[], None] | None = None,
) -> ModelingResponse:
"""Execute retained operations 11-14 on one continuously serviced socket."""
@@ -564,6 +566,17 @@ class PhysicalAcceptanceDialogueExecutor:
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
)
# The correlated START response plus fresh, bound, initialized
# SCANNING is the complete physical side-effect proof. Persist that
# fact before the trailing read-only refresh so a host suspend between
# ordinals 12 and 13/14 cannot relabel an already-running K1 as an
# unknown START. The observer performs storage only; it neither
# grants STOP authority nor changes the canonical transcript.
self._start_active_confirmed = True
self._dialogue_stage = "start-active-observed"
if start_active_observer is not None:
start_active_observer()
refresh = post_start.post_initialization_refresh
required_operations = {
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
@@ -797,6 +810,7 @@ class PhysicalAcceptanceDialogueExecutor:
"bootstrap_complete": self._bootstrap_complete,
"command_complete": self._command_complete,
"start_attempted": self._command_complete,
"start_active_confirmed": self._start_active_confirmed,
"start_complete": self._start_complete,
"active_session_adopted": self._active_session_adopted,
"stop_attempted": self._stop_attempted,
@@ -1013,28 +1013,56 @@ class InteractiveApplicationControlSession:
)
self._validate_connection_binding_snapshot("start-pre-dispatch")
self._set_phase("initializing")
executor.execute_canonical_start(
start_command,
build_canonical_post_start_observation(authority, binding),
authority=authority,
binding=binding,
permit=start_permit,
checkpoint=start_checkpoint,
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
"start-dispatch"
),
)
self._validate_connection_binding_snapshot("start-post-response")
if coordinator is not None:
coordinator.resolve("start")
with self._scanning_transition_gate:
# Activate the exact durable checkpoint before publishing
# the STOP-admitting SCANNING phase. The observer performs
# storage only; it never sends a device command.
self._scanning_observer_confirmed = (
self._notify_scanning_observer()
start_active_observed = False
start_transition_gate_acquired = False
def observe_start_active() -> None:
nonlocal start_active_observed, start_transition_gate_acquired
if start_active_observed:
return
self._scanning_transition_gate.acquire()
start_transition_gate_acquired = True
try:
if coordinator is not None:
coordinator.resolve("start")
# Activate the durable recovery checkpoint at the same
# exact SCANNING proof as the physical ledger. The
# gate remains held while the read-only ordinals 13-14
# finish, so STOP cannot overtake the later public
# ``scanning`` transition.
if not self._scanning_observer_confirmed:
self._scanning_observer_confirmed = (
self._notify_scanning_observer()
)
start_active_observed = True
except BaseException:
self._scanning_transition_gate.release()
start_transition_gate_acquired = False
raise
try:
executor.execute_canonical_start(
start_command,
build_canonical_post_start_observation(authority, binding),
authority=authority,
binding=binding,
permit=start_permit,
checkpoint=start_checkpoint,
dispatch_guard=lambda: self._validate_connection_binding_snapshot(
"start-dispatch"
),
start_active_observer=observe_start_active,
)
# Compatibility executors used by lower-level integrations may
# return without invoking the new proof callback. A normal
# production executor invokes it before the read-only refresh;
# this idempotent fallback remains strictly post-success.
observe_start_active()
self._validate_connection_binding_snapshot("start-post-response")
self._set_phase("scanning")
finally:
if start_transition_gate_acquired:
self._scanning_transition_gate.release()
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
(