flow-it-api 0.0.1.0b4__py3-none-any.whl
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.
- flow_it_api/__init__.py +13 -0
- flow_it_api/auth.py +98 -0
- flow_it_api/client.py +221 -0
- flow_it_api/const.py +48 -0
- flow_it_api/exceptions.py +21 -0
- flow_it_api/models.py +182 -0
- flow_it_api/websocket.py +92 -0
- flow_it_api-0.0.1.0b4.dist-info/METADATA +143 -0
- flow_it_api-0.0.1.0b4.dist-info/RECORD +11 -0
- flow_it_api-0.0.1.0b4.dist-info/WHEEL +4 -0
- flow_it_api-0.0.1.0b4.dist-info/licenses/LICENSE +674 -0
flow_it_api/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Python API library client for the FlowIt VMC machine."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version("flow-it-api")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
# Package is not installed
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
_LOGGER.info("Loading flow-it-api version %s", __version__)
|
flow_it_api/auth.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Authentication management for the FlowIt VMC API."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .const import DEFAULT_USERNAME, TIMEOUT
|
|
9
|
+
from .exceptions import FlowItAuthError, FlowItConnectionError
|
|
10
|
+
from .models import AuthResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Authenticator:
|
|
14
|
+
"""Handles JWT authentication and token lifecycle."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
host: str,
|
|
19
|
+
password: str,
|
|
20
|
+
username: str = DEFAULT_USERNAME,
|
|
21
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Initialize the authenticator.
|
|
25
|
+
|
|
26
|
+
:param host: Base URL of the VMC machine.
|
|
27
|
+
:param password: API password.
|
|
28
|
+
:param username: API username.
|
|
29
|
+
:param client: Optional existing HTTPX async client.
|
|
30
|
+
"""
|
|
31
|
+
self.host = host.rstrip("/")
|
|
32
|
+
self.username = username
|
|
33
|
+
self.password = password
|
|
34
|
+
self._client = client
|
|
35
|
+
self._token: Optional[str] = None
|
|
36
|
+
self._expires_at: float = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def token(self) -> Optional[str]:
|
|
40
|
+
"""
|
|
41
|
+
Return the current token if it is still valid.
|
|
42
|
+
|
|
43
|
+
:return: Token string or None if expired or not set.
|
|
44
|
+
"""
|
|
45
|
+
if self._token and time.time() < self._expires_at:
|
|
46
|
+
return self._token
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def login(self) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Login to the device and return the JWT token.
|
|
52
|
+
|
|
53
|
+
:return: JWT token string.
|
|
54
|
+
:raises FlowItAuthError: If credentials are invalid.
|
|
55
|
+
:raises FlowItConnectionError: If connection fails.
|
|
56
|
+
"""
|
|
57
|
+
url = f"{self.host}/auth"
|
|
58
|
+
data = {"username": self.username, "password": self.password}
|
|
59
|
+
|
|
60
|
+
own_client = False
|
|
61
|
+
if self._client is None:
|
|
62
|
+
self._client = httpx.AsyncClient(timeout=TIMEOUT)
|
|
63
|
+
own_client = True
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
response = await self._client.post(url, json=data)
|
|
67
|
+
if response.status_code == 401:
|
|
68
|
+
raise FlowItAuthError("Invalid credentials")
|
|
69
|
+
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
auth_data = AuthResponse(**response.json())
|
|
72
|
+
|
|
73
|
+
self._token = auth_data.token
|
|
74
|
+
# Set expiration with a 30s buffer
|
|
75
|
+
self._expires_at = time.time() + auth_data.expires_in - 30
|
|
76
|
+
return self._token
|
|
77
|
+
except httpx.HTTPError as e:
|
|
78
|
+
raise FlowItConnectionError(f"Failed to connect to {url}: {e}") from e
|
|
79
|
+
finally:
|
|
80
|
+
if own_client:
|
|
81
|
+
await self._client.aclose()
|
|
82
|
+
self._client = None
|
|
83
|
+
|
|
84
|
+
async def get_valid_token(self) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Return a valid token, logging in if necessary.
|
|
87
|
+
|
|
88
|
+
:return: Valid JWT token string.
|
|
89
|
+
"""
|
|
90
|
+
token = self.token
|
|
91
|
+
if token:
|
|
92
|
+
return token
|
|
93
|
+
return await self.login()
|
|
94
|
+
|
|
95
|
+
def invalidate_token(self) -> None:
|
|
96
|
+
"""Invalidate the current token forcing a re-login on next request."""
|
|
97
|
+
self._token = None
|
|
98
|
+
self._expires_at = 0
|
flow_it_api/client.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Main client for interacting with the FlowIt VMC machine."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable, Optional, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .auth import Authenticator
|
|
9
|
+
from .const import DEFAULT_USERNAME, TIMEOUT, Speed
|
|
10
|
+
from .exceptions import FlowItCommandError, FlowItConnectionError, FlowItResponseError
|
|
11
|
+
from .models import (
|
|
12
|
+
CommandRequest,
|
|
13
|
+
CommandResponse,
|
|
14
|
+
MachineData,
|
|
15
|
+
MachineInfoResponse,
|
|
16
|
+
MachineStatusResponse,
|
|
17
|
+
)
|
|
18
|
+
from .websocket import FlowItWebSocket
|
|
19
|
+
|
|
20
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def authenticated(func: F) -> F:
|
|
24
|
+
"""
|
|
25
|
+
Decorator to ensure the client is authenticated before calling a method.
|
|
26
|
+
|
|
27
|
+
Automatically handles token refresh if a 401 Unauthorized is received.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@wraps(func)
|
|
31
|
+
async def wrapper(self: "FlowItVMCMachine", *args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
token = await self._auth.get_valid_token()
|
|
33
|
+
try:
|
|
34
|
+
return await func(self, token, *args, **kwargs)
|
|
35
|
+
except httpx.HTTPStatusError as e:
|
|
36
|
+
if e.response.status_code == 401:
|
|
37
|
+
self._auth.invalidate_token()
|
|
38
|
+
token = await self._auth.get_valid_token()
|
|
39
|
+
return await func(self, token, *args, **kwargs)
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
return cast(F, wrapper)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FlowItVMCMachine:
|
|
46
|
+
"""Primary API client for FlowIt VMC devices."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
host: str,
|
|
51
|
+
password: str,
|
|
52
|
+
username: str = DEFAULT_USERNAME,
|
|
53
|
+
session: Optional[httpx.AsyncClient] = None,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Initialize the VMC machine client.
|
|
57
|
+
|
|
58
|
+
:param host: Base URL of the VMC machine.
|
|
59
|
+
:param password: API password.
|
|
60
|
+
:param username: API username.
|
|
61
|
+
:param session: Optional existing HTTPX async client (session).
|
|
62
|
+
"""
|
|
63
|
+
self.host = host.rstrip("/")
|
|
64
|
+
self._session = session
|
|
65
|
+
self._own_session = False
|
|
66
|
+
if self._session is None:
|
|
67
|
+
self._session = httpx.AsyncClient(timeout=TIMEOUT)
|
|
68
|
+
self._own_session = True
|
|
69
|
+
|
|
70
|
+
self._auth = Authenticator(self.host, password, username, client=self._session)
|
|
71
|
+
self._state: Optional[MachineStatusResponse] = None
|
|
72
|
+
self._info: Optional[MachineInfoResponse] = None
|
|
73
|
+
self._ws: Optional[FlowItWebSocket] = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def websocket(self) -> FlowItWebSocket:
|
|
77
|
+
"""
|
|
78
|
+
Return the websocket client for real-time updates.
|
|
79
|
+
|
|
80
|
+
:return: FlowItWebSocket instance.
|
|
81
|
+
"""
|
|
82
|
+
if self._ws is None:
|
|
83
|
+
self._ws = FlowItWebSocket(
|
|
84
|
+
self.host, self._auth, on_data=self._async_handle_ws_data
|
|
85
|
+
)
|
|
86
|
+
return self._ws
|
|
87
|
+
|
|
88
|
+
async def _async_handle_ws_data(self, data: MachineData) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Internal handler for data received from the websocket.
|
|
91
|
+
|
|
92
|
+
:param data: Parsed machine data from the WS event.
|
|
93
|
+
"""
|
|
94
|
+
if self._state:
|
|
95
|
+
self._state.data = data
|
|
96
|
+
else:
|
|
97
|
+
# If we don't have a full state yet, we just initialize the data part
|
|
98
|
+
# but we won't have the top-level fields like name, chrono_id, etc.
|
|
99
|
+
# until a refresh_state is called.
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def machine_state(self) -> Optional[MachineData]:
|
|
104
|
+
"""
|
|
105
|
+
Return the current machine data if available.
|
|
106
|
+
|
|
107
|
+
:return: MachineData or None.
|
|
108
|
+
"""
|
|
109
|
+
if self._state:
|
|
110
|
+
return self._state.data
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_connected(self) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Return True if the client has successfully fetched the state.
|
|
117
|
+
|
|
118
|
+
:return: Connection status boolean.
|
|
119
|
+
"""
|
|
120
|
+
return self._state is not None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def _http(self) -> httpx.AsyncClient:
|
|
124
|
+
"""Return the async client, ensuring it is initialized."""
|
|
125
|
+
if self._session is None:
|
|
126
|
+
# Fallback in case of unexpected state
|
|
127
|
+
self._session = httpx.AsyncClient(timeout=TIMEOUT)
|
|
128
|
+
self._own_session = True
|
|
129
|
+
return self._session
|
|
130
|
+
|
|
131
|
+
async def get_info(self) -> MachineInfoResponse:
|
|
132
|
+
"""
|
|
133
|
+
Fetch basic machine information (Model, FW, HW versions).
|
|
134
|
+
|
|
135
|
+
Does not require authentication.
|
|
136
|
+
|
|
137
|
+
:return: MachineInfoResponse instance.
|
|
138
|
+
:raises FlowItConnectionError: If connection fails.
|
|
139
|
+
"""
|
|
140
|
+
url = f"{self.host}/info"
|
|
141
|
+
try:
|
|
142
|
+
response = await self._http.get(url)
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
self._info = MachineInfoResponse(**response.json())
|
|
145
|
+
return self._info
|
|
146
|
+
except httpx.HTTPError as e:
|
|
147
|
+
raise FlowItConnectionError(f"Failed to fetch info: {e}") from e
|
|
148
|
+
|
|
149
|
+
@authenticated
|
|
150
|
+
async def refresh_state(self, token: str) -> MachineData:
|
|
151
|
+
"""
|
|
152
|
+
Fetch the full machine state via REST API.
|
|
153
|
+
|
|
154
|
+
:param token: JWT token (provided by @authenticated).
|
|
155
|
+
:return: MachineData instance.
|
|
156
|
+
:raises FlowItConnectionError: If connection fails.
|
|
157
|
+
:raises FlowItResponseError: If parsing fails.
|
|
158
|
+
"""
|
|
159
|
+
url = f"{self.host}/status"
|
|
160
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
response = await self._http.get(url, headers=headers)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
self._state = MachineStatusResponse(**response.json())
|
|
166
|
+
return self._state.data
|
|
167
|
+
except httpx.HTTPError as e:
|
|
168
|
+
raise FlowItConnectionError(f"Failed to refresh state: {e}") from e
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise FlowItResponseError(f"Failed to parse state response: {e}") from e
|
|
171
|
+
|
|
172
|
+
@authenticated
|
|
173
|
+
async def send_command(
|
|
174
|
+
self, token: str, speed: Speed, flow_in: bool, flow_out: bool
|
|
175
|
+
) -> CommandResponse:
|
|
176
|
+
"""
|
|
177
|
+
Send a command to update speed and flow settings.
|
|
178
|
+
|
|
179
|
+
:param token: JWT token (provided by @authenticated).
|
|
180
|
+
:param speed: Target fan speed level.
|
|
181
|
+
:param flow_in: Enable/disable inflow.
|
|
182
|
+
:param flow_out: Enable/disable outflow.
|
|
183
|
+
:return: CommandResponse instance.
|
|
184
|
+
:raises FlowItCommandError: If the command fails.
|
|
185
|
+
"""
|
|
186
|
+
url = f"{self.host}/command"
|
|
187
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
188
|
+
|
|
189
|
+
command = CommandRequest(speed=speed, flowIn=flow_in, flowOut=flow_out)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
response = await self._http.post(
|
|
193
|
+
url, json=command.model_dump(), headers=headers
|
|
194
|
+
)
|
|
195
|
+
response.raise_for_status()
|
|
196
|
+
cmd_resp = CommandResponse(**response.json())
|
|
197
|
+
|
|
198
|
+
# Optimistically update local state if we have one
|
|
199
|
+
if self._state:
|
|
200
|
+
self._state.data.mode.speed = cmd_resp.speed
|
|
201
|
+
self._state.data.mode.flowIn = cmd_resp.flowIn
|
|
202
|
+
self._state.data.mode.flowOut = cmd_resp.flowOut
|
|
203
|
+
|
|
204
|
+
return cmd_resp
|
|
205
|
+
except httpx.HTTPError as e:
|
|
206
|
+
raise FlowItCommandError(f"Command failed: {e}") from e
|
|
207
|
+
|
|
208
|
+
async def close(self) -> None:
|
|
209
|
+
"""Close the underlying HTTP client (if owned) and stop websocket listener."""
|
|
210
|
+
if self._ws:
|
|
211
|
+
await self._ws.stop()
|
|
212
|
+
if self._own_session and self._session:
|
|
213
|
+
await self._session.aclose()
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> "FlowItVMCMachine":
|
|
216
|
+
"""Async context manager entry."""
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
220
|
+
"""Async context manager exit."""
|
|
221
|
+
await self.close()
|
flow_it_api/const.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Constants for the FlowIt VMC API."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Speed(StrEnum):
|
|
7
|
+
"""VMC Fan speed levels."""
|
|
8
|
+
|
|
9
|
+
OFF = "off"
|
|
10
|
+
LEVEL_1 = "1"
|
|
11
|
+
LEVEL_2 = "2"
|
|
12
|
+
LEVEL_3 = "3"
|
|
13
|
+
LEVEL_4 = "4"
|
|
14
|
+
LEVEL_5 = "5"
|
|
15
|
+
AUTO = "auto"
|
|
16
|
+
BOOST = "boost"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BypassMode(StrEnum):
|
|
20
|
+
"""VMC Bypass operation modes."""
|
|
21
|
+
|
|
22
|
+
IN_OUT = "0"
|
|
23
|
+
IN_ONLY = "1"
|
|
24
|
+
OUT_ONLY = "2"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FilterStatus(IntEnum):
|
|
28
|
+
"""Filter cleanliness status percentage/level."""
|
|
29
|
+
|
|
30
|
+
DIRTY_0 = 0
|
|
31
|
+
DIRTY_25 = 1
|
|
32
|
+
DIRTY_50 = 2
|
|
33
|
+
DIRTY_75 = 3
|
|
34
|
+
CLEAN = 4
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AlertFilterStatus(IntEnum):
|
|
38
|
+
"""Alert status for filter maintenance."""
|
|
39
|
+
|
|
40
|
+
CLEAN = 0
|
|
41
|
+
DIRTY = 1
|
|
42
|
+
CLOGGED = 2
|
|
43
|
+
MISSING = 3
|
|
44
|
+
NOT_CHECKED = 4
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DEFAULT_USERNAME = "api"
|
|
48
|
+
TIMEOUT = 10
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions for the FlowIt VMC API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FlowItError(Exception):
|
|
5
|
+
"""Base exception for flow-it-api."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FlowItAuthError(FlowItError):
|
|
9
|
+
"""Exception raised for authentication errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FlowItConnectionError(FlowItError):
|
|
13
|
+
"""Exception raised for connection errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FlowItResponseError(FlowItError):
|
|
17
|
+
"""Exception raised for invalid or error responses from the device."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FlowItCommandError(FlowItError):
|
|
21
|
+
"""Exception raised when a command fails."""
|
flow_it_api/models.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Data models for the FlowIt VMC API."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from .const import AlertFilterStatus, BypassMode, FilterStatus, Speed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_bool(v: Any) -> bool:
|
|
11
|
+
"""
|
|
12
|
+
Parse a value as a boolean.
|
|
13
|
+
|
|
14
|
+
Supports string values like "TRUE" and "FALSE".
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(v, str):
|
|
17
|
+
if v.upper() == "TRUE":
|
|
18
|
+
return True
|
|
19
|
+
if v.upper() == "FALSE":
|
|
20
|
+
return False
|
|
21
|
+
return bool(v)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FlowItBool = Annotated[bool, BeforeValidator(parse_bool)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_sensor_value(v: Any) -> float | None:
|
|
28
|
+
"""Parse sensor reading, returning None if 0."""
|
|
29
|
+
try:
|
|
30
|
+
val = float(v)
|
|
31
|
+
if val == 0.0:
|
|
32
|
+
return None
|
|
33
|
+
return val
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
FlowItSensorValue = Annotated[float | None, BeforeValidator(parse_sensor_value)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SensorReadings(BaseModel):
|
|
42
|
+
"""Raw sensor readings (pressure, temperature, humidity)."""
|
|
43
|
+
|
|
44
|
+
pressure: FlowItSensorValue
|
|
45
|
+
temperature: FlowItSensorValue
|
|
46
|
+
humidity: FlowItSensorValue
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def temperature_celsius(self) -> float | None:
|
|
50
|
+
"""Return temperature in Celsius."""
|
|
51
|
+
if self.temperature is None:
|
|
52
|
+
return None
|
|
53
|
+
return round(self.temperature - 273.15, 2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MachineSensors(BaseModel):
|
|
57
|
+
"""Collection of all machine sensors."""
|
|
58
|
+
|
|
59
|
+
Sin: SensorReadings = Field(..., description="Inflow sensor before fan")
|
|
60
|
+
Sout: SensorReadings = Field(..., description="Inflow sensor after fan")
|
|
61
|
+
Iin: SensorReadings = Field(..., description="Extraction sensor before fan")
|
|
62
|
+
Iout: SensorReadings = Field(..., description="Extraction sensor after fan")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MachineMode(BaseModel):
|
|
66
|
+
"""Machine operation mode and environmental data."""
|
|
67
|
+
|
|
68
|
+
speed: Speed
|
|
69
|
+
autoSpeed: str
|
|
70
|
+
flowIn: FlowItBool
|
|
71
|
+
flowOut: FlowItBool
|
|
72
|
+
bypassMode: BypassMode
|
|
73
|
+
iaq: int
|
|
74
|
+
temperatureIn: FlowItSensorValue
|
|
75
|
+
temperatureOut: FlowItSensorValue
|
|
76
|
+
humidityIn: FlowItSensorValue
|
|
77
|
+
humidityOut: FlowItSensorValue
|
|
78
|
+
pressureIn: FlowItSensorValue
|
|
79
|
+
pressureOut: FlowItSensorValue
|
|
80
|
+
bypassOn: FlowItBool = False
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def temperatureIn_celsius(self) -> float | None:
|
|
84
|
+
"""Return inflow temperature in Celsius."""
|
|
85
|
+
if self.temperatureIn is None:
|
|
86
|
+
return None
|
|
87
|
+
return round(self.temperatureIn - 273.15, 2)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def temperatureOut_celsius(self) -> float | None:
|
|
91
|
+
"""Return outflow temperature in Celsius."""
|
|
92
|
+
if self.temperatureOut is None:
|
|
93
|
+
return None
|
|
94
|
+
return round(self.temperatureOut - 273.15, 2)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FilterDetail(BaseModel):
|
|
98
|
+
"""Detailed filter information."""
|
|
99
|
+
|
|
100
|
+
status: FilterStatus
|
|
101
|
+
changed: int
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MachineFilter(BaseModel):
|
|
105
|
+
"""Machine filter status (HEPA and G4)."""
|
|
106
|
+
|
|
107
|
+
hepa: FilterDetail
|
|
108
|
+
g4: FilterDetail
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MachineAlert(BaseModel):
|
|
112
|
+
"""Machine alerts and diagnostic status."""
|
|
113
|
+
|
|
114
|
+
update_reboot: FlowItBool
|
|
115
|
+
worries: FlowItBool
|
|
116
|
+
ice: FlowItBool
|
|
117
|
+
condensation: FlowItBool
|
|
118
|
+
filterS: AlertFilterStatus
|
|
119
|
+
filterI: AlertFilterStatus
|
|
120
|
+
warmup: FlowItBool
|
|
121
|
+
service: FlowItBool
|
|
122
|
+
fault_code: str = Field(..., alias="fault-code")
|
|
123
|
+
net_fault_code: str = Field(..., alias="net-fault-code")
|
|
124
|
+
version: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MachineData(BaseModel):
|
|
128
|
+
"""Complete machine data container."""
|
|
129
|
+
|
|
130
|
+
event: str
|
|
131
|
+
sensors: MachineSensors
|
|
132
|
+
mode: MachineMode
|
|
133
|
+
filter: MachineFilter
|
|
134
|
+
alert: MachineAlert
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class MachineStatusResponse(BaseModel):
|
|
138
|
+
"""Full machine status response from REST/WS."""
|
|
139
|
+
|
|
140
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
141
|
+
|
|
142
|
+
lastUpdate: int
|
|
143
|
+
chrono_id: str
|
|
144
|
+
status: FlowItBool
|
|
145
|
+
name: str
|
|
146
|
+
data: MachineData
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MachineInfoResponse(BaseModel):
|
|
150
|
+
"""Machine information response (version info)."""
|
|
151
|
+
|
|
152
|
+
model: str
|
|
153
|
+
api_ver: str
|
|
154
|
+
fw_ver: str
|
|
155
|
+
hw_ver: str
|
|
156
|
+
hostname: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AuthResponse(BaseModel):
|
|
160
|
+
"""Authentication response with JWT token."""
|
|
161
|
+
|
|
162
|
+
token: str
|
|
163
|
+
user: str
|
|
164
|
+
expires_in: int
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class CommandRequest(BaseModel):
|
|
168
|
+
"""Request model for sending commands to the machine."""
|
|
169
|
+
|
|
170
|
+
type_message: str = "set_parameters"
|
|
171
|
+
speed: Speed
|
|
172
|
+
flowIn: bool
|
|
173
|
+
flowOut: bool
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CommandResponse(BaseModel):
|
|
177
|
+
"""Response from a command execution."""
|
|
178
|
+
|
|
179
|
+
status: str
|
|
180
|
+
speed: Speed
|
|
181
|
+
flowIn: bool
|
|
182
|
+
flowOut: bool
|
flow_it_api/websocket.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""WebSocket client for the FlowIt VMC API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Awaitable, Callable, Optional
|
|
7
|
+
|
|
8
|
+
import websockets
|
|
9
|
+
|
|
10
|
+
from .auth import Authenticator
|
|
11
|
+
from .models import MachineData, MachineStatusResponse
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FlowItWebSocket:
|
|
17
|
+
"""Handles real-time updates from the VMC machine via WebSocket."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
host: str,
|
|
22
|
+
auth: Authenticator,
|
|
23
|
+
on_data: Optional[Callable[[MachineData], Awaitable[None]]] = None,
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Initialize the WebSocket client.
|
|
27
|
+
|
|
28
|
+
:param host: Base URL of the VMC machine.
|
|
29
|
+
:param auth: Authenticator instance for token management.
|
|
30
|
+
:param on_data: Optional async callback for received data.
|
|
31
|
+
"""
|
|
32
|
+
self.host = (
|
|
33
|
+
host.replace("http://", "ws://").replace("https://", "wss://").rstrip("/")
|
|
34
|
+
)
|
|
35
|
+
self._auth = auth
|
|
36
|
+
self._on_data = on_data
|
|
37
|
+
self._stop = False
|
|
38
|
+
self._task: Optional[asyncio.Task] = None
|
|
39
|
+
|
|
40
|
+
async def listen(self) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Start the websocket listener loop.
|
|
43
|
+
|
|
44
|
+
Handles reconnection and authentication automatically.
|
|
45
|
+
"""
|
|
46
|
+
url = f"{self.host}/ws"
|
|
47
|
+
|
|
48
|
+
while not self._stop:
|
|
49
|
+
try:
|
|
50
|
+
token = await self._auth.get_valid_token()
|
|
51
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
52
|
+
|
|
53
|
+
async with websockets.connect(url, additional_headers=headers) as ws:
|
|
54
|
+
_LOGGER.info("Connected to WebSocket: %s", url)
|
|
55
|
+
async for message in ws:
|
|
56
|
+
if self._stop:
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
data_dict = json.loads(message)
|
|
61
|
+
# The WS might send the full MachineStatusResponse or just MachineData
|
|
62
|
+
# Based on specs, it's usually the full status
|
|
63
|
+
status = MachineStatusResponse(**data_dict)
|
|
64
|
+
if self._on_data:
|
|
65
|
+
await self._on_data(status.data)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
_LOGGER.error("Failed to parse WebSocket message: %s", e)
|
|
68
|
+
|
|
69
|
+
except (websockets.ConnectionClosed, OSError) as e:
|
|
70
|
+
if not self._stop:
|
|
71
|
+
_LOGGER.warning("WebSocket connection lost, retrying in 5s: %s", e)
|
|
72
|
+
await asyncio.sleep(5)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if not self._stop:
|
|
75
|
+
_LOGGER.error("WebSocket unexpected error: %s", e)
|
|
76
|
+
await asyncio.sleep(5)
|
|
77
|
+
|
|
78
|
+
def start(self) -> None:
|
|
79
|
+
"""Start the websocket listener in a background task."""
|
|
80
|
+
self._stop = False
|
|
81
|
+
self._task = asyncio.create_task(self.listen())
|
|
82
|
+
|
|
83
|
+
async def stop(self) -> None:
|
|
84
|
+
"""Stop the websocket listener and cancel the task."""
|
|
85
|
+
self._stop = True
|
|
86
|
+
if self._task:
|
|
87
|
+
self._task.cancel()
|
|
88
|
+
try:
|
|
89
|
+
await self._task
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
pass
|
|
92
|
+
self._task = None
|