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.
@@ -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
@@ -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