Skip to content

Commit 12c5d19

Browse files
author
Maksym Shtembuliak
committed
feat: reuse owned HTTPS sessions in ordinary shadow learning
1 parent c3550fe commit 12c5d19

12 files changed

Lines changed: 676 additions & 42 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ the GitHub release body should be rendered from the matching version section her
99

1010
### Added
1111

12+
- SmartESS and DESSMonitor active Shadow Learning reuse one verified HTTPS
13+
connection per control-discovery run. Unknown-delivery failures stop the run
14+
without retrying commands; cancellation drains the outstanding request before
15+
cleanup. Safe phase diagnostics omit signed URLs and credentials. Read-only
16+
preparation, ValueCloud and ordinary polling are unchanged.
17+
1218
- Active control learning shares numeric command evidence across SmartESS,
1319
DESSMonitor and generic ValueCloud controls. Missing cloud ranges no longer
1420
exclude numeric discovery; two intercepted values must agree on the supported
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Operation-owned HTTPS reuse; never retry a semantic cloud write.
2+
3+
No credentials, redirects, global pool or provider fallback. The owner drains
4+
its blocking worker before closing this resource. Construction performs no IO.
5+
"""
6+
from __future__ import annotations
7+
8+
from http.client import HTTPSConnection, IncompleteRead
9+
from io import BytesIO
10+
import logging
11+
from math import isfinite
12+
from socket import gaierror
13+
from ssl import SSLError
14+
from threading import Lock
15+
from time import monotonic
16+
from urllib.error import HTTPError, URLError
17+
from urllib.parse import urlsplit
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
22+
def _failure_reason(exc: BaseException) -> str:
23+
"""Closed diagnostics only: never stringify provider/socket exceptions."""
24+
for kind, reason in ((TimeoutError, "timeout"), (gaierror, "dns"),
25+
(SSLError, "tls"), (HTTPError, "http_status"),
26+
(IncompleteRead, "incomplete_response"), (OSError, "network"),
27+
(ValueError, "invalid_response")):
28+
if isinstance(exc, kind):
29+
return reason
30+
return "unexpected"
31+
32+
33+
class CloudHttpSession:
34+
"""A single origin/path, serialized requests, terminal failure/close."""
35+
36+
def __init__(self, base_url: str):
37+
parsed = self._parse(base_url)
38+
if parsed.query:
39+
raise ValueError("cloud_http_origin_invalid")
40+
self._origin = (parsed.hostname, parsed.port or 443)
41+
self._path = parsed.path or "/"
42+
self._connection = None
43+
self._closed = False
44+
self._lock = Lock()
45+
46+
@staticmethod
47+
def _parse(url):
48+
if type(url) is not str or any(ord(c) <= 32 or ord(c) >= 127 for c in url):
49+
raise ValueError("cloud_http_url_invalid")
50+
parsed = urlsplit(url)
51+
if (parsed.scheme != "https" or not parsed.hostname or parsed.username is not None
52+
or parsed.password is not None or parsed.fragment):
53+
raise ValueError("cloud_http_origin_invalid")
54+
return parsed
55+
56+
def get(self, url: str, *, headers: dict[str, str], timeout: float,
57+
max_bytes: int = 2 * 1024 * 1024) -> bytes:
58+
if not self._lock.acquire(blocking=False):
59+
raise RuntimeError("cloud_http_busy")
60+
phase, reused, started = "validation", False, monotonic()
61+
try:
62+
if self._closed:
63+
raise ValueError("cloud_http_closed")
64+
parsed = self._parse(url)
65+
if ((parsed.hostname, parsed.port or 443) != self._origin
66+
or (parsed.path or "/") != self._path):
67+
raise ValueError("cloud_http_origin_mismatch")
68+
if (type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
69+
or type(max_bytes) is not int or not 0 < max_bytes <= 4 * 1024 * 1024):
70+
raise ValueError("cloud_http_limits_invalid")
71+
if self._connection is None:
72+
# Default context validates certificates and hostname. This
73+
# constructor and all TLS/HTTP IO execute in the owned worker.
74+
self._connection = HTTPSConnection(*self._origin, timeout=timeout)
75+
connection = self._connection
76+
connection.timeout = timeout
77+
reused = connection.sock is not None
78+
if not reused:
79+
# DNS/TCP/TLS finish before http.client can send the signed
80+
# request. A connect timeout alone cannot identify which of
81+
# those three sub-stages failed. Never infer queue settlement.
82+
phase = "connect"
83+
connection.connect()
84+
if connection.sock is not None:
85+
connection.sock.settimeout(timeout)
86+
target = self._path + ("?" + parsed.query if parsed.query else "")
87+
phase = "request"
88+
connection.request("GET", target, headers=headers)
89+
phase = "response_headers"
90+
response = connection.getresponse()
91+
with response:
92+
# Do not follow redirects or retain a signed URL/error body.
93+
if not 200 <= response.status < 300:
94+
raise HTTPError("", response.status, "cloud_http_status", None, BytesIO())
95+
phase = "response_body"
96+
payload = response.read(max_bytes + 1)
97+
if len(payload) > max_bytes:
98+
raise ValueError("cloud_http_response_too_large")
99+
if response.length not in (None, 0):
100+
raise IncompleteRead(b"", response.length)
101+
if response.will_close:
102+
connection.close()
103+
self._connection = None
104+
return payload
105+
except BaseException as exc:
106+
if phase != "validation":
107+
_LOGGER.warning(
108+
"Cloud HTTPS request failed phase=%s reason=%s reused=%s elapsed_ms=%s; no automatic retry",
109+
phase, _failure_reason(exc), reused, round((monotonic() - started) * 1000),
110+
)
111+
self._close_locked()
112+
if isinstance(exc, OSError) and not isinstance(exc, URLError):
113+
raise URLError(exc) from exc
114+
raise
115+
finally:
116+
self._lock.release()
117+
118+
def _close_locked(self):
119+
self._closed = True
120+
connection, self._connection = self._connection, None
121+
if connection is not None:
122+
connection.close()
123+
124+
def close(self) -> None:
125+
"""Owner-only cleanup AFTER draining its worker; never race request IO."""
126+
if not self._lock.acquire(blocking=False):
127+
raise RuntimeError("cloud_http_busy")
128+
try:
129+
self._close_locked()
130+
finally:
131+
self._lock.release()

‎custom_components/eybond_local/dessmonitor_cloud.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -399,18 +399,22 @@ def _decode_response_body(body: bytes) -> str:
399399
return text
400400

401401

402-
def _http_get_json(url: str, *, timeout: float) -> DessMonitorApiEnvelope:
402+
def _http_get_json(url: str, *, timeout: float, http_session=None) -> DessMonitorApiEnvelope:
403403
request = Request(
404404
url,
405405
headers={"User-Agent": "DessMonitor/3.6.2.1", "Accept": "application/json"},
406406
method="GET",
407407
)
408408
try:
409-
with urlopen(request, timeout=_strict_timeout(timeout)) as response:
410-
body = response.read(DEFAULT_MAX_RESPONSE_BYTES + 1)
411-
if len(body) > DEFAULT_MAX_RESPONSE_BYTES:
412-
raise DessMonitorCloudError("response_too_large")
413-
payload = _decode_response_body(body)
409+
if http_session is None:
410+
with urlopen(request, timeout=_strict_timeout(timeout)) as response:
411+
body = response.read(DEFAULT_MAX_RESPONSE_BYTES + 1)
412+
if len(body) > DEFAULT_MAX_RESPONSE_BYTES:
413+
raise DessMonitorCloudError("response_too_large")
414+
else:
415+
body = http_session.get(url, headers=dict(request.header_items()), timeout=_strict_timeout(timeout),
416+
max_bytes=DEFAULT_MAX_RESPONSE_BYTES)
417+
payload = _decode_response_body(body)
414418
except HTTPError as exc:
415419
raise DessMonitorCloudError(f"http_error:{exc.code}") from exc
416420
except URLError as exc:
@@ -549,6 +553,7 @@ def fetch_signed_action(
549553
base_url: str = DEFAULT_BASE_URL,
550554
language: str = DEFAULT_LANGUAGE,
551555
timeout: float = DEFAULT_TIMEOUT,
556+
http_session=None,
552557
) -> DessMonitorApiEnvelope:
553558
stage = action.split("&", 2)[1].removeprefix("action=")
554559
try:
@@ -560,6 +565,7 @@ def fetch_signed_action(
560565
language=language,
561566
),
562567
timeout=timeout,
568+
**({"http_session": http_session} if http_session is not None else {}),
563569
)
564570
except DessMonitorCloudError as exc:
565571
if exc.stage:
@@ -620,6 +626,7 @@ def send_device_control(
620626
base_url: str = DEFAULT_BASE_URL,
621627
language: str = DEFAULT_LANGUAGE,
622628
timeout: float = DEFAULT_TIMEOUT,
629+
http_session=None,
623630
) -> DessMonitorApiEnvelope:
624631
"""Send one explicit DESSMonitor control action through a typed session."""
625632

@@ -635,6 +642,7 @@ def send_device_control(
635642
base_url=base_url,
636643
language=language,
637644
timeout=timeout,
645+
**({"http_session": http_session} if http_session is not None else {}),
638646
)
639647

640648

‎custom_components/eybond_local/smartess_cloud.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def _decode_response_body(body: bytes) -> str:
213213
return text
214214

215215

216-
def _http_get_json(url: str, *, timeout: float = DEFAULT_TIMEOUT) -> ApiEnvelope:
216+
def _http_get_json(url: str, *, timeout: float = DEFAULT_TIMEOUT, http_session=None) -> ApiEnvelope:
217217
request = Request(
218218
url,
219219
headers={
@@ -223,8 +223,11 @@ def _http_get_json(url: str, *, timeout: float = DEFAULT_TIMEOUT) -> ApiEnvelope
223223
method="GET",
224224
)
225225
try:
226-
with urlopen(request, timeout=timeout) as response:
227-
payload = _decode_response_body(response.read())
226+
if http_session is None:
227+
with urlopen(request, timeout=timeout) as response:
228+
payload = _decode_response_body(response.read())
229+
else:
230+
payload = _decode_response_body(http_session.get(url, headers=dict(request.header_items()), timeout=timeout))
228231
except HTTPError as exc:
229232
detail = exc.read().decode("utf-8", errors="replace").strip()
230233
raise SmartEssCloudError(f"http_error:{exc.code}:{detail}") from exc
@@ -380,6 +383,7 @@ def fetch_signed_action(
380383
app_id: str = DEFAULT_APP_ID,
381384
app_version: str = DEFAULT_APP_VERSION,
382385
timeout: float = DEFAULT_TIMEOUT,
386+
http_session=None,
383387
) -> ApiEnvelope:
384388
envelope = _http_get_json(
385389
build_signed_action_url(
@@ -391,6 +395,7 @@ def fetch_signed_action(
391395
app_version=app_version,
392396
),
393397
timeout=timeout,
398+
**({"http_session": http_session} if http_session is not None else {}),
394399
)
395400
if envelope.err != 0:
396401
raise SmartEssActionRejectedError(err=envelope.err, desc=envelope.desc)

‎custom_components/eybond_local/support/cloud_control_discovery.py‎

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313

1414
from __future__ import annotations
1515

16+
from contextlib import closing
1617
import logging
1718
from typing import Any
1819

1920
from .. import valuecloud_cloud as valuecloud_cloud_module
21+
from ..cloud_http_session import CloudHttpSession
2022
from ..smartess_cloud import (
23+
DEFAULT_BASE_URL,
2124
build_device_detail_action,
2225
build_device_settings_action,
2326
fetch_control_discovery_bundle_for_collector,
@@ -149,25 +152,29 @@ async def async_correlate(
149152

150153
on_learning()
151154
progress(0.30, "testing")
152-
result = await async_orchestrate_shadow_learning_settings(
153-
settings_dat=settings_dat,
154-
session=control_session,
155-
pn=identity["pn"],
156-
sn=identity["sn"],
157-
devcode=identity["devcode"],
158-
devaddr=identity["devaddr"],
159-
dry_run=False,
160-
confirm_cloud_write=True,
161-
shadow_session_state="ready",
162-
field_ids=[],
163-
include_numeric=True,
164-
all_choice_values=True,
165-
max_fields=max_fields,
166-
continue_on_error=True,
167-
delay_seconds=0.0,
168-
timeout=self.timeout_policy.action_request,
169-
**dict(orchestrator_callbacks),
170-
)
155+
# The awaited dispatcher drains even repeatedly cancelled HTTP workers.
156+
# Close only once that owner has returned; never race route recovery.
157+
with closing(CloudHttpSession(DEFAULT_BASE_URL)) as http_session:
158+
result = await async_orchestrate_shadow_learning_settings(
159+
settings_dat=settings_dat,
160+
session=control_session,
161+
pn=identity["pn"],
162+
sn=identity["sn"],
163+
devcode=identity["devcode"],
164+
devaddr=identity["devaddr"],
165+
dry_run=False,
166+
confirm_cloud_write=True,
167+
shadow_session_state="ready",
168+
field_ids=[],
169+
include_numeric=True,
170+
all_choice_values=True,
171+
max_fields=max_fields,
172+
continue_on_error=True,
173+
delay_seconds=0.0,
174+
timeout=self.timeout_policy.action_request,
175+
http_session=http_session,
176+
**dict(orchestrator_callbacks),
177+
)
171178
result = dict(result)
172179
result["source"] = self.source_id
173180
result["metadata_only"] = False

‎custom_components/eybond_local/support/dessmonitor_active.py‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
from __future__ import annotations
44

5+
from contextlib import closing
6+
7+
from ..cloud_http_session import CloudHttpSession
58
from ..collector_identity import pn_is_same_identity
69
from ..dessmonitor_cloud import (
10+
DEFAULT_BASE_URL,
711
DessMonitorEvidenceBundle,
812
fetch_read_only_evidence_for_session,
913
login_with_password,
@@ -85,20 +89,24 @@ async def async_correlate(
8589
await start_shadow_route()
8690
on_learning()
8791
progress(0.30, "testing")
88-
result = await async_orchestrate_dessmonitor_shadow_learning(
89-
control_fields=bundle.control_fields,
90-
session=cloud_session,
91-
identity=bundle.identity,
92-
confirm_cloud_write=True,
93-
shadow_session_state="learning",
94-
field_ids=(),
95-
all_choice_values=True,
96-
max_fields=max_fields,
97-
continue_on_error=True,
98-
delay_seconds=0.0,
99-
timeout=self.timeout_policy.action_request,
100-
**dict(orchestrator_callbacks),
101-
)
92+
# The awaited dispatcher drains even repeatedly cancelled HTTP workers.
93+
# Close only once that owner has returned; never race route recovery.
94+
with closing(CloudHttpSession(DEFAULT_BASE_URL)) as http_session:
95+
result = await async_orchestrate_dessmonitor_shadow_learning(
96+
control_fields=bundle.control_fields,
97+
session=cloud_session,
98+
identity=bundle.identity,
99+
confirm_cloud_write=True,
100+
shadow_session_state="learning",
101+
field_ids=(),
102+
all_choice_values=True,
103+
max_fields=max_fields,
104+
continue_on_error=True,
105+
delay_seconds=0.0,
106+
timeout=self.timeout_policy.action_request,
107+
http_session=http_session,
108+
**dict(orchestrator_callbacks),
109+
)
102110
result = dict(result)
103111
result["source"] = self.source_id
104112
result["metadata_only"] = False

‎custom_components/eybond_local/support/shadow_learning/dessmonitor_orchestrator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ async def async_orchestrate_dessmonitor_shadow_learning(
293293
timeout: float = DEFAULT_TIMEOUT,
294294
correlation_timeout_seconds: float = 2.0,
295295
action: Callable[..., DessMonitorApiEnvelope] = send_device_control,
296+
http_session=None,
296297
on_progress: Callable[[int, int], None] | None = None,
297298
) -> dict[str, Any]:
298299
"""Correlate explicit DESSMonitor actions with writes on the exact route."""
@@ -401,6 +402,7 @@ async def async_orchestrate_dessmonitor_shadow_learning(
401402
base_url=base_url,
402403
language=language,
403404
timeout=request_timeout,
405+
**({"http_session": http_session} if http_session is not None else {}),
404406
)
405407
if type(envelope) is not DessMonitorApiEnvelope:
406408
raise TypeError("dessmonitor_learning_response_invalid")

‎custom_components/eybond_local/support/shadow_learning/orchestrator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ async def async_orchestrate_shadow_learning_settings(
228228
timeout: float = DEFAULT_TIMEOUT,
229229
correlation_timeout_seconds: float = 2.0,
230230
fetch_action: Callable[..., Any] = fetch_signed_action,
231+
http_session=None,
231232
on_progress: Callable[[int, int], None] | None = None,
232233
) -> dict[str, Any]:
233234
"""Plan and execute one SmartESS learn-settings run with async per-attempt observation correlation.
@@ -315,6 +316,7 @@ async def async_orchestrate_shadow_learning_settings(
315316
app_id=app_id,
316317
app_version=app_version,
317318
timeout=timeout,
319+
**({"http_session": http_session} if http_session is not None else {}),
318320
)
319321
attempt["status"] = _CONTROL_STATUS_SENT
320322
attempt["delivery_outcome"] = "response"

0 commit comments

Comments
 (0)