python-ember-mug 0.9.0b1__tar.gz → 0.9.0b2__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.
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/PKG-INFO +1 -1
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/__init__.py +1 -1
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/consts.py +1 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/mug.py +42 -26
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/utils.py +15 -1
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/test_commands.py +1 -1
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/conftest.py +9 -4
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_connection.py +55 -27
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_utils.py +28 -6
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/.gitignore +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/LICENSE +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/README.md +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/__main__.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/__init__.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/commands.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/helpers.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/data.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/formatting.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/scanner.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/pyproject.toml +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/__init__.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/__init__.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/test_helpers.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_consts.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_data.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_formatting.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_mug_data.py +0 -0
- {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-ember-mug
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.0b2
|
|
4
4
|
Summary: Python Library for Ember Mugs.
|
|
5
5
|
Project-URL: Documentation, https://sopelj.github.io/python-ember-mug/
|
|
6
6
|
Project-URL: Source code, https://github.com/sopelj/python-ember-mug/
|
|
@@ -8,7 +8,7 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from enum import Enum
|
|
9
9
|
from functools import cached_property
|
|
10
10
|
from time import time
|
|
11
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Concatenate, Literal, ParamSpec, TypeVar
|
|
12
12
|
|
|
13
13
|
from bleak import BleakClient, BleakError
|
|
14
14
|
from bleak_retry_connector import establish_connection
|
|
@@ -18,7 +18,6 @@ from .consts import (
|
|
|
18
18
|
IS_LINUX,
|
|
19
19
|
MUG_NAME_REGEX,
|
|
20
20
|
PUSH_EVENT_BATTERY_IDS,
|
|
21
|
-
DeviceType,
|
|
22
21
|
LiquidState,
|
|
23
22
|
MugCharacteristic,
|
|
24
23
|
PushEvent,
|
|
@@ -36,7 +35,7 @@ from .utils import (
|
|
|
36
35
|
)
|
|
37
36
|
|
|
38
37
|
if TYPE_CHECKING:
|
|
39
|
-
from collections.abc import AsyncIterator, Callable
|
|
38
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
40
39
|
|
|
41
40
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
42
41
|
from bleak.backends.device import BLEDevice
|
|
@@ -46,6 +45,31 @@ logger = logging.getLogger(__name__)
|
|
|
46
45
|
|
|
47
46
|
DISCONNECT_DELAY = 120
|
|
48
47
|
|
|
48
|
+
P = ParamSpec("P")
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_attribute(
|
|
53
|
+
attr_name: str,
|
|
54
|
+
) -> Callable[[Callable[Concatenate[EmberMug, P], Awaitable[T]]], Callable[Concatenate[EmberMug, P], Awaitable[T]]]:
|
|
55
|
+
"""Require an attribute to be available on the device."""
|
|
56
|
+
|
|
57
|
+
def decorator(
|
|
58
|
+
func: Callable[Concatenate[EmberMug, P], Awaitable[T]],
|
|
59
|
+
) -> Callable[Concatenate[EmberMug, P], Awaitable[T]]:
|
|
60
|
+
"""Inner decorator."""
|
|
61
|
+
|
|
62
|
+
async def wrapper(self: EmberMug, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
63
|
+
if self.has_attribute(attr_name) is False:
|
|
64
|
+
raise NotImplementedError(
|
|
65
|
+
f"The {self.data.model_info.device_type} " "does not have a name attribute",
|
|
66
|
+
)
|
|
67
|
+
return await func(self, *args, **kwargs)
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
|
|
71
|
+
return decorator
|
|
72
|
+
|
|
49
73
|
|
|
50
74
|
class EmberMug:
|
|
51
75
|
"""Handle actual the actual mug connection and update states."""
|
|
@@ -72,11 +96,6 @@ class EmberMug:
|
|
|
72
96
|
self._latest_events: dict[int, float] = {}
|
|
73
97
|
self._client_kwargs: dict[str, str] = {}
|
|
74
98
|
|
|
75
|
-
# Just shortcuts, the value doesn't change once initialized
|
|
76
|
-
self.is_tumbler = self.data.model_info.device_type == DeviceType.TUMBLER
|
|
77
|
-
self.is_travel_mug = self.data.model_info.device_type == DeviceType.TRAVEL_MUG
|
|
78
|
-
self.is_cup = self.data.model_info.device_type == DeviceType.CUP
|
|
79
|
-
|
|
80
99
|
logger.debug("New mug connection initialized.")
|
|
81
100
|
self.set_client_options(**kwargs)
|
|
82
101
|
|
|
@@ -90,6 +109,15 @@ class EmberMug:
|
|
|
90
109
|
"""Shortcut to model name."""
|
|
91
110
|
return self.data.model_info.model
|
|
92
111
|
|
|
112
|
+
@property
|
|
113
|
+
def can_write(self) -> bool:
|
|
114
|
+
"""Check if the mug can support write operations."""
|
|
115
|
+
return self.data.udsk is not None
|
|
116
|
+
|
|
117
|
+
def has_attribute(self, attribute: str) -> bool:
|
|
118
|
+
"""Check whether the device has the given attribute."""
|
|
119
|
+
return attribute in self.data.model_info.update_attributes
|
|
120
|
+
|
|
93
121
|
async def _ensure_connection(self) -> None:
|
|
94
122
|
"""Connect to mug."""
|
|
95
123
|
if self._connect_lock.locked():
|
|
@@ -214,19 +242,15 @@ class EmberMug:
|
|
|
214
242
|
"""Get Battery percent from mug gatt."""
|
|
215
243
|
return BatteryInfo.from_bytes(await self._read(MugCharacteristic.BATTERY))
|
|
216
244
|
|
|
245
|
+
@require_attribute("led_colour")
|
|
217
246
|
async def get_led_colour(self) -> Colour:
|
|
218
247
|
"""Get RGBA colours from mug gatt."""
|
|
219
|
-
if self.is_travel_mug is True:
|
|
220
|
-
msg = "The Travel Mug does not have an LED colour attribute"
|
|
221
|
-
raise NotImplementedError(msg)
|
|
222
248
|
colour_data = await self._read(MugCharacteristic.LED)
|
|
223
249
|
return Colour(*bytearray(colour_data))
|
|
224
250
|
|
|
251
|
+
@require_attribute("led_colour")
|
|
225
252
|
async def set_led_colour(self, colour: Colour) -> None:
|
|
226
253
|
"""Set new target temp for mug."""
|
|
227
|
-
if self.is_travel_mug is True:
|
|
228
|
-
msg = "The Travel Mug does not have an LED colour attribute"
|
|
229
|
-
raise NotImplementedError(msg)
|
|
230
254
|
await self._write(MugCharacteristic.LED, colour.as_bytearray())
|
|
231
255
|
self.data.led_colour = colour
|
|
232
256
|
|
|
@@ -251,23 +275,19 @@ class EmberMug:
|
|
|
251
275
|
liquid_level_bytes = await self._read(MugCharacteristic.LIQUID_LEVEL)
|
|
252
276
|
return bytes_to_little_int(liquid_level_bytes)
|
|
253
277
|
|
|
278
|
+
@require_attribute("volume_level")
|
|
254
279
|
async def get_volume_level(self) -> VolumeLevel | None:
|
|
255
280
|
"""Get volume level from mug gatt."""
|
|
256
|
-
if self.is_travel_mug is False:
|
|
257
|
-
msg = "The Mug and Cup do not have a volume level attribute"
|
|
258
|
-
raise NotImplementedError(msg)
|
|
259
281
|
volume_bytes = await self._read(MugCharacteristic.VOLUME)
|
|
260
282
|
volume_int = bytes_to_little_int(volume_bytes)
|
|
261
283
|
return VolumeLevel.from_state(volume_int)
|
|
262
284
|
|
|
285
|
+
@require_attribute("volume_level")
|
|
263
286
|
async def set_volume_level(self, volume: int | VolumeLevel) -> None:
|
|
264
287
|
"""Set volume_level on Travel Mug."""
|
|
265
288
|
if not isinstance(volume, VolumeLevel) and isinstance(volume, int) and volume not in (0, 1, 2):
|
|
266
289
|
msg = "Volume level value should be 0, 1, 2 or a VolumeLevel enum"
|
|
267
290
|
raise ValueError(msg)
|
|
268
|
-
if self.is_travel_mug is False:
|
|
269
|
-
msg = "The Mug and Cup do not have a volume level attribute"
|
|
270
|
-
raise NotImplementedError(msg)
|
|
271
291
|
volume_level = volume if isinstance(volume, VolumeLevel) else VolumeLevel.from_state(volume)
|
|
272
292
|
await self._write(MugCharacteristic.VOLUME, bytearray([volume_level.state]))
|
|
273
293
|
self.data.volume_level = volume_level
|
|
@@ -278,22 +298,18 @@ class EmberMug:
|
|
|
278
298
|
state = bytes_to_little_int(liquid_state_bytes)
|
|
279
299
|
return LiquidState(state)
|
|
280
300
|
|
|
301
|
+
@require_attribute("name")
|
|
281
302
|
async def get_name(self) -> str:
|
|
282
303
|
"""Get mug name from gatt."""
|
|
283
|
-
if self.is_cup is True:
|
|
284
|
-
msg = "The Cup does not have a name attribute"
|
|
285
|
-
raise NotImplementedError(msg)
|
|
286
304
|
name_bytes: bytearray = await self._read(MugCharacteristic.MUG_NAME)
|
|
287
305
|
return bytes(name_bytes).decode("utf8")
|
|
288
306
|
|
|
307
|
+
@require_attribute("name")
|
|
289
308
|
async def set_name(self, name: str) -> None:
|
|
290
309
|
"""Assign new name to mug."""
|
|
291
310
|
if MUG_NAME_REGEX.match(name) is None:
|
|
292
311
|
msg = "Name cannot contain any special characters and must be 16 characters or less"
|
|
293
312
|
raise ValueError(msg)
|
|
294
|
-
if self.is_cup is True:
|
|
295
|
-
msg = "The Cup does not have a name attribute"
|
|
296
|
-
raise NotImplementedError(msg)
|
|
297
313
|
await self._write(MugCharacteristic.MUG_NAME, bytearray(name.encode("utf8")))
|
|
298
314
|
self.data.name = name
|
|
299
315
|
|
|
@@ -111,6 +111,17 @@ def get_model_from_id_and_gen(model_id: int, generation: int) -> DeviceModel | N
|
|
|
111
111
|
return None
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def guess_model_from_name(name: str | None) -> DeviceModel | None:
|
|
115
|
+
"""Guess model from BLE name."""
|
|
116
|
+
if not name:
|
|
117
|
+
return None
|
|
118
|
+
if "Travel" in name:
|
|
119
|
+
return DeviceModel.TRAVEL_MUG_12_OZ
|
|
120
|
+
if "Cup" in name:
|
|
121
|
+
return DeviceModel.CUP_6_OZ
|
|
122
|
+
return DeviceModel.UNKNOWN_DEVICE
|
|
123
|
+
|
|
124
|
+
|
|
114
125
|
def get_model_info_from_advertiser_data(advertisement: AdvertisementData) -> ModelInfo:
|
|
115
126
|
"""Extract model info from manufacturer data in advertiser data."""
|
|
116
127
|
from ember_mug.data import ModelInfo
|
|
@@ -128,7 +139,10 @@ def get_model_info_from_advertiser_data(advertisement: AdvertisementData) -> Mod
|
|
|
128
139
|
get_model_from_id_and_gen(model_id, generation),
|
|
129
140
|
get_colour_from_int(colour_id),
|
|
130
141
|
)
|
|
131
|
-
|
|
142
|
+
logger.debug(
|
|
143
|
+
"Unable to reliably determine model info from advertiser data." "Falling back to guessing based on name.",
|
|
144
|
+
)
|
|
145
|
+
return ModelInfo(guess_model_from_name(advertisement.local_name))
|
|
132
146
|
|
|
133
147
|
|
|
134
148
|
async def discover_services(client: BleakClient) -> dict[str, Any]:
|
|
@@ -14,7 +14,7 @@ from ember_mug import EmberMug
|
|
|
14
14
|
from ember_mug.consts import DEFAULT_NAME, DeviceModel, DeviceColour
|
|
15
15
|
from ember_mug.cli.commands import EmberMugCli, discover, fetch_info, find_device, get_mug, get_mug_value, poll_mug
|
|
16
16
|
from ember_mug.data import ModelInfo, MugData
|
|
17
|
-
from
|
|
17
|
+
from ..conftest import TEST_MAC, mock_connection, TEST_MUG_ADVERTISEMENT
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@pytest.fixture
|
|
@@ -12,7 +12,7 @@ from bleak.backends.device import BLEDevice
|
|
|
12
12
|
|
|
13
13
|
from ember_mug import EmberMug
|
|
14
14
|
from ember_mug.data import ModelInfo, MugData
|
|
15
|
-
from ember_mug.consts import
|
|
15
|
+
from ember_mug.consts import EMBER_BLE_SIG, MugCharacteristic, DeviceModel, DeviceColour
|
|
16
16
|
|
|
17
17
|
TEST_MAC = "32:36:a5:be:88:cb"
|
|
18
18
|
TEST_MUG_BLUETOOTH_NAME = "Ember Ceramic Mug"
|
|
@@ -41,16 +41,20 @@ class TestManufacturerData(bytes, Enum):
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def build_advertisement_data(
|
|
44
|
-
manufacturer_data: TestManufacturerData =
|
|
44
|
+
manufacturer_data: TestManufacturerData | None = None,
|
|
45
45
|
service_uuids: list[MugCharacteristic] | None = None,
|
|
46
46
|
name: str = TEST_MUG_BLUETOOTH_NAME,
|
|
47
47
|
) -> AdvertisementData:
|
|
48
48
|
if service_uuids is None:
|
|
49
49
|
service_uuids = [MugCharacteristic.STANDARD_SERVICE]
|
|
50
50
|
|
|
51
|
+
data_dict: dict[int, bytes] = {}
|
|
52
|
+
if manufacturer_data is not None:
|
|
53
|
+
data_dict[EMBER_BLE_SIG] = manufacturer_data
|
|
54
|
+
|
|
51
55
|
return AdvertisementData(
|
|
52
56
|
local_name=name,
|
|
53
|
-
manufacturer_data=
|
|
57
|
+
manufacturer_data=data_dict,
|
|
54
58
|
service_data={},
|
|
55
59
|
service_uuids=[str(service) for service in service_uuids],
|
|
56
60
|
tx_power=1,
|
|
@@ -59,6 +63,7 @@ def build_advertisement_data(
|
|
|
59
63
|
)
|
|
60
64
|
|
|
61
65
|
|
|
66
|
+
TEST_UNKNOWN_ADVERTISEMENT = build_advertisement_data(None)
|
|
62
67
|
TEST_MUG_ADVERTISEMENT = build_advertisement_data(TestManufacturerData.MUG_2_BLACK)
|
|
63
68
|
TEST_TUMBLER_ADVERTISEMENT = build_advertisement_data(TestManufacturerData.TUMBLER)
|
|
64
69
|
TEST_TRAVEL_MUG_ADVERTISEMENT = build_advertisement_data(
|
|
@@ -83,5 +88,5 @@ async def ember_mug(ble_device: BLEDevice) -> AsyncGenerator[EmberMug | Mock, No
|
|
|
83
88
|
ble_device,
|
|
84
89
|
ModelInfo(DeviceModel.MUG_2_10_OZ, DeviceColour.BLACK),
|
|
85
90
|
)
|
|
86
|
-
mug._client =
|
|
91
|
+
mug._client = Mock()
|
|
87
92
|
yield mug
|
|
@@ -10,16 +10,15 @@ from bleak import BleakError
|
|
|
10
10
|
from bleak.backends.device import BLEDevice
|
|
11
11
|
|
|
12
12
|
from ember_mug.consts import (
|
|
13
|
-
DEFAULT_NAME,
|
|
14
13
|
INITIAL_ATTRS,
|
|
15
14
|
UPDATE_ATTRS,
|
|
16
15
|
MugCharacteristic,
|
|
17
16
|
TemperatureUnit,
|
|
18
17
|
VolumeLevel,
|
|
18
|
+
DeviceModel,
|
|
19
19
|
)
|
|
20
20
|
from ember_mug.data import Colour, ModelInfo
|
|
21
21
|
from ember_mug.mug import EmberMug
|
|
22
|
-
from .conftest import TEST_MUG_BLUETOOTH_NAME
|
|
23
22
|
|
|
24
23
|
if TYPE_CHECKING:
|
|
25
24
|
|
|
@@ -159,7 +158,7 @@ async def test_read(
|
|
|
159
158
|
ember_mug: MockMug,
|
|
160
159
|
) -> None:
|
|
161
160
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
162
|
-
ember_mug._client.read_gatt_char
|
|
161
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"TEST")
|
|
163
162
|
await ember_mug._read(MugCharacteristic.MUG_NAME)
|
|
164
163
|
ember_mug._client.read_gatt_char.assert_called_with(
|
|
165
164
|
MugCharacteristic.MUG_NAME.uuid,
|
|
@@ -175,6 +174,7 @@ async def test_read(
|
|
|
175
174
|
async def test_write(mock_logger: Mock, ember_mug: MockMug) -> None:
|
|
176
175
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
177
176
|
test_name = bytearray(b"TEST")
|
|
177
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
178
178
|
await ember_mug._write(
|
|
179
179
|
MugCharacteristic.MUG_NAME,
|
|
180
180
|
test_name,
|
|
@@ -190,7 +190,7 @@ async def test_write(mock_logger: Mock, ember_mug: MockMug) -> None:
|
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
ember_mug._client = AsyncMock()
|
|
193
|
-
ember_mug._client.write_gatt_char
|
|
193
|
+
ember_mug._client.write_gatt_char = AsyncMock(side_effect=BleakError)
|
|
194
194
|
with pytest.raises(BleakError):
|
|
195
195
|
await ember_mug._write(
|
|
196
196
|
MugCharacteristic.MUG_NAME,
|
|
@@ -219,9 +219,24 @@ def test_set_device(ember_mug: MockMug) -> None:
|
|
|
219
219
|
assert ember_mug.device.address == new_device.address
|
|
220
220
|
|
|
221
221
|
|
|
222
|
+
def test_can_write(ember_mug: MockMug) -> None:
|
|
223
|
+
ember_mug.data.udsk = "non-empty"
|
|
224
|
+
assert ember_mug.can_write is True
|
|
225
|
+
|
|
226
|
+
ember_mug.data.udsk = None
|
|
227
|
+
assert ember_mug.can_write is False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_has_attribute(ember_mug: MockMug) -> None:
|
|
231
|
+
ember_mug.data.model_info.model = DeviceModel.CUP_6_OZ
|
|
232
|
+
assert ember_mug.has_attribute("name") is False
|
|
233
|
+
ember_mug.data.model_info = ModelInfo(DeviceModel.MUG_2_10_OZ)
|
|
234
|
+
assert ember_mug.has_attribute("name") is True
|
|
235
|
+
|
|
236
|
+
|
|
222
237
|
async def test_get_mug_meta(ember_mug: MockMug) -> None:
|
|
223
238
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
224
|
-
ember_mug._client.read_gatt_char
|
|
239
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"Yw====-ABCDEFGHIJ")
|
|
225
240
|
meta = await ember_mug.get_meta()
|
|
226
241
|
assert meta.mug_id == "WXc9PT09"
|
|
227
242
|
assert meta.serial_number == "ABCDEFGHIJ"
|
|
@@ -230,7 +245,7 @@ async def test_get_mug_meta(ember_mug: MockMug) -> None:
|
|
|
230
245
|
|
|
231
246
|
async def test_get_mug_battery(ember_mug: MockMug) -> None:
|
|
232
247
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
233
|
-
ember_mug._client.read_gatt_char
|
|
248
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"5\x01")
|
|
234
249
|
battery = await ember_mug.get_battery()
|
|
235
250
|
assert battery.percent == 53.00
|
|
236
251
|
assert battery.on_charging_base is True
|
|
@@ -239,7 +254,7 @@ async def test_get_mug_battery(ember_mug: MockMug) -> None:
|
|
|
239
254
|
|
|
240
255
|
async def test_get_mug_led_colour(ember_mug: MockMug) -> None:
|
|
241
256
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
242
|
-
ember_mug._client.read_gatt_char
|
|
257
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\xf4\x00\xa1\xff")
|
|
243
258
|
colour = await ember_mug.get_led_colour()
|
|
244
259
|
assert colour.as_hex() == "#f400a1"
|
|
245
260
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.LED.uuid)
|
|
@@ -247,6 +262,7 @@ async def test_get_mug_led_colour(ember_mug: MockMug) -> None:
|
|
|
247
262
|
|
|
248
263
|
async def test_set_mug_led_colour(ember_mug: MockMug) -> None:
|
|
249
264
|
mock_ensure_connection = AsyncMock()
|
|
265
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
250
266
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
251
267
|
await ember_mug.set_led_colour(Colour(244, 0, 161))
|
|
252
268
|
mock_ensure_connection.assert_called_once()
|
|
@@ -257,8 +273,9 @@ async def test_set_mug_led_colour(ember_mug: MockMug) -> None:
|
|
|
257
273
|
|
|
258
274
|
|
|
259
275
|
async def test_set_volume_level_travel_mug(ember_mug: MockMug) -> None:
|
|
260
|
-
ember_mug.
|
|
276
|
+
ember_mug.data.model_info.model = DeviceModel.TRAVEL_MUG_12_OZ
|
|
261
277
|
mock_ensure_connection = AsyncMock()
|
|
278
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
262
279
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
263
280
|
await ember_mug.set_volume_level(VolumeLevel.HIGH)
|
|
264
281
|
mock_ensure_connection.assert_called_once()
|
|
@@ -288,13 +305,14 @@ async def test_set_volume_level_mug(ember_mug: MockMug) -> None:
|
|
|
288
305
|
|
|
289
306
|
async def test_get_mug_target_temp(ember_mug: MockMug) -> None:
|
|
290
307
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
291
|
-
ember_mug._client.read_gatt_char
|
|
308
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\xcd\x15")
|
|
292
309
|
assert (await ember_mug.get_target_temp()) == 55.81
|
|
293
310
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.TARGET_TEMPERATURE.uuid)
|
|
294
311
|
|
|
295
312
|
|
|
296
313
|
async def test_set_mug_target_temp(ember_mug: MockMug) -> None:
|
|
297
314
|
mock_ensure_connection = AsyncMock()
|
|
315
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
298
316
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
299
317
|
await ember_mug.set_target_temp(55.81)
|
|
300
318
|
mock_ensure_connection.assert_called_once()
|
|
@@ -306,37 +324,42 @@ async def test_set_mug_target_temp(ember_mug: MockMug) -> None:
|
|
|
306
324
|
|
|
307
325
|
async def test_get_mug_current_temp(ember_mug: MockMug) -> None:
|
|
308
326
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
309
|
-
ember_mug._client.read_gatt_char
|
|
327
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\xcd\x15")
|
|
310
328
|
assert (await ember_mug.get_current_temp()) == 55.81
|
|
311
329
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.CURRENT_TEMPERATURE.uuid)
|
|
312
330
|
|
|
313
331
|
|
|
314
332
|
async def test_get_mug_liquid_level(ember_mug: MockMug) -> None:
|
|
315
333
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
316
|
-
ember_mug._client.read_gatt_char
|
|
334
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\n")
|
|
317
335
|
assert (await ember_mug.get_liquid_level()) == 10
|
|
318
336
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.LIQUID_LEVEL.uuid)
|
|
319
337
|
|
|
320
338
|
|
|
321
339
|
async def test_get_mug_liquid_state(ember_mug: MockMug) -> None:
|
|
322
340
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
323
|
-
ember_mug._client.read_gatt_char
|
|
341
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\x06")
|
|
324
342
|
assert (await ember_mug.get_liquid_state()) == 6
|
|
325
343
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.LIQUID_STATE.uuid)
|
|
326
344
|
|
|
327
345
|
|
|
328
346
|
async def test_get_mug_name(ember_mug: MockMug) -> None:
|
|
329
347
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
330
|
-
ember_mug._client.read_gatt_char
|
|
348
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"Mug Name")
|
|
331
349
|
assert (await ember_mug.get_name()) == "Mug Name"
|
|
332
350
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.MUG_NAME.uuid)
|
|
333
351
|
|
|
352
|
+
ember_mug.data.model_info = ModelInfo(DeviceModel.CUP_6_OZ)
|
|
353
|
+
with pytest.raises(NotImplementedError):
|
|
354
|
+
await ember_mug.get_name()
|
|
355
|
+
|
|
334
356
|
|
|
335
357
|
async def test_set_mug_name(ember_mug: MockMug) -> None:
|
|
336
358
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()), pytest.raises(ValueError):
|
|
337
359
|
await ember_mug.set_name("Hé!")
|
|
338
360
|
|
|
339
361
|
mock_ensure_connection = AsyncMock()
|
|
362
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
340
363
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
341
364
|
await ember_mug.set_name("Mug name")
|
|
342
365
|
mock_ensure_connection.assert_called()
|
|
@@ -345,16 +368,21 @@ async def test_set_mug_name(ember_mug: MockMug) -> None:
|
|
|
345
368
|
bytearray(b"Mug name"),
|
|
346
369
|
)
|
|
347
370
|
|
|
371
|
+
ember_mug.data.model_info = ModelInfo(DeviceModel.CUP_6_OZ)
|
|
372
|
+
with pytest.raises(NotImplementedError):
|
|
373
|
+
await ember_mug.set_name("Test")
|
|
374
|
+
|
|
348
375
|
|
|
349
376
|
async def test_get_mug_udsk(ember_mug: MockMug) -> None:
|
|
350
377
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
351
|
-
ember_mug._client.read_gatt_char
|
|
378
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"abcd12345")
|
|
352
379
|
assert (await ember_mug.get_udsk()) == "YWJjZDEyMzQ1"
|
|
353
380
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.UDSK.uuid)
|
|
354
381
|
|
|
355
382
|
|
|
356
383
|
async def test_set_mug_udsk(ember_mug: MockMug) -> None:
|
|
357
384
|
mock_ensure_connection = AsyncMock()
|
|
385
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
358
386
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
359
387
|
await ember_mug.set_udsk("abcd12345")
|
|
360
388
|
mock_ensure_connection.assert_called_once()
|
|
@@ -366,26 +394,26 @@ async def test_set_mug_udsk(ember_mug: MockMug) -> None:
|
|
|
366
394
|
|
|
367
395
|
async def test_get_mug_dsk(ember_mug: MockMug) -> None:
|
|
368
396
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
369
|
-
ember_mug._client.read_gatt_char
|
|
397
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"abcd12345")
|
|
370
398
|
assert (await ember_mug.get_dsk()) == "YWJjZDEyMzQ1"
|
|
371
|
-
ember_mug._client.read_gatt_char
|
|
399
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"something else")
|
|
372
400
|
assert (await ember_mug.get_dsk()) == "c29tZXRoaW5nIGVsc2U="
|
|
373
401
|
ember_mug._client.read_gatt_char.assert_called_with(MugCharacteristic.DSK.uuid)
|
|
374
402
|
|
|
375
403
|
|
|
376
404
|
async def test_get_mug_temperature_unit(ember_mug: MockMug) -> None:
|
|
377
405
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
378
|
-
ember_mug._client.read_gatt_char
|
|
406
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\x01")
|
|
379
407
|
assert (await ember_mug.get_temperature_unit()) == TemperatureUnit.FAHRENHEIT
|
|
380
408
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.TEMPERATURE_UNIT.uuid)
|
|
381
|
-
ember_mug._client.read_gatt_char
|
|
382
|
-
ember_mug._client.read_gatt_char.return_value = b"\x00"
|
|
409
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\x00")
|
|
383
410
|
assert (await ember_mug.get_temperature_unit()) == TemperatureUnit.CELSIUS
|
|
384
411
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.TEMPERATURE_UNIT.uuid)
|
|
385
412
|
|
|
386
413
|
|
|
387
414
|
async def test_set_mug_temperature_unit(ember_mug: MockMug) -> None:
|
|
388
415
|
mock_ensure_connection = AsyncMock()
|
|
416
|
+
ember_mug._client.write_gatt_char = AsyncMock()
|
|
389
417
|
with patch.object(ember_mug, "_ensure_connection", mock_ensure_connection):
|
|
390
418
|
await ember_mug.set_temperature_unit(TemperatureUnit.CELSIUS)
|
|
391
419
|
mock_ensure_connection.assert_called_once()
|
|
@@ -400,7 +428,7 @@ async def test_mug_ensure_correct_unit(ember_mug: MockMug) -> None:
|
|
|
400
428
|
ember_mug.data.temperature_unit = TemperatureUnit.CELSIUS
|
|
401
429
|
ember_mug.data.use_metric = True
|
|
402
430
|
mock_set_temp = AsyncMock(return_value=None)
|
|
403
|
-
with patch.object(ember_mug,
|
|
431
|
+
with patch.object(ember_mug, "set_temperature_unit", mock_set_temp):
|
|
404
432
|
await ember_mug.ensure_correct_unit()
|
|
405
433
|
mock_set_temp.assert_not_called()
|
|
406
434
|
ember_mug.data.temperature_unit = TemperatureUnit.FAHRENHEIT
|
|
@@ -410,14 +438,14 @@ async def test_mug_ensure_correct_unit(ember_mug: MockMug) -> None:
|
|
|
410
438
|
|
|
411
439
|
async def test_get_mug_battery_voltage(ember_mug: MockMug) -> None:
|
|
412
440
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
413
|
-
ember_mug._client.read_gatt_char
|
|
441
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"\x01")
|
|
414
442
|
assert (await ember_mug.get_battery_voltage()) == 1
|
|
415
443
|
ember_mug._client.read_gatt_char.assert_called_once_with(MugCharacteristic.CONTROL_REGISTER_DATA.uuid)
|
|
416
444
|
|
|
417
445
|
|
|
418
446
|
async def test_get_mug_date_time_zone(ember_mug: MockMug) -> None:
|
|
419
447
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
420
|
-
ember_mug._client.read_gatt_char
|
|
448
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"c\x0f\xf6\x00")
|
|
421
449
|
date_time = await ember_mug.get_date_time_zone()
|
|
422
450
|
assert isinstance(date_time, datetime)
|
|
423
451
|
assert date_time.timestamp() == 1661990400.0
|
|
@@ -426,7 +454,7 @@ async def test_get_mug_date_time_zone(ember_mug: MockMug) -> None:
|
|
|
426
454
|
|
|
427
455
|
async def test_read_firmware(ember_mug: MockMug) -> None:
|
|
428
456
|
with patch.object(ember_mug, "_ensure_connection", AsyncMock()):
|
|
429
|
-
ember_mug._client.read_gatt_char
|
|
457
|
+
ember_mug._client.read_gatt_char = AsyncMock(return_value=b"c\x01\x80\x00\x12\x00")
|
|
430
458
|
firmware = await ember_mug.get_firmware()
|
|
431
459
|
assert firmware.version == 355
|
|
432
460
|
assert firmware.hardware == 128
|
|
@@ -453,8 +481,8 @@ async def test_mug_update_multiple(ember_mug: MockMug) -> None:
|
|
|
453
481
|
mock_get_name = AsyncMock(return_value="name")
|
|
454
482
|
mock_update_info = AsyncMock()
|
|
455
483
|
|
|
456
|
-
with patch.multiple(ember_mug,
|
|
457
|
-
with patch.object(ember_mug.data,
|
|
484
|
+
with patch.multiple(ember_mug, get_name=mock_get_name):
|
|
485
|
+
with patch.object(ember_mug.data, "update_info", mock_update_info):
|
|
458
486
|
await ember_mug._update_multiple({"name"})
|
|
459
487
|
mock_get_name.assert_called_once()
|
|
460
488
|
mock_update_info.assert_called_once_with(name="name")
|
|
@@ -464,10 +492,10 @@ async def test_mug_update_queued_attributes(ember_mug: MockMug) -> None:
|
|
|
464
492
|
mock_get_name = AsyncMock(return_value="name")
|
|
465
493
|
mock_update_info = AsyncMock()
|
|
466
494
|
|
|
467
|
-
with patch.multiple(ember_mug,
|
|
495
|
+
with patch.multiple(ember_mug, get_name=mock_get_name):
|
|
468
496
|
ember_mug._queued_updates = set()
|
|
469
497
|
assert (await ember_mug.update_queued_attributes()) == []
|
|
470
|
-
with patch.object(ember_mug.data,
|
|
498
|
+
with patch.object(ember_mug.data, "update_info", mock_update_info):
|
|
471
499
|
ember_mug._queued_updates = {"name"}
|
|
472
500
|
await ember_mug.update_queued_attributes()
|
|
473
501
|
mock_update_info.assert_called_once_with(name="name")
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Tests for `ember_mug.utils`."""
|
|
2
|
-
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, Mock, call, patch, PropertyMock
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
|
-
from bleak import AdvertisementData
|
|
5
|
+
from bleak import AdvertisementData, BleakError
|
|
6
6
|
|
|
7
7
|
from ember_mug.consts import DeviceModel, DeviceColour, MugCharacteristic
|
|
8
8
|
from ember_mug.utils import (
|
|
@@ -16,11 +16,14 @@ from ember_mug.utils import (
|
|
|
16
16
|
get_colour_from_int,
|
|
17
17
|
get_model_from_single_int_and_services,
|
|
18
18
|
get_model_from_id_and_gen,
|
|
19
|
+
guess_model_from_name,
|
|
20
|
+
)
|
|
21
|
+
from tests.conftest import (
|
|
22
|
+
TEST_TUMBLER_ADVERTISEMENT,
|
|
23
|
+
TEST_MUG_ADVERTISEMENT,
|
|
24
|
+
TEST_TRAVEL_MUG_ADVERTISEMENT,
|
|
25
|
+
TEST_UNKNOWN_ADVERTISEMENT,
|
|
19
26
|
)
|
|
20
|
-
from tests.conftest import TEST_TUMBLER_ADVERTISEMENT, TEST_MUG_ADVERTISEMENT, TEST_TRAVEL_MUG_ADVERTISEMENT
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
# 131, 147->152, 150-151
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def test_bytes_to_little_int() -> None:
|
|
@@ -71,6 +74,7 @@ def test_get_colour_from_int(colour_id: int, expected_colour: DeviceColour | Non
|
|
|
71
74
|
(65, [], DeviceModel.MUG_1_14_OZ),
|
|
72
75
|
(-127, [], DeviceModel.MUG_2_10_OZ),
|
|
73
76
|
(-63, [], DeviceModel.MUG_2_14_OZ),
|
|
77
|
+
(-60, [], DeviceModel.CUP_6_OZ),
|
|
74
78
|
(0, [], None),
|
|
75
79
|
],
|
|
76
80
|
)
|
|
@@ -82,6 +86,23 @@ def test_get_model_from_single_int_and_services(
|
|
|
82
86
|
assert get_model_from_single_int_and_services(model_id, service_uuids) == expected_model
|
|
83
87
|
|
|
84
88
|
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"model_name,expected_model",
|
|
91
|
+
[
|
|
92
|
+
("", None),
|
|
93
|
+
("Test", DeviceModel.UNKNOWN_DEVICE),
|
|
94
|
+
("Ember Ceramic Mug", DeviceModel.UNKNOWN_DEVICE),
|
|
95
|
+
("Ember Cup", DeviceModel.CUP_6_OZ),
|
|
96
|
+
("Ember Travel Mug", DeviceModel.TRAVEL_MUG_12_OZ),
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
def test_guess_model_from_name(
|
|
100
|
+
model_name: str,
|
|
101
|
+
expected_model: DeviceModel | None,
|
|
102
|
+
) -> None:
|
|
103
|
+
assert guess_model_from_name(model_name) == expected_model
|
|
104
|
+
|
|
105
|
+
|
|
85
106
|
@pytest.mark.parametrize(
|
|
86
107
|
"model_id,generation,expected_model",
|
|
87
108
|
[
|
|
@@ -106,6 +127,7 @@ def test_get_model_from_id_and_gen(
|
|
|
106
127
|
@pytest.mark.parametrize(
|
|
107
128
|
"advertisement,expected_model,expected_colour",
|
|
108
129
|
[
|
|
130
|
+
(TEST_UNKNOWN_ADVERTISEMENT, DeviceModel.UNKNOWN_DEVICE, None),
|
|
109
131
|
(TEST_MUG_ADVERTISEMENT, DeviceModel.MUG_2_10_OZ, DeviceColour.BLACK),
|
|
110
132
|
(TEST_TUMBLER_ADVERTISEMENT, DeviceModel.TUMBLER_16_OZ, DeviceColour.BLACK),
|
|
111
133
|
(TEST_TRAVEL_MUG_ADVERTISEMENT, DeviceModel.TRAVEL_MUG_12_OZ, DeviceColour.RED),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|