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.
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/PKG-INFO +3 -3
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/README.md +1 -1
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/__init__.py +1 -1
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/cli/commands.py +18 -5
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/consts.py +2 -2
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/data.py +12 -1
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/mug.py +32 -8
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/scanner.py +1 -1
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/utils.py +27 -1
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/.gitignore +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/LICENSE +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/__main__.py +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/cli/__init__.py +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/cli/helpers.py +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/formatting.py +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/ember_mug/py.typed +0 -0
- {python_ember_mug-1.4.0b2 → python_ember_mug-1.5.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-ember-mug
|
|
3
|
-
Version: 1.
|
|
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(
|
|
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(
|
|
83
|
+
print("Connected.\nFetching Info")
|
|
84
84
|
await mug.update_all()
|
|
85
85
|
print(mug.data.formatted)
|
|
86
86
|
```
|
|
@@ -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
|
|
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
|
|
101
|
+
# Level (Between 0 -> 30 for mug and 0-100 for travel mug
|
|
102
102
|
LIQUID_LEVEL = 5
|
|
103
|
-
# Current date and time zone
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|