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.
- airlino_api-0.1.0/PKG-INFO +65 -0
- airlino_api-0.1.0/README.md +57 -0
- airlino_api-0.1.0/pyproject.toml +14 -0
- airlino_api-0.1.0/setup.cfg +4 -0
- airlino_api-0.1.0/src/airlino_api/__init__.py +39 -0
- airlino_api-0.1.0/src/airlino_api/api.py +303 -0
- airlino_api-0.1.0/src/airlino_api/const.py +24 -0
- airlino_api-0.1.0/src/airlino_api.egg-info/PKG-INFO +65 -0
- airlino_api-0.1.0/src/airlino_api.egg-info/SOURCES.txt +10 -0
- airlino_api-0.1.0/src/airlino_api.egg-info/dependency_links.txt +1 -0
- airlino_api-0.1.0/src/airlino_api.egg-info/requires.txt +1 -0
- airlino_api-0.1.0/src/airlino_api.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
airlino_api
|