Стабилизация подключения K1 после сна и ожидания Bluetooth
This commit is contained in:
@@ -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)
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user