-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathapi.py
More file actions
More file actions
/
Copy pathapi.py
File metadata and controls
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
"""Asynchronous client and pairing flow for June ovens."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import secrets
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import urlparse
from aiohttp import (
ClientError,
ClientResponse,
ClientSession,
ClientWebSocketResponse,
WSMsgType,
WSServerHandshakeError,
)
from .protocol import (
JUNE_API_URL,
JUNE_APP_VERSION,
JUNE_CLIENT_ID,
JUNE_CLIENT_SECRET,
JUNE_MESSAGING_URL,
JUNE_PLATFORM_VERSION,
JUNE_USER_AGENT,
JUNE_WS_URL,
MC_ACK,
MC_CAMERA,
MC_CANCEL,
MC_CANCELLED,
MC_DEVICE_STATE,
MC_KEEPALIVE,
MC_PAIRING_INFO,
MC_PAIRING_INVALIDATED,
MC_PLAN,
MC_PREHEAT,
MC_SET_TIMER,
MC_TELEMETRY,
MC_TEMPERATURE,
OrderGenerator,
SrpServer,
build_shown_code,
build_signed_frame,
fahrenheit_to_millic,
find_long_base64,
millic_to_celsius,
)
_LOGGER = logging.getLogger(__name__)
REQUEST_TIMEOUT = 15
COMMAND_TIMEOUT = 6
PAIRING_TIMEOUT = 5 * 60
TRIGGER_PULSE_SECONDS = 30
TRUSTED_CAMERA_HOSTS = {"api.junelife.com", "june-api.s3.amazonaws.com"}
class JuneError(Exception):
"""Base error for the June API."""
class JuneAuthenticationError(JuneError):
"""June rejected the companion credentials."""
class JuneConnectionError(JuneError):
"""June's cloud could not be reached."""
class JuneCommandError(JuneError):
"""The oven rejected or did not acknowledge a command."""
class JunePairingNotReady(JuneError):
"""The oven has not finished pairing yet."""
@dataclass(slots=True)
class JuneIdentity:
"""Persistent per-oven companion identity."""
oven_id: str
device_id: str
device_name: str
password: str
ed25519_seed_hex: str
access_token: str
refresh_token: str
client_id: str = JUNE_CLIENT_ID
client_secret: str = JUNE_CLIENT_SECRET
@classmethod
def from_mapping(cls, data: Mapping[str, Any]) -> JuneIdentity:
"""Construct an identity from config-entry data."""
return cls(
oven_id=str(data["oven_id"]),
device_id=str(data["device_id"]),
device_name=str(data["device_name"]),
password=str(data["password"]),
ed25519_seed_hex=str(data["ed25519_seed_hex"]),
access_token=str(data.get("access_token", "")),
refresh_token=str(data.get("refresh_token", "")),
client_id=str(data.get("client_id", JUNE_CLIENT_ID)),
client_secret=str(data.get("client_secret", JUNE_CLIENT_SECRET)),
)
def as_dict(self) -> dict[str, str]:
"""Return serializable config-entry data."""
return asdict(self)
@dataclass(slots=True)
class JuneState:
"""Latest state retained in memory for Home Assistant entities."""
connection_state: str = "unknown"
active: bool = False
current_temp_c: float | None = None
target_temp_c: float | None = None
cook_mode: str = "bake"
progress_percent: float | None = None
probe_temp_c: float | None = None
probe_present: bool | None = None
ready: bool = False
done: bool = False
snapshot_url: str | None = None
last_ack_status: str | None = None
@property
def online(self) -> bool:
"""Return whether June reports the oven online."""
return self.connection_state == "online"
TokenCallback = Callable[[JuneIdentity], None]
UpdateCallback = Callable[[JuneState], None]
except asyncio.CancelledError:
raise
except JuneError as err:
self._fail(err)
except (TimeoutError, ClientError, ValueError) as err:
self._fail(JuneConnectionError(f"Pairing failed: {err}"))
async def _async_wait_for_association(self) -> None:
assert self._registration is not None
for attempt in range(20):
await asyncio.sleep(min(30, 3 * (2 ** min(attempt, 4))))
try:
async with asyncio.timeout(REQUEST_TIMEOUT):
async with self.session.get(
(
f"{JUNE_API_URL}/2/devices/"
f"{self._registration['device_id']}/associated"
),
headers={
"Authorization": (
f"Bearer {self._registration['access_token']}"
),
"User-Agent": JUNE_USER_AGENT,
},
) as response:
if response.status >= 500 or response.status == 429:
await response.read()
continue
payload = await JuneClient._checked_json(
response, "Association status"
)
except (TimeoutError, ClientError):
continue
devices = payload.get("devices")
if not isinstance(devices, list):
continue
oven = next(
(
device
for device in devices
if isinstance(device, dict)
and isinstance(device.get("oven_id"), str)
),
None,
)
if not oven:
continue
self.identity = JuneIdentity(
oven_id=oven["oven_id"],
device_id=self._registration["device_id"],
device_name=self.device_name,
password=self._registration["password"],
ed25519_seed_hex=bytes(self._signing_key).hex(),
access_token=self._registration["access_token"],
refresh_token=self._registration["refresh_token"],
)
self._finished.set()
await self.async_close()
return
self._fail(
JunePairingNotReady("Timed out waiting for the oven to finish pairing")
)
async def _async_deadline(self) -> None:
await asyncio.sleep(PAIRING_TIMEOUT)
self._fail(JunePairingNotReady("Pairing session timed out"))
await self.async_close()
def _fail(self, error: JuneError) -> None:
if self._finished.is_set():
return
self.error = error
self._finished.set()
asyncio.create_task(self.async_close(), name="june-oven-pairing-cleanup")