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.
Files changed (28) hide show
  1. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/PKG-INFO +1 -1
  2. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/__init__.py +1 -1
  3. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/consts.py +1 -0
  4. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/mug.py +42 -26
  5. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/utils.py +15 -1
  6. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/test_commands.py +1 -1
  7. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/conftest.py +9 -4
  8. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_connection.py +55 -27
  9. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_utils.py +28 -6
  10. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/.gitignore +0 -0
  11. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/LICENSE +0 -0
  12. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/README.md +0 -0
  13. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/__main__.py +0 -0
  14. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/__init__.py +0 -0
  15. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/commands.py +0 -0
  16. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/cli/helpers.py +0 -0
  17. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/data.py +0 -0
  18. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/formatting.py +0 -0
  19. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/ember_mug/scanner.py +0 -0
  20. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/pyproject.toml +0 -0
  21. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/__init__.py +0 -0
  22. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/__init__.py +0 -0
  23. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/cli/test_helpers.py +0 -0
  24. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_consts.py +0 -0
  25. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_data.py +0 -0
  26. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_formatting.py +0 -0
  27. {python_ember_mug-0.9.0b1 → python_ember_mug-0.9.0b2}/tests/test_mug_data.py +0 -0
  28. {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.0b1
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/
@@ -5,4 +5,4 @@ __all__ = ("EmberMug",)
5
5
 
6
6
  __author__ = """Jesse Sopel"""
7
7
  __email__ = "jesse.sopel@gmail.com"
8
- __version__ = "0.9.0b1"
8
+ __version__ = "0.9.0b2"
@@ -35,6 +35,7 @@ class DeviceModel(str, Enum):
35
35
  MUG_2_14_OZ = "CM19P" # or CM21L?
36
36
  TRAVEL_MUG_12_OZ = "TM19"
37
37
  TUMBLER_16_OZ = "CM21XL"
38
+ UNKNOWN_DEVICE = "Unknown Mug or Tumbler"
38
39
 
39
40
 
40
41
  class DeviceColour(str, Enum):
@@ -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
- return ModelInfo()
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 tests.conftest import TEST_MAC, mock_connection, TEST_MUG_ADVERTISEMENT
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 DEFAULT_NAME, EMBER_BLE_SIG, MugCharacteristic, DeviceModel, DeviceColour
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 = TestManufacturerData.UNKNOWN,
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={EMBER_BLE_SIG: 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 = AsyncMock()
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.return_value = b"TEST"
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.side_effect = BleakError
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.return_value = b"Yw====-ABCDEFGHIJ"
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.return_value = b"5\x01"
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.return_value = b"\xf4\x00\xa1\xff"
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.is_travel_mug = True
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.return_value = b"\xcd\x15"
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.return_value = b"\xcd\x15"
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.return_value = b"\n"
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.return_value = b"\x06"
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.return_value = b"Mug Name"
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.return_value = b"abcd12345"
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.return_value = b"abcd12345"
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.return_value = b"something else"
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.return_value = b"\x01"
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.reset_mock()
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, 'set_temperature_unit', mock_set_temp):
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.return_value = b"\x01"
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.return_value = b"c\x0f\xf6\x00"
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.return_value = b"c\x01\x80\x00\x12\x00"
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, _ensure_connection=AsyncMock(), get_name=mock_get_name):
457
- with patch.object(ember_mug.data, 'update_info', mock_update_info):
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, _ensure_connection=AsyncMock(), get_name=mock_get_name):
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, 'update_info', mock_update_info):
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),