python-ember-mug 1.4.0b2__tar.gz → 1.5.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-ember-mug
3
- Version: 1.4.0b2
3
+ Version: 1.5.0
4
4
  Summary: Python Library for Ember Mugs.
5
5
  Project-URL: Changelog, https://sopelj.github.io/python-ember-mug/changelog/
6
6
  Project-URL: Documentation, https://sopelj.github.io/python-ember-mug/
@@ -121,7 +121,7 @@ await mug.disconnect()
121
121
  # You can also use connection as a context manager
122
122
  # if you want to ensure connection before starting and cleanup on exit
123
123
  async with mug.connection():
124
- print('Connected.\nFetching Info')
124
+ print("Connected.\nFetching Info")
125
125
  await mug.update_all()
126
126
  print(mug.data.formatted)
127
127
  ```
@@ -80,7 +80,7 @@ await mug.disconnect()
80
80
  # You can also use connection as a context manager
81
81
  # if you want to ensure connection before starting and cleanup on exit
82
82
  async with mug.connection():
83
- print('Connected.\nFetching Info')
83
+ print("Connected.\nFetching Info")
84
84
  await mug.update_all()
85
85
  print(mug.data.formatted)
86
86
  ```
@@ -6,4 +6,4 @@ __all__ = ("EmberMug",)
6
6
 
7
7
  __author__ = """Jesse Sopel"""
8
8
  __email__ = "jesse@sopelj.ca"
9
- __version__ = "1.4.0b2"
9
+ __version__ = "1.5.0"
@@ -8,10 +8,11 @@ import logging
8
8
  import re
9
9
  import sys
10
10
  from argparse import ArgumentParser, ArgumentTypeError, Namespace
11
+ from datetime import datetime
11
12
  from pathlib import Path
12
13
  from typing import IO, TYPE_CHECKING, ClassVar
13
14
 
14
- from bleak import BleakError
15
+ from bleak.exc import BleakError
15
16
 
16
17
  from ember_mug.consts import ATTR_LABELS, EMBER_BLE_SIG, EXTRA_ATTRS, IS_LINUX, TESTING_BLE_SIG, VolumeLevel
17
18
  from ember_mug.data import Colour, DeviceModel
@@ -26,11 +27,12 @@ from .helpers import CommandLoop, print_changes, print_info, print_table, valida
26
27
  if TYPE_CHECKING:
27
28
  from collections.abc import Awaitable, Callable
28
29
 
29
- from bleak import AdvertisementData
30
30
  from bleak.backends.device import BLEDevice
31
+ from bleak.backends.scanner import AdvertisementData
31
32
 
32
33
  all_attrs = list(ATTR_LABELS) + list(EXTRA_ATTRS)
33
34
  get_attribute_names = [n.replace("_", "-") for n in all_attrs]
35
+ SetValues = str | float | Colour | datetime
34
36
 
35
37
 
36
38
  async def get_device(args: Namespace) -> EmberMug:
@@ -86,7 +88,7 @@ async def discover_cmd(args: Namespace) -> list[tuple[BLEDevice, AdvertisementDa
86
88
  model_info = get_model_info_from_advertiser_data(advertisement)
87
89
  model_number = model_info.model.value if model_info.model else "Unknown Model"
88
90
  print(f"Found {model_info.device_type.value}:", mug)
89
- print("Name:", advertisement.local_name)
91
+ print("Name:", advertisement.local_name or "Ember Mug")
90
92
  print("Model:", f"{model_info.name} [{model_number}]")
91
93
  print("Colour:", model_info.colour.value if model_info.colour else "Unknown")
92
94
  print("Capacity:", format_capacity(model_info.capacity))
@@ -143,8 +145,10 @@ async def get_device_value_cmd(args: Namespace) -> None:
143
145
 
144
146
  async def set_device_value_cmd(args: Namespace) -> None:
145
147
  """Set one or more values on the device."""
146
- attrs = ("name", "target_temp", "temperature_unit", "led_colour", "volume_level")
147
- values = [(attr, value) for attr in attrs if (value := getattr(args, attr, None))]
148
+ attrs = ("name", "target_temp", "temperature_unit", "led_colour", "volume_level", "date_time_zone")
149
+ values: list[tuple[str, SetValues | None]] = [
150
+ (attr, value) for attr in attrs if (value := getattr(args, attr, None))
151
+ ]
148
152
  if not values:
149
153
  print("Please specify at least one attribute and value to set.")
150
154
  options = [f"--{a.replace('_', '-')}" for a in attrs]
@@ -186,6 +190,14 @@ def colour_type(value: str) -> Colour:
186
190
  raise ArgumentTypeError(msg)
187
191
 
188
192
 
193
+ def dt_zone_type(value: str) -> datetime:
194
+ """Convert date/time string into datetime object."""
195
+ try:
196
+ return datetime.fromisoformat(value)
197
+ except ValueError as e:
198
+ raise ArgumentTypeError(f'"{value}" is not a valid ISO datetime string') from e
199
+
200
+
189
201
  class EmberMugCli:
190
202
  """Very simple CLI Interface to interact with a mug."""
191
203
 
@@ -246,6 +258,7 @@ class EmberMugCli:
246
258
  set_parser.add_argument("--target-temp", help="Target Temperature", type=float, required=False)
247
259
  set_parser.add_argument("--temperature-unit", help="Temperature Unit", choices=["C", "F"], required=False)
248
260
  set_parser.add_argument("--led-colour", help="LED Colour", type=colour_type, required=False)
261
+ set_parser.add_argument("--date-time-zone", help="Internal Date/Time+Zone", type=dt_zone_type, required=False)
249
262
  set_parser.add_argument(
250
263
  "--volume-level",
251
264
  help="Volume Level",
@@ -98,9 +98,9 @@ class MugCharacteristic(IntEnum):
98
98
  TARGET_TEMPERATURE = 3
99
99
  # Unit (0 -> Celsius, 1 -> Fahrenheit) (Read/Write)
100
100
  TEMPERATURE_UNIT = 4
101
- # Level (Between 0 -> 30 ?) 30 100% ?
101
+ # Level (Between 0 -> 30 for mug and 0-100 for travel mug
102
102
  LIQUID_LEVEL = 5
103
- # Current date and time zone? (Read/Write)
103
+ # Current date and time zone (Write only)
104
104
  DATE_TIME_AND_ZONE = 6
105
105
  # Battery Info (Read)
106
106
  BATTERY = 7
@@ -76,18 +76,29 @@ class BatteryInfo(AsDict):
76
76
 
77
77
  percent: float
78
78
  on_charging_base: bool
79
+ temperature: float | None = None
80
+ """Battery temperature in °C (bytes 2-3 of the characteristic), or None if the mug did not report one."""
79
81
 
80
82
  @classmethod
81
83
  def from_bytes(cls, data: bytes) -> BatteryInfo:
82
84
  """Initialize from raw bytes."""
85
+ temperature: float | None = None
86
+ if len(data) >= 4:
87
+ raw = bytes_to_little_int(data[2:4])
88
+ if 0 < raw < 10000:
89
+ temperature = round(raw * 0.01, 2)
83
90
  return cls(
84
91
  percent=round(float(data[0]), 2),
85
92
  on_charging_base=data[1] == 1,
93
+ temperature=temperature,
86
94
  )
87
95
 
88
96
  def __str__(self) -> str:
89
97
  """Format nicely for printing."""
90
- return f"{self.percent}%, {'' if self.on_charging_base else 'not '}on charging base"
98
+ text = f"{self.percent}%, {'' if self.on_charging_base else 'not '}on charging base"
99
+ if self.temperature is not None:
100
+ text += f", {self.temperature:.1f}°C"
101
+ return text
91
102
 
92
103
 
93
104
  @dataclass
@@ -12,7 +12,8 @@ from functools import cached_property
12
12
  from time import time
13
13
  from typing import TYPE_CHECKING, Any, Concatenate, Literal, ParamSpec, TypeVar
14
14
 
15
- from bleak import AdvertisementData, BleakClient, BleakError
15
+ from bleak import BleakClient
16
+ from bleak.exc import BleakError
16
17
  from bleak_retry_connector import establish_connection
17
18
 
18
19
  from .consts import (
@@ -34,8 +35,10 @@ from .utils import (
34
35
  convert_temp_to_celsius,
35
36
  convert_temp_to_fahrenheit,
36
37
  decode_byte_string,
38
+ decode_date_time,
37
39
  discover_services,
38
40
  encode_byte_string,
41
+ encode_date_time,
39
42
  get_model_info_from_advertiser_data,
40
43
  temp_from_bytes,
41
44
  )
@@ -45,6 +48,7 @@ if TYPE_CHECKING:
45
48
 
46
49
  from bleak.backends.characteristic import BleakGATTCharacteristic
47
50
  from bleak.backends.device import BLEDevice
51
+ from bleak.backends.scanner import AdvertisementData
48
52
 
49
53
  TempUnitType = Literal["°C", "°F"] | TemperatureUnit | StrEnum
50
54
 
@@ -414,8 +418,16 @@ class EmberMug:
414
418
  async def get_date_time_zone(self) -> datetime | None:
415
419
  """Get date and time zone."""
416
420
  date_time_zone_bytes = await self._read(MugCharacteristic.DATE_TIME_AND_ZONE)
417
- time_value = bytes_to_big_int(date_time_zone_bytes[:4])
418
- return datetime.fromtimestamp(time_value, UTC) if time_value > 0 else None
421
+ return decode_date_time(date_time_zone_bytes)
422
+
423
+ async def set_date_time_zone(self, date_time: datetime | None = None) -> None:
424
+ """Get date and time zone."""
425
+ if not date_time:
426
+ date_time = datetime.now()
427
+ if date_time.tzinfo is None:
428
+ date_time = date_time.replace(tzinfo=UTC)
429
+ encoded_date = encode_date_time(date_time)
430
+ await self._write(MugCharacteristic.DATE_TIME_AND_ZONE, bytearray(encoded_date))
419
431
 
420
432
  async def get_firmware(self) -> MugFirmwareInfo:
421
433
  """Get firmware info."""
@@ -435,9 +447,19 @@ class EmberMug:
435
447
  """Update a list of attributes from the mug."""
436
448
  logger.debug("Updating the following attributes: %s", attrs)
437
449
  await self._ensure_connection()
438
- changes = self.data.update_info(**{attr: await getattr(self, f"get_{attr}")() for attr in attrs})
450
+ updates = {}
451
+ failed_attrs = set()
452
+ for attr in attrs:
453
+ try:
454
+ updates[attr] = await getattr(self, f"get_{attr}")()
455
+ except BleakError as e:
456
+ failed_attrs |= {attr}
457
+ logger.debug("Failed to fetch %s: %s", attr, e)
458
+ changes = self.data.update_info(**updates)
439
459
  if changes:
440
460
  self._fire_callbacks()
461
+ if failed_attrs:
462
+ logger.warning("Failed to fetch some attributes: %s", failed_attrs)
441
463
  logger.debug("Attributes updated: %s", changes)
442
464
  return changes
443
465
 
@@ -457,15 +479,16 @@ class EmberMug:
457
479
  def _notify_callback(self, characteristic: BleakGATTCharacteristic, data: bytearray) -> None:
458
480
  """Push events from the mug to indicate changes."""
459
481
  event_id = data[0]
460
- now = time()
461
- if (last_time := self._latest_events.get(event_id)) and now - last_time < 5:
462
- return
463
- self._latest_events[event_id] = now
464
482
 
465
483
  if characteristic.uuid == MugCharacteristic.STATISTICS.uuid:
466
484
  logger.info("Statistics received from %s (%s) - Data: %s.", self.model_name, event_id, data)
467
485
  return
468
486
 
487
+ now = time()
488
+ if (last_time := self._latest_events.get(event_id)) and now - last_time < 5:
489
+ return
490
+ self._latest_events[event_id] = now
491
+
469
492
  logger.debug("Push event received from %s (%s) - Data: %s.", self.model_name, event_id, data)
470
493
 
471
494
  # Check known IDs
@@ -478,6 +501,7 @@ class EmberMug:
478
501
  self.data.battery = BatteryInfo(
479
502
  percent=self.data.battery.percent if self.data.battery else 0,
480
503
  on_charging_base=event_id == PushEvent.CHARGER_CONNECTED,
504
+ temperature=self.data.battery.temperature if self.data.battery else None,
481
505
  )
482
506
  self._fire_callbacks()
483
507
  # All indicate changes in battery
@@ -14,7 +14,7 @@ from .consts import DEVICE_SERVICE_UUIDS, IS_LINUX
14
14
  if TYPE_CHECKING:
15
15
  from typing import NotRequired, TypedDict
16
16
 
17
- from bleak import BlueZScannerArgs
17
+ from bleak.args.bluez import BlueZScannerArgs
18
18
  from bleak.backends.device import BLEDevice
19
19
  from bleak.backends.scanner import AdvertisementData
20
20
 
@@ -6,14 +6,16 @@ import base64
6
6
  import contextlib
7
7
  import logging
8
8
  import re
9
+ from datetime import UTC, datetime, timedelta, timezone
9
10
  from typing import TYPE_CHECKING, Any
10
11
 
11
- from bleak import AdvertisementData, BleakError
12
+ from bleak.exc import BleakError
12
13
 
13
14
  from ember_mug.consts import EMBER_BLE_SIG, TESTING_BLE_SIG, TRAVEL_MUG_SERVICE_UUIDS, DeviceColour, DeviceModel
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from bleak import BleakClient
18
+ from bleak.backends.scanner import AdvertisementData
17
19
 
18
20
  from ember_mug.data import ModelInfo
19
21
 
@@ -61,6 +63,30 @@ def temp_from_bytes(temp_bytes: bytearray) -> float:
61
63
  return float(bytes_to_little_int(temp_bytes)) * 0.01
62
64
 
63
65
 
66
+ def encode_date_time(date_time: datetime) -> bytes:
67
+ """Encode datetime with timezone as the mug expects."""
68
+ offset_hours = 0
69
+ if offset := date_time.utcoffset():
70
+ offset_hours = int(offset.total_seconds() / 60 / 60)
71
+
72
+ timestamp = int(date_time.timestamp())
73
+ timestamp_bytes = timestamp.to_bytes(4, byteorder="little", signed=False)
74
+ offset_byte = offset_hours.to_bytes(1, byteorder="big", signed=True)
75
+
76
+ return timestamp_bytes + offset_byte
77
+
78
+
79
+ def decode_date_time(data: bytes | bytearray) -> datetime:
80
+ """Extract date time with timezone from bytes."""
81
+ timestamp = int.from_bytes(data[:4], "little", signed=False)
82
+ offset_hours = int.from_bytes(data[4:5], "little", signed=True)
83
+
84
+ utc_datetime = datetime.fromtimestamp(timestamp, UTC)
85
+ local_timezone = timezone(timedelta(hours=offset_hours))
86
+
87
+ return utc_datetime.astimezone(local_timezone)
88
+
89
+
64
90
  def get_colour_from_int(colour_id: int) -> DeviceColour | None: # noqa: PLR0911
65
91
  """Extrapolate device colour from integer in advertiser data."""
66
92
  if colour_id in (-127, -63, 1, 14, 65):