python-ember-mug 1.4.0b3__tar.gz → 1.5.1__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.0b3
3
+ Version: 1.5.1
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.0b3"
9
+ __version__ = "1.5.1"
@@ -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."""
@@ -437,17 +449,25 @@ class EmberMug:
437
449
  await self._ensure_connection()
438
450
  updates = {}
439
451
  failed_attrs = set()
452
+ last_error = None
440
453
  for attr in attrs:
441
454
  try:
442
455
  updates[attr] = await getattr(self, f"get_{attr}")()
443
456
  except BleakError as e:
444
457
  failed_attrs |= {attr}
458
+ last_error = e
445
459
  logger.debug("Failed to fetch %s: %s", attr, e)
460
+
461
+ if len(failed_attrs) == len(attrs) and last_error:
462
+ raise last_error
463
+
446
464
  changes = self.data.update_info(**updates)
447
465
  if changes:
448
466
  self._fire_callbacks()
467
+
449
468
  if failed_attrs:
450
469
  logger.warning("Failed to fetch some attributes: %s", failed_attrs)
470
+
451
471
  logger.debug("Attributes updated: %s", changes)
452
472
  return changes
453
473
 
@@ -467,15 +487,16 @@ class EmberMug:
467
487
  def _notify_callback(self, characteristic: BleakGATTCharacteristic, data: bytearray) -> None:
468
488
  """Push events from the mug to indicate changes."""
469
489
  event_id = data[0]
470
- now = time()
471
- if (last_time := self._latest_events.get(event_id)) and now - last_time < 5:
472
- return
473
- self._latest_events[event_id] = now
474
490
 
475
491
  if characteristic.uuid == MugCharacteristic.STATISTICS.uuid:
476
492
  logger.info("Statistics received from %s (%s) - Data: %s.", self.model_name, event_id, data)
477
493
  return
478
494
 
495
+ now = time()
496
+ if (last_time := self._latest_events.get(event_id)) and now - last_time < 5:
497
+ return
498
+ self._latest_events[event_id] = now
499
+
479
500
  logger.debug("Push event received from %s (%s) - Data: %s.", self.model_name, event_id, data)
480
501
 
481
502
  # Check known IDs
@@ -488,6 +509,7 @@ class EmberMug:
488
509
  self.data.battery = BatteryInfo(
489
510
  percent=self.data.battery.percent if self.data.battery else 0,
490
511
  on_charging_base=event_id == PushEvent.CHARGER_CONNECTED,
512
+ temperature=self.data.battery.temperature if self.data.battery else None,
491
513
  )
492
514
  self._fire_callbacks()
493
515
  # 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):