airlino-api 0.1.0__tar.gz

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,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: airlino-api
3
+ Version: 0.1.0
4
+ Summary: Async HTTP API client for AirLino media players
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: aiohttp>=3.9
8
+
9
+ # airlino-api
10
+
11
+ `airlino-api` is an asynchronous Python client for the HTTP API used by LinTech AirLino media players. It supports device and network information, playback, radio streams, volume, and Songcast multiroom operations. It is a third-party community project and is not affiliated with LinTech GmbH.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ python -m pip install airlino-api
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from airlino_api import AirlinoApi
25
+
26
+
27
+ async def main() -> None:
28
+ api = AirlinoApi("192.168.1.100")
29
+ try:
30
+ info = await api.async_get_device_info()
31
+ print(info)
32
+ await api.async_play()
33
+ finally:
34
+ await api.async_close()
35
+
36
+
37
+ asyncio.run(main())
38
+ ```
39
+
40
+ The default port is `8989` and the default API version is `v22`. Override them when constructing the client if your device uses different settings:
41
+
42
+ ```python
43
+ api = AirlinoApi("airlino.local", port=8989, api_version="v22")
44
+ ```
45
+
46
+ ## HTTP session lifecycle
47
+
48
+ The client creates and owns an `aiohttp.ClientSession` when no session is supplied. Close the client with `await api.async_close()` when finished. If you pass an existing session, the caller owns it and must close it; `async_close()` will not close that supplied session.
49
+
50
+ ```python
51
+ import aiohttp
52
+ from airlino_api import AirlinoApi
53
+
54
+ async with aiohttp.ClientSession() as session:
55
+ api = AirlinoApi("192.168.1.100", session=session)
56
+ info = await api.async_get_device_info()
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ `AirlinoApiConnectionError` indicates a connection or timeout problem. `AirlinoApiError` indicates an HTTP, device, response, or validation error; its `status` attribute contains the HTTP status code when available.
62
+
63
+ ## Home Assistant
64
+
65
+ This client is used by the [AirLino Home Assistant integration](https://github.com/Philipp-E/home-assistant.io/blob/feature-airlino_integration/source/_integrations/airlino.markdown). It requires Python 3.11 or later.
@@ -0,0 +1,57 @@
1
+ # airlino-api
2
+
3
+ `airlino-api` is an asynchronous Python client for the HTTP API used by LinTech AirLino media players. It supports device and network information, playback, radio streams, volume, and Songcast multiroom operations. It is a third-party community project and is not affiliated with LinTech GmbH.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ python -m pip install airlino-api
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import asyncio
15
+
16
+ from airlino_api import AirlinoApi
17
+
18
+
19
+ async def main() -> None:
20
+ api = AirlinoApi("192.168.1.100")
21
+ try:
22
+ info = await api.async_get_device_info()
23
+ print(info)
24
+ await api.async_play()
25
+ finally:
26
+ await api.async_close()
27
+
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ The default port is `8989` and the default API version is `v22`. Override them when constructing the client if your device uses different settings:
33
+
34
+ ```python
35
+ api = AirlinoApi("airlino.local", port=8989, api_version="v22")
36
+ ```
37
+
38
+ ## HTTP session lifecycle
39
+
40
+ The client creates and owns an `aiohttp.ClientSession` when no session is supplied. Close the client with `await api.async_close()` when finished. If you pass an existing session, the caller owns it and must close it; `async_close()` will not close that supplied session.
41
+
42
+ ```python
43
+ import aiohttp
44
+ from airlino_api import AirlinoApi
45
+
46
+ async with aiohttp.ClientSession() as session:
47
+ api = AirlinoApi("192.168.1.100", session=session)
48
+ info = await api.async_get_device_info()
49
+ ```
50
+
51
+ ## Errors
52
+
53
+ `AirlinoApiConnectionError` indicates a connection or timeout problem. `AirlinoApiError` indicates an HTTP, device, response, or validation error; its `status` attribute contains the HTTP status code when available.
54
+
55
+ ## Home Assistant
56
+
57
+ This client is used by the [AirLino Home Assistant integration](https://github.com/Philipp-E/home-assistant.io/blob/feature-airlino_integration/source/_integrations/airlino.markdown). It requires Python 3.11 or later.
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "airlino-api"
7
+ version = "0.1.0"
8
+ description = "Async HTTP API client for AirLino media players"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.11"
11
+ dependencies = ["aiohttp>=3.9"]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ """Airlino API client package."""
2
+
3
+ from .api import AirlinoApi, AirlinoApiConnectionError, AirlinoApiError
4
+ from .const import (
5
+ DEFAULT_API_VERSION,
6
+ DEFAULT_PORT,
7
+ PLAYER_STATE_PAUSED,
8
+ PLAYER_STATE_PLAYING,
9
+ PLAYER_STATE_STOPPED,
10
+ RECEIVER_STATE_DISCONNECTED,
11
+ RECEIVER_STATE_NOT_PLAYING,
12
+ RECEIVER_STATE_OFF,
13
+ RECEIVER_STATE_PLAYING,
14
+ SENDER_STATE_PLAYING,
15
+ SENDER_STATE_STOPPED,
16
+ VOLUME_MAX,
17
+ VOLUME_MIN,
18
+ VOLUME_STEP,
19
+ )
20
+
21
+ __all__ = [
22
+ "AirlinoApi",
23
+ "AirlinoApiConnectionError",
24
+ "AirlinoApiError",
25
+ "DEFAULT_API_VERSION",
26
+ "DEFAULT_PORT",
27
+ "PLAYER_STATE_PAUSED",
28
+ "PLAYER_STATE_PLAYING",
29
+ "PLAYER_STATE_STOPPED",
30
+ "RECEIVER_STATE_DISCONNECTED",
31
+ "RECEIVER_STATE_NOT_PLAYING",
32
+ "RECEIVER_STATE_OFF",
33
+ "RECEIVER_STATE_PLAYING",
34
+ "SENDER_STATE_PLAYING",
35
+ "SENDER_STATE_STOPPED",
36
+ "VOLUME_MAX",
37
+ "VOLUME_MIN",
38
+ "VOLUME_STEP",
39
+ ]
@@ -0,0 +1,303 @@
1
+ """The API handling for Airlino (Lintech HBM11 HTTP API)."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+
9
+ from .const import (
10
+ API_TIMEOUT,
11
+ DEFAULT_API_VERSION,
12
+ DEFAULT_PORT,
13
+ MULTIROOM_GROUP_NAME,
14
+ PLAYER_STATE_PAUSED,
15
+ PLAYER_STATE_PLAYING,
16
+ PLAYER_STATE_STOPPED,
17
+ RECEIVER_STATE_DISCONNECTED,
18
+ RECEIVER_STATE_NOT_PLAYING,
19
+ RECEIVER_STATE_OFF,
20
+ RECEIVER_STATE_PLAYING,
21
+ SONGCAST_MODE_UNICAST,
22
+ VOLUME_MAX,
23
+ VOLUME_MIN,
24
+ VOLUME_STEP,
25
+ )
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+
30
+ class AirlinoApiError(Exception):
31
+ """Raised when the device returns an error."""
32
+
33
+ def __init__(self, message: str, *, status: int | None = None) -> None:
34
+ """Initialize the error."""
35
+ super().__init__(message)
36
+ self.status = status
37
+
38
+
39
+ class AirlinoApiConnectionError(Exception):
40
+ """Raised when the device cannot be reached."""
41
+
42
+
43
+ class AirlinoApi:
44
+ """API access to AirLino devices.
45
+
46
+ All requests are POST requests with a JSON body to
47
+ http://<host>:<port>/api/<version>/<endpoint>.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ host: str,
53
+ port: int = DEFAULT_PORT,
54
+ timeout: float = API_TIMEOUT,
55
+ session: aiohttp.ClientSession | None = None,
56
+ api_version: str = DEFAULT_API_VERSION,
57
+ ) -> None:
58
+ """Initialize the API client."""
59
+ self.host = host
60
+ self.port = port
61
+ self.timeout = timeout
62
+ self.api_version = api_version
63
+ self._session = session
64
+ self._owns_session = session is None
65
+ self._base_url = f"http://{host}:{port}/api/{api_version}"
66
+
67
+ async def async_close(self) -> None:
68
+ """Close the session if we own it."""
69
+ if self._owns_session and self._session and not self._session.closed:
70
+ await self._session.close()
71
+
72
+ async def _get_session(self) -> aiohttp.ClientSession:
73
+ if self._session is None or self._session.closed:
74
+ self._session = aiohttp.ClientSession()
75
+ self._owns_session = True
76
+ return self._session
77
+
78
+ async def _request(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
79
+ """Send a POST request to the device and return the JSON response."""
80
+ session = await self._get_session()
81
+ url = f"{self._base_url}/{endpoint}"
82
+ try:
83
+ async with asyncio.timeout(self.timeout):
84
+ async with session.post(
85
+ url,
86
+ json=payload,
87
+ headers={
88
+ "Content-Type": "application/json; charset=UTF-8",
89
+ "Connection": "close",
90
+ },
91
+ ) as response:
92
+ response.raise_for_status()
93
+ try:
94
+ data: Any = await response.json(content_type=None)
95
+ except ValueError as err:
96
+ raise AirlinoApiError(f"{url} returned invalid JSON") from err
97
+ if not isinstance(data, dict):
98
+ raise AirlinoApiError(
99
+ f"{url} returned invalid response type: "
100
+ f"{type(data).__name__}"
101
+ )
102
+ except TimeoutError as err:
103
+ raise AirlinoApiConnectionError(
104
+ f"Timeout while connecting to {url}"
105
+ ) from err
106
+ except aiohttp.ClientResponseError as err:
107
+ raise AirlinoApiError(
108
+ f"{url} returned HTTP {err.status}: {err.message}",
109
+ status=err.status,
110
+ ) from err
111
+ except aiohttp.ClientError as err:
112
+ raise AirlinoApiConnectionError(
113
+ f"Error connecting to {url}: {err}"
114
+ ) from err
115
+ else:
116
+ LOGGER.debug("POST %s %s -> %s", url, payload, data)
117
+ return data
118
+
119
+ async def _action(
120
+ self, endpoint: str, action: str, **params: Any
121
+ ) -> dict[str, Any]:
122
+ """Send an action to an endpoint and validate the returncode."""
123
+ payload: dict[str, Any] = {"action": action, **params}
124
+ data = await self._request(endpoint, payload)
125
+ if data.get("returncode") == "error":
126
+ raise AirlinoApiError(f"{endpoint}/{action} returned error: {data}")
127
+ return data
128
+
129
+ async def async_get_device_info(self) -> dict[str, Any]:
130
+ """Get device information (model, devicename, firmware, hardware)."""
131
+ data = await self._request("device.action", {"action": "info"})
132
+ for key in ("model", "devicename", "firmware", "hardware"):
133
+ value = data.get(key)
134
+ if value is not None and not isinstance(value, str):
135
+ raise AirlinoApiError(f"Device info field {key} must be a string")
136
+ return data
137
+
138
+ async def async_get_network_info(self) -> dict[str, Any]:
139
+ """Get network information."""
140
+ data = await self._request("network.action", {"action": "info"})
141
+ for interface_name in ("eth", "wlan"):
142
+ interface = data.get(interface_name)
143
+ if interface is not None and not isinstance(interface, dict):
144
+ raise AirlinoApiError(
145
+ f"Network info field {interface_name} must be an object"
146
+ )
147
+ if isinstance(interface, dict):
148
+ mac = interface.get("mac")
149
+ if mac is not None and not isinstance(mac, str):
150
+ raise AirlinoApiError(
151
+ f"Network info field {interface_name}.mac must be a string"
152
+ )
153
+ return data
154
+
155
+ async def async_get_player_status(self) -> dict[str, Any]:
156
+ """Get playback state and status information."""
157
+ data = await self._request("player.action", {"action": "status"})
158
+ state = data.get("state")
159
+ if state is not None and (
160
+ isinstance(state, bool)
161
+ or not isinstance(state, int)
162
+ or state
163
+ not in (
164
+ PLAYER_STATE_STOPPED,
165
+ PLAYER_STATE_PLAYING,
166
+ PLAYER_STATE_PAUSED,
167
+ )
168
+ ):
169
+ raise AirlinoApiError("Player status contains an invalid state")
170
+ status = data.get("status")
171
+ if status is not None and not isinstance(status, dict):
172
+ raise AirlinoApiError("Player status field status must be an object")
173
+ if isinstance(status, dict):
174
+ for key in ("station", "track"):
175
+ value = status.get(key)
176
+ if value is not None and not isinstance(value, dict):
177
+ raise AirlinoApiError(
178
+ f"Player status field status.{key} must be an object"
179
+ )
180
+ return data
181
+
182
+ async def async_play(self) -> None:
183
+ """Start playback."""
184
+ await self._action("player.action", "play")
185
+
186
+ async def async_playpause(self) -> None:
187
+ """Toggle between play and pause."""
188
+ await self._action("player.action", "playpause")
189
+
190
+ async def async_stop(self) -> None:
191
+ """Stop playback."""
192
+ await self._action("player.action", "stop")
193
+
194
+ async def async_next(self) -> None:
195
+ """Play next item from the playlist."""
196
+ await self._action("player.action", "next")
197
+
198
+ async def async_previous(self) -> None:
199
+ """Play previous item from the playlist."""
200
+ await self._action("player.action", "prev")
201
+
202
+ async def async_play_station(
203
+ self, url: str, name: str | None = None, image: str | None = None
204
+ ) -> None:
205
+ """Play a stream URL on the device (radio.action/play)."""
206
+ station: dict[str, Any] = {"url": url}
207
+ if name is not None:
208
+ station["name"] = name
209
+ if image is not None:
210
+ station["image"] = image
211
+ await self._action("radio.action", "play", station=station)
212
+
213
+ async def async_get_master_volume(self) -> int:
214
+ """Get the master volume level."""
215
+ data = await self._request("sound.action", {"action": "getmastervol"})
216
+ volume = data.get("volume")
217
+ if isinstance(volume, bool) or not isinstance(volume, (int, str)):
218
+ raise AirlinoApiError("Response is missing a valid volume")
219
+ try:
220
+ parsed_volume = int(volume)
221
+ except ValueError as err:
222
+ raise AirlinoApiError("Response contains an invalid volume") from err
223
+ if not VOLUME_MIN <= parsed_volume <= VOLUME_MAX:
224
+ raise AirlinoApiError("Response contains an out-of-range volume")
225
+ return parsed_volume
226
+
227
+ async def async_set_master_volume(self, volume: int) -> None:
228
+ """Set the master volume level."""
229
+ if isinstance(volume, bool) or not VOLUME_MIN <= volume <= VOLUME_MAX:
230
+ raise AirlinoApiError("Volume must be within the supported range")
231
+ await self._action("sound.action", "setmastervol", volume=volume)
232
+
233
+ async def async_volume_up(self, step: int = VOLUME_STEP) -> None:
234
+ """Increase master volume."""
235
+ current = await self.async_get_master_volume()
236
+ await self.async_set_master_volume(min(VOLUME_MAX, current + step))
237
+
238
+ async def async_volume_down(self, step: int = VOLUME_STEP) -> None:
239
+ """Decrease master volume."""
240
+ current = await self.async_get_master_volume()
241
+ await self.async_set_master_volume(max(VOLUME_MIN, current - step))
242
+
243
+ async def async_get_sender_status(self) -> dict[str, Any]:
244
+ """Get the Songcast sender status (enabled, state, uuid, groupname, mode)."""
245
+ data = await self._request("songcast/sender.action", {"action": "status"})
246
+ enabled = data.get("enabled")
247
+ if enabled is not None and not isinstance(enabled, (bool, int)):
248
+ raise AirlinoApiError("Sender status field enabled must be boolean")
249
+ uuid = data.get("uuid")
250
+ if uuid is not None and not isinstance(uuid, str):
251
+ raise AirlinoApiError("Sender status field uuid must be a string")
252
+ return data
253
+
254
+ async def async_enable_sender(
255
+ self,
256
+ groupname: str = MULTIROOM_GROUP_NAME,
257
+ mode: int = SONGCAST_MODE_UNICAST,
258
+ ) -> dict[str, Any]:
259
+ """Enable the Songcast sender mode (unicast by default)."""
260
+ try:
261
+ return await self._action(
262
+ "songcast/sender.action",
263
+ "enable",
264
+ groupname=groupname,
265
+ mode=mode,
266
+ )
267
+ except AirlinoApiError as err:
268
+ if "Already running" not in str(err):
269
+ raise
270
+ return {}
271
+
272
+ async def async_disable_sender(self) -> None:
273
+ """Disable the Songcast sender mode."""
274
+ await self._action("songcast/sender.action", "disable")
275
+
276
+ async def async_receiver_link(self, uuid: str) -> None:
277
+ """Link this device as a Songcast receiver to a sender by its UUID."""
278
+ await self._action("songcast/receiver.action", "link", uuid=uuid)
279
+
280
+ async def async_receiver_unlink(self) -> None:
281
+ """Unlink this Songcast receiver from its sender."""
282
+ await self._action("songcast/receiver.action", "unlink")
283
+
284
+ async def async_get_receiver_state(self) -> dict[str, Any]:
285
+ """Get the current state of the Songcast receiver (state, sender UUID)."""
286
+ data = await self._request("songcast/receiver.action", {"action": "state"})
287
+ sender = data.get("sender")
288
+ if sender is not None and not isinstance(sender, str):
289
+ raise AirlinoApiError("Receiver state field sender must be a string")
290
+ state = data.get("state")
291
+ if state is not None and (
292
+ isinstance(state, bool)
293
+ or not isinstance(state, int)
294
+ or state
295
+ not in (
296
+ RECEIVER_STATE_OFF,
297
+ RECEIVER_STATE_NOT_PLAYING,
298
+ RECEIVER_STATE_PLAYING,
299
+ RECEIVER_STATE_DISCONNECTED,
300
+ )
301
+ ):
302
+ raise AirlinoApiError("Receiver state field state is invalid")
303
+ return data
@@ -0,0 +1,24 @@
1
+ """Constants for the AirLino device API."""
2
+
3
+ API_TIMEOUT = 10.0
4
+ DEFAULT_API_VERSION = "v22"
5
+ DEFAULT_PORT = 8989
6
+
7
+ VOLUME_MIN = 0
8
+ VOLUME_MAX = 255
9
+ VOLUME_STEP = 10
10
+
11
+ PLAYER_STATE_STOPPED = 1
12
+ PLAYER_STATE_PLAYING = 2
13
+ PLAYER_STATE_PAUSED = 3
14
+
15
+ MULTIROOM_GROUP_NAME = "Airlino Group"
16
+ SONGCAST_MODE_UNICAST = 0
17
+
18
+ SENDER_STATE_STOPPED = 1
19
+ SENDER_STATE_PLAYING = 2
20
+
21
+ RECEIVER_STATE_OFF = 0
22
+ RECEIVER_STATE_NOT_PLAYING = 1
23
+ RECEIVER_STATE_PLAYING = 2
24
+ RECEIVER_STATE_DISCONNECTED = 3
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: airlino-api
3
+ Version: 0.1.0
4
+ Summary: Async HTTP API client for AirLino media players
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: aiohttp>=3.9
8
+
9
+ # airlino-api
10
+
11
+ `airlino-api` is an asynchronous Python client for the HTTP API used by LinTech AirLino media players. It supports device and network information, playback, radio streams, volume, and Songcast multiroom operations. It is a third-party community project and is not affiliated with LinTech GmbH.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ python -m pip install airlino-api
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from airlino_api import AirlinoApi
25
+
26
+
27
+ async def main() -> None:
28
+ api = AirlinoApi("192.168.1.100")
29
+ try:
30
+ info = await api.async_get_device_info()
31
+ print(info)
32
+ await api.async_play()
33
+ finally:
34
+ await api.async_close()
35
+
36
+
37
+ asyncio.run(main())
38
+ ```
39
+
40
+ The default port is `8989` and the default API version is `v22`. Override them when constructing the client if your device uses different settings:
41
+
42
+ ```python
43
+ api = AirlinoApi("airlino.local", port=8989, api_version="v22")
44
+ ```
45
+
46
+ ## HTTP session lifecycle
47
+
48
+ The client creates and owns an `aiohttp.ClientSession` when no session is supplied. Close the client with `await api.async_close()` when finished. If you pass an existing session, the caller owns it and must close it; `async_close()` will not close that supplied session.
49
+
50
+ ```python
51
+ import aiohttp
52
+ from airlino_api import AirlinoApi
53
+
54
+ async with aiohttp.ClientSession() as session:
55
+ api = AirlinoApi("192.168.1.100", session=session)
56
+ info = await api.async_get_device_info()
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ `AirlinoApiConnectionError` indicates a connection or timeout problem. `AirlinoApiError` indicates an HTTP, device, response, or validation error; its `status` attribute contains the HTTP status code when available.
62
+
63
+ ## Home Assistant
64
+
65
+ This client is used by the [AirLino Home Assistant integration](https://github.com/Philipp-E/home-assistant.io/blob/feature-airlino_integration/source/_integrations/airlino.markdown). It requires Python 3.11 or later.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/airlino_api/__init__.py
4
+ src/airlino_api/api.py
5
+ src/airlino_api/const.py
6
+ src/airlino_api.egg-info/PKG-INFO
7
+ src/airlino_api.egg-info/SOURCES.txt
8
+ src/airlino_api.egg-info/dependency_links.txt
9
+ src/airlino_api.egg-info/requires.txt
10
+ src/airlino_api.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ aiohttp>=3.9
@@ -0,0 +1 @@
1
+ airlino_api