python-ember-mug 1.3.0b4__tar.gz → 1.3.0b6__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
1
  Metadata-Version: 2.4
2
2
  Name: python-ember-mug
3
- Version: 1.3.0b4
3
+ Version: 1.3.0b6
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/
@@ -16,9 +16,10 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
19
20
  Requires-Python: >=3.11
20
21
  Requires-Dist: bleak-retry-connector>=4.0.2
21
- Requires-Dist: bleak>=1.0.1
22
+ Requires-Dist: bleak<2.2.0,>=1.0.1
22
23
  Provides-Extra: dev
23
24
  Requires-Dist: ipython; extra == 'dev'
24
25
  Provides-Extra: docs
@@ -44,7 +45,7 @@ Description-Content-Type: text/markdown
44
45
  [![python](https://img.shields.io/pypi/pyversions/python-ember-mug.svg)](https://pypi.org/project/python-ember-mug/)
45
46
  [![Build Status](https://github.com/sopelj/python-ember-mug/actions/workflows/tests.yml/badge.svg)](https://github.com/sopelj/python-ember-mug/actions/workflows/tests.yml)
46
47
  [![codecov](https://codecov.io/gh/sopelj/python-ember-mug/graph/badge.svg?token=2Lw2iVjKsG)](https://codecov.io/gh/sopelj/python-ember-mug)
47
- ![Project Maintenance](https://img.shields.io/maintenance/yes/2025.svg)
48
+ ![Project Maintenance](https://img.shields.io/maintenance/yes/2026.svg)
48
49
  [![Maintainer](https://img.shields.io/badge/maintainer-%40sopelj-blue.svg)](https://github.com/sopelj)
49
50
  [![License](https://img.shields.io/github/license/sopelj/python-ember-mug.svg)](https://github.com/sopelj/python-ember-mug/blob/main/LICENSE)
50
51
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen)](https://github.com/pre-commit/pre-commit)
@@ -177,8 +178,6 @@ Device Data
177
178
  +--------------+----------------------+
178
179
  | Target Temp | 55.00°C |
179
180
  +--------------+----------------------+
180
- | Use Metric | True |
181
- +--------------+----------------------+
182
181
 
183
182
  Watching for changes
184
183
  Current Temp changed from "24.50°C" to "25.50°"
@@ -4,7 +4,7 @@
4
4
  [![python](https://img.shields.io/pypi/pyversions/python-ember-mug.svg)](https://pypi.org/project/python-ember-mug/)
5
5
  [![Build Status](https://github.com/sopelj/python-ember-mug/actions/workflows/tests.yml/badge.svg)](https://github.com/sopelj/python-ember-mug/actions/workflows/tests.yml)
6
6
  [![codecov](https://codecov.io/gh/sopelj/python-ember-mug/graph/badge.svg?token=2Lw2iVjKsG)](https://codecov.io/gh/sopelj/python-ember-mug)
7
- ![Project Maintenance](https://img.shields.io/maintenance/yes/2025.svg)
7
+ ![Project Maintenance](https://img.shields.io/maintenance/yes/2026.svg)
8
8
  [![Maintainer](https://img.shields.io/badge/maintainer-%40sopelj-blue.svg)](https://github.com/sopelj)
9
9
  [![License](https://img.shields.io/github/license/sopelj/python-ember-mug.svg)](https://github.com/sopelj/python-ember-mug/blob/main/LICENSE)
10
10
  [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen)](https://github.com/pre-commit/pre-commit)
@@ -137,8 +137,6 @@ Device Data
137
137
  +--------------+----------------------+
138
138
  | Target Temp | 55.00°C |
139
139
  +--------------+----------------------+
140
- | Use Metric | True |
141
- +--------------+----------------------+
142
140
 
143
141
  Watching for changes
144
142
  Current Temp changed from "24.50°C" to "25.50°"
@@ -6,4 +6,4 @@ __all__ = ("EmberMug",)
6
6
 
7
7
  __author__ = """Jesse Sopel"""
8
8
  __email__ = "jesse.sopel@gmail.com"
9
- __version__ = "1.3.0b4"
9
+ __version__ = "1.3.0b6"
@@ -112,9 +112,9 @@ async def poll_device_cmd(args: Namespace) -> None:
112
112
  for _ in CommandLoop():
113
113
  for _ in range(60):
114
114
  await asyncio.sleep(1)
115
- print_changes(await mug.update_queued_attributes(), mug.data.use_metric)
115
+ print_changes(await mug.update_queued_attributes(), mug.data.user_unit)
116
116
  # Every minute do a full update
117
- print_changes(await mug.update_all(), mug.data.use_metric)
117
+ print_changes(await mug.update_all(), mug.data.user_unit)
118
118
 
119
119
 
120
120
  async def get_device_value_cmd(args: Namespace) -> None:
@@ -2,13 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
6
5
  from argparse import ArgumentTypeError
7
6
  from collections import defaultdict
8
7
  from functools import partial
9
8
  from typing import TYPE_CHECKING
10
9
 
11
- from ember_mug.consts import MAC_ADDRESS_REGEX
10
+ from ember_mug.consts import IS_MACOS, MAC_ADDRESS_REGEX, MAC_UUID_REGEX, TemperatureUnit
12
11
  from ember_mug.data import Change
13
12
  from ember_mug.formatting import format_led_colour, format_liquid_level, format_temp
14
13
 
@@ -26,7 +25,8 @@ base_formatters: dict[str, Callable] = {
26
25
 
27
26
  def validate_mac(value: str) -> str:
28
27
  """Check if specified MAC Address is valid."""
29
- if not isinstance(value, str) or not re.match(MAC_ADDRESS_REGEX, value):
28
+ mac_reg = MAC_UUID_REGEX if IS_MACOS else MAC_ADDRESS_REGEX
29
+ if not isinstance(value, str) or not mac_reg.match(value):
30
30
  raise ArgumentTypeError("Invalid MAC Address")
31
31
  return value.lower()
32
32
 
@@ -62,11 +62,11 @@ def print_info(mug: EmberMug) -> None:
62
62
  print_table(list(mug.data.formatted.items()))
63
63
 
64
64
 
65
- def print_changes(changes: list[Change], metric: bool = True) -> None:
65
+ def print_changes(changes: list[Change], unit: TemperatureUnit | None) -> None:
66
66
  """Print changes."""
67
67
  formatters: dict[str, Callable] = {
68
- "current_temp": partial(format_temp, metric=metric),
69
- "target_temp": partial(format_temp, metric=metric),
68
+ "current_temp": partial(format_temp, unit=unit),
69
+ "target_temp": partial(format_temp, unit=unit),
70
70
  **base_formatters,
71
71
  }
72
72
  for attr, old_value, new_value in changes:
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import platform
6
6
  import re
7
- from enum import Enum, IntEnum
7
+ from enum import IntEnum, StrEnum
8
8
  from functools import cached_property
9
9
  from typing import NamedTuple
10
10
  from uuid import UUID
@@ -17,7 +17,7 @@ EMBER_BLE_SIG = 0x03C1
17
17
  DEFAULT_NAME = "Ember Device"
18
18
 
19
19
 
20
- class DeviceType(str, Enum):
20
+ class DeviceType(StrEnum):
21
21
  """Base device types."""
22
22
 
23
23
  CUP = "cup"
@@ -26,7 +26,7 @@ class DeviceType(str, Enum):
26
26
  TUMBLER = "tumbler"
27
27
 
28
28
 
29
- class DeviceModel(str, Enum):
29
+ class DeviceModel(StrEnum):
30
30
  """Know device models."""
31
31
 
32
32
  CUP_6_OZ = "CM21S"
@@ -50,7 +50,7 @@ DEVICE_MODEL_NAMES: dict[DeviceModel, str] = {
50
50
  }
51
51
 
52
52
 
53
- class DeviceColour(str, Enum):
53
+ class DeviceColour(StrEnum):
54
54
  """All colours possible found across models."""
55
55
 
56
56
  SAGE_GREEN = "Sage Green"
@@ -66,7 +66,7 @@ class DeviceColour(str, Enum):
66
66
  ROSE_GOLD = "Rose Gold"
67
67
 
68
68
 
69
- class TemperatureUnit(str, Enum):
69
+ class TemperatureUnit(StrEnum):
70
70
  """Temperature Units."""
71
71
 
72
72
  CELSIUS = "°C"
@@ -177,7 +177,7 @@ class LiquidState(IntEnum):
177
177
  return self.label
178
178
 
179
179
 
180
- class VolumeLevel(str, Enum):
180
+ class VolumeLevel(StrEnum):
181
181
  """Class to manage volume levels."""
182
182
 
183
183
  LOW = "low"
@@ -240,7 +240,6 @@ ATTR_LABELS = {
240
240
  "liquid_level": "Liquid Level",
241
241
  "current_temp": "Current Temp",
242
242
  "target_temp": "Target Temp",
243
- "use_metric": "Use Metric",
244
243
  "dsk": "DSK",
245
244
  "udsk": "UDSK",
246
245
  "date_time_zone": "Date Time + Time Zone",
@@ -269,8 +268,11 @@ UPDATE_ATTRS = {
269
268
  EXTRA_ATTRS = {"battery_voltage", "date_time_zone", "udsk", "dsk"}
270
269
 
271
270
  # Validation
272
- MUG_NAME_REGEX = re.compile(r"^[A-Za-z0-9,.\[\]#()!\"\';:|\-_+<>%= ]{1,16}$")
271
+ # *Note*: Additional characters are escaped because Home Assistant uses LitElement with "v" mode which is stricter.
272
+ MUG_NAME_REGEX = re.compile(r"^[A-Za-z0-9,.\[\]#\(\)!\"\';:\|\-_+<>%= ]{1,16}$")
273
273
  MUG_NAME_PATTERN = MUG_NAME_REGEX.pattern
274
- MAC_ADDRESS_REGEX = re.compile(r"^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$")
274
+ MAC_ADDRESS_REGEX = re.compile(r"^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$", re.IGNORECASE)
275
+ MAC_UUID_REGEX = re.compile(r"^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$", re.IGNORECASE)
275
276
 
276
277
  IS_LINUX = platform.system() == "Linux"
278
+ IS_MACOS = platform.system() == "Darwin"
@@ -211,7 +211,7 @@ class MugData(AsDict):
211
211
 
212
212
  # Options
213
213
  model_info: ModelInfo
214
- use_metric: bool = True
214
+ use_metric: bool | None = None
215
215
  debug: bool = False
216
216
 
217
217
  # Attributes
@@ -231,6 +231,15 @@ class MugData(AsDict):
231
231
  date_time_zone: datetime | None = None
232
232
  battery_voltage: int | None = None
233
233
 
234
+ @property
235
+ def user_unit(self) -> TemperatureUnit | None:
236
+ """Get the correct unit for the user."""
237
+ if self.use_metric is False:
238
+ return TemperatureUnit.FAHRENHEIT
239
+ if self.use_metric is True:
240
+ return TemperatureUnit.CELSIUS
241
+ return None
242
+
234
243
  @property
235
244
  def meta_display(self) -> str:
236
245
  """Return Meta infor based on preference."""
@@ -263,12 +272,12 @@ class MugData(AsDict):
263
272
  @property
264
273
  def current_temp_display(self) -> str:
265
274
  """Human-readable current temp with unit."""
266
- return format_temp(self.current_temp, self.use_metric)
275
+ return format_temp(self.current_temp, self.user_unit)
267
276
 
268
277
  @property
269
278
  def target_temp_display(self) -> str:
270
279
  """Human-readable target temp with unit."""
271
- return format_temp(self.target_temp, self.use_metric)
280
+ return format_temp(self.target_temp, self.user_unit)
272
281
 
273
282
  def update_info(self, **kwargs: Any) -> list[Change]:
274
283
  """Update attributes of the mug if they haven't changed."""
@@ -288,7 +297,7 @@ class MugData(AsDict):
288
297
  @property
289
298
  def formatted(self) -> dict[str, Any]:
290
299
  """Return human-readable names and values for all attributes for display."""
291
- all_attrs = self.model_info.device_attributes | {"use_metric"}
300
+ all_attrs = set(self.model_info.device_attributes)
292
301
  if not self.debug:
293
302
  all_attrs -= EXTRA_ATTRS
294
303
  return {label: self.get_formatted_attr(attr) for attr, label in ATTR_LABELS.items() if attr in all_attrs}
@@ -296,6 +305,7 @@ class MugData(AsDict):
296
305
  def as_dict(self) -> dict[str, Any]:
297
306
  """Dump all attributes as dict for info/debugging."""
298
307
  data = asdict(self)
308
+ del data["use_metric"]
299
309
  all_attrs = self.model_info.device_attributes
300
310
  if not self.debug:
301
311
  all_attrs -= EXTRA_ATTRS
@@ -5,16 +5,17 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
+ from .consts import TemperatureUnit
8
9
  from .data import Colour
9
10
 
10
11
 
11
- def format_temp(temp: float, metric: bool = True) -> str:
12
+ def format_temp(temp: float, unit: TemperatureUnit | None = None) -> str:
12
13
  """Format temperature with the correct unit."""
13
- unit = "C" if metric else "F"
14
- return f"{temp:.2f}°{unit}"
14
+ value = f"{temp:.2f}"
15
+ return f"{value}{unit.value}" if unit else value
15
16
 
16
17
 
17
- def format_capacity(capacity: int | None, metric: bool = True) -> str:
18
+ def format_capacity(capacity: int | None, metric: bool | None = None) -> str:
18
19
  """Format capacity for display."""
19
20
  if capacity is None:
20
21
  return "Unknown"
@@ -7,7 +7,7 @@ import contextlib
7
7
  import logging
8
8
  import os
9
9
  from datetime import UTC, datetime
10
- from enum import Enum
10
+ from enum import StrEnum
11
11
  from functools import cached_property
12
12
  from time import time
13
13
  from typing import TYPE_CHECKING, Any, Concatenate, Literal, ParamSpec, TypeVar
@@ -46,7 +46,7 @@ if TYPE_CHECKING:
46
46
  from bleak.backends.characteristic import BleakGATTCharacteristic
47
47
  from bleak.backends.device import BLEDevice
48
48
 
49
- TempUnitType = Literal["°C", "°F"] | TemperatureUnit | Enum
49
+ TempUnitType = Literal["°C", "°F"] | TemperatureUnit | StrEnum
50
50
 
51
51
 
52
52
  logger = logging.getLogger(__name__)
@@ -68,7 +68,7 @@ def require_attribute(
68
68
  """Inner decorator."""
69
69
 
70
70
  async def wrapper(self: EmberMug, *args: P.args, **kwargs: P.kwargs) -> T:
71
- if self.has_attribute(attr_name) is False:
71
+ if not self.has_attribute(attr_name):
72
72
  device_type = self.data.model_info.device_type.value
73
73
  raise NotImplementedError(
74
74
  f"The {device_type} does not have the {attr_name} attribute",
@@ -87,7 +87,7 @@ class EmberMug:
87
87
  self,
88
88
  ble_device: BLEDevice,
89
89
  model_info: ModelInfo,
90
- use_metric: bool = True,
90
+ use_metric: bool | None = None,
91
91
  debug: bool = False,
92
92
  **kwargs: Any,
93
93
  ) -> None:
@@ -136,17 +136,13 @@ class EmberMug:
136
136
 
137
137
  def _convert_to_device_unit(self, value: float) -> float:
138
138
  """Convert user value to the unit the device expects."""
139
- if self.data.use_metric and self.data.temperature_unit != TemperatureUnit.CELSIUS:
140
- return convert_temp_to_fahrenheit(value)
141
- if not self.data.use_metric and self.data.temperature_unit != TemperatureUnit.FAHRENHEIT:
139
+ if self.data.user_unit == TemperatureUnit.FAHRENHEIT:
142
140
  return convert_temp_to_celsius(value)
143
141
  return value
144
142
 
145
143
  def _convert_to_user_unit(self, value: float) -> float:
146
144
  """Convert device value to the unit the user expects."""
147
- if self.data.use_metric and self.data.temperature_unit != TemperatureUnit.CELSIUS:
148
- return convert_temp_to_celsius(value)
149
- if not self.data.use_metric and self.data.temperature_unit != TemperatureUnit.FAHRENHEIT:
145
+ if self.data.user_unit == TemperatureUnit.FAHRENHEIT:
150
146
  return convert_temp_to_fahrenheit(value)
151
147
  return value
152
148
 
@@ -291,7 +287,7 @@ class EmberMug:
291
287
 
292
288
  async def pair(self) -> None:
293
289
  """Attempt to pair."""
294
- with contextlib.suppress(BleakError, EOFError):
290
+ with contextlib.suppress(BleakError, EOFError, NotImplementedError):
295
291
  await self._ensure_connection()
296
292
  await self._client.pair()
297
293
 
@@ -314,7 +310,7 @@ class EmberMug:
314
310
 
315
311
  async def set_target_temp(self, target_temp: float) -> None:
316
312
  """Set new target temp for mug."""
317
- unit = TemperatureUnit.CELSIUS if self.data.use_metric else TemperatureUnit.FAHRENHEIT
313
+ unit = TemperatureUnit.FAHRENHEIT if self.data.use_metric is False else TemperatureUnit.CELSIUS
318
314
  min_temp, max_temp = MIN_MAX_TEMPS[unit]
319
315
  if target_temp != 0 and not (min_temp <= target_temp <= max_temp):
320
316
  raise ValueError(f"Temperature should be between {min_temp} and {max_temp} or 0.")
@@ -405,17 +401,11 @@ class EmberMug:
405
401
 
406
402
  async def set_temperature_unit(self, unit: TempUnitType) -> None:
407
403
  """Set mug unit."""
408
- text_unit = unit.value if isinstance(unit, Enum) else unit
404
+ text_unit = unit.value if isinstance(unit, StrEnum) else unit
409
405
  unit_bytes = bytearray([1 if text_unit == TemperatureUnit.FAHRENHEIT else 0])
410
406
  await self._write(MugCharacteristic.TEMPERATURE_UNIT, unit_bytes)
411
407
  self.data.temperature_unit = TemperatureUnit(unit)
412
408
 
413
- async def ensure_correct_unit(self) -> None:
414
- """Set mug unit if it's not what we want."""
415
- desired = TemperatureUnit.CELSIUS if self.data.use_metric else TemperatureUnit.FAHRENHEIT
416
- if self.data.temperature_unit != desired:
417
- await self.set_temperature_unit(desired)
418
-
419
409
  async def get_battery_voltage(self) -> int:
420
410
  """Get voltage and charge time."""
421
411
  battery_voltage_bytes = await self._read(MugCharacteristic.CONTROL_REGISTER_DATA)
@@ -14,10 +14,11 @@ classifiers = [
14
14
  'Programming Language :: Python :: 3.11',
15
15
  'Programming Language :: Python :: 3.12',
16
16
  'Programming Language :: Python :: 3.13',
17
+ 'Programming Language :: Python :: 3.14',
17
18
  ]
18
19
  dependencies = [
19
20
  "bleak-retry-connector>=4.0.2",
20
- "bleak>=1.0.1",
21
+ "bleak>=1.0.1,<2.2.0",
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
@@ -72,7 +73,7 @@ python = "3.13"
72
73
  features = ["test"]
73
74
 
74
75
  [[tool.hatch.envs.test.matrix]]
75
- python = ["3.11", "3.12", "3.13"]
76
+ python = ["3.11", "3.12", "3.13", "3.14"]
76
77
 
77
78
  [tool.hatch.envs.test.scripts]
78
79
  cov = "pytest -vvv --asyncio-mode=auto --cov=ember_mug --cov-branch --cov-report=xml --cov-report=term-missing tests"
@@ -89,7 +90,7 @@ serve = "mkdocs serve --dev-addr localhost:8000"
89
90
  [tool.black]
90
91
  line-length = 120
91
92
  skip-string-normalization = true
92
- target-version = ["py311", "py312", "py313"]
93
+ target-version = ["py311", "py312", "py313", "py314"]
93
94
  include = '\.pyi?$'
94
95
  exclude = '''
95
96
  /(