Skip to content

Commit ec341cf

Browse files
authored
Merge pull request #36 from tidalf/add-stats
Add stats (1/3)
2 parents 83e02ab + cbc235c commit ec341cf

File tree

4 files changed

+291
-10
lines changed

4 files changed

+291
-10
lines changed

custom_components/jura/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
_LOGGER = logging.getLogger(__name__)
1111

12-
PLATFORMS = ["binary_sensor", "button", "number", "select", "switch"]
12+
PLATFORMS = ["binary_sensor", "button", "number", "select", "switch", "sensor"]
1313

1414

1515
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):

custom_components/jura/core/client.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,25 @@
1010

1111
_LOGGER = logging.getLogger(__name__)
1212

13-
ACTIVE_TIME = 120
13+
ACTIVE_TIME = 30
1414
COMMAND_TIME = 15
1515

1616

17+
class UUIDs:
18+
"""BLE characteristic UUIDs."""
19+
20+
# https://github.com/Jutta-Proto/protocol-bt-cpp?tab=readme-ov-file#bluetooth-characteristics
21+
# Heartbeat
22+
ABOUT_MACHINE = "5a401531-ab2e-2548-c435-08c300000710"
23+
24+
# Start product
25+
START_PRODUCT = "5a401525-ab2e-2548-c435-08c300000710"
26+
27+
# Statistics
28+
STATS_COMMAND = "5a401533-ab2e-2548-c435-08c300000710"
29+
STATS_DATA = "5A401534-ab2e-2548-c435-08c300000710"
30+
31+
1732
class Client:
1833
def __init__(self, device: BLEDevice, callback: Callable = None):
1934
self.device = device
@@ -25,9 +40,10 @@ def __init__(self, device: BLEDevice, callback: Callable = None):
2540
self.ping_future: asyncio.Future | None = None
2641
self.ping_task: asyncio.Task | None = None
2742
self.ping_time = 0
28-
43+
self.key = None
2944
self.send_data = None
3045
self.send_time = 0
46+
self.send_uuid = None
3147

3248
def ping(self):
3349
self.ping_time = time.time() + ACTIVE_TIME
@@ -43,10 +59,11 @@ def ping_cancel(self):
4359
if self.ping_future:
4460
self.ping_future.cancel()
4561

46-
def send(self, data: bytes):
62+
def send(self, data: bytes, uuid: str = UUIDs.START_PRODUCT):
4763
# if send loop active - we change sending data
4864
self.send_time = time.time() + COMMAND_TIME
4965
self.send_data = data
66+
self.send_uuid = uuid
5067

5168
# refresh ping time
5269
self.ping()
@@ -68,15 +85,16 @@ async def _ping_loop(self):
6885
while time.time() < self.ping_time:
6986
# important dummy read for keep connection
7087
data = await self.client.read_gatt_char(
71-
"5a401531-ab2e-2548-c435-08c300000710"
88+
UUIDs.ABOUT_MACHINE
7289
)
73-
key = data[0]
90+
self.key = data[0]
91+
_LOGGER.debug(f"key: {self.key}")
7492

7593
if self.send_data:
7694
if time.time() < self.send_time:
7795
await self.client.write_gatt_char(
78-
"5a401525-ab2e-2548-c435-08c300000710",
79-
data=encrypt(self.send_data, key),
96+
self.send_uuid,
97+
data=encrypt(self.send_data, self.key),
8098
response=True,
8199
)
82100
self.send_data = None
@@ -104,6 +122,62 @@ async def _ping_loop(self):
104122

105123
self.ping_task = None
106124

125+
async def read(self, uuid: str, decrypt: bool = False):
126+
"""Read data from a characteristic."""
127+
if not self.client:
128+
_LOGGER.warning("Cannot read: No active client connection")
129+
return None
130+
131+
try:
132+
data = await self.client.read_gatt_char(uuid)
133+
if decrypt and self.key:
134+
return encryption.encdec(list(data), self.key)
135+
return data
136+
except BleakError as e:
137+
_LOGGER.error(
138+
f"Error reading from characteristic {uuid}", exc_info=e)
139+
raise
140+
except Exception as e:
141+
_LOGGER.error(
142+
f"Error reading from characteristic {uuid}", exc_info=e)
143+
raise
144+
145+
async def read_statistics_data(self, timeout: int = 20, retries: int = 30) -> bytes | None:
146+
"""Read statistics data from the device."""
147+
_LOGGER.debug("Reading Jura statistics...")
148+
149+
# Send statistics request command
150+
# https://github.com/Jutta-Proto/protocol-bt-cpp?tab=readme-ov-file#writing-1
151+
command_bytes = [0x2A, 0x00, 0x01, 0xFF, 0xFF]
152+
self.send(bytes(command_bytes), uuid=UUIDs.STATS_COMMAND)
153+
154+
# Wait for connection
155+
if not self.client:
156+
_LOGGER.debug("Waiting for client connection...")
157+
for _ in range(timeout):
158+
if not self.client:
159+
await asyncio.sleep(1)
160+
else:
161+
break
162+
if not self.client:
163+
_LOGGER.debug("Failed to establish connection")
164+
return None
165+
166+
# Wait for statistics to be ready
167+
# https://github.com/Jutta-Proto/protocol-bt-cpp?tab=readme-ov-file#reading
168+
for _ in range(retries):
169+
status = await self.read(UUIDs.STATS_COMMAND)
170+
if status and status[1] != 225: # 225 means not ready
171+
break
172+
await asyncio.sleep(0.8)
173+
else:
174+
_LOGGER.error("Device not ready for statistics reading")
175+
return None
176+
177+
# Read statistics data
178+
# https://github.com/Jutta-Proto/protocol-bt-cpp?tab=readme-ov-file#statistics-data
179+
return await self.read(UUIDs.STATS_DATA, decrypt=True)
180+
107181

108182
def encrypt(data: bytes, key: int) -> bytes:
109183
data = bytearray(data)

custom_components/jura/core/device.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
from pathlib import Path
33
from typing import Callable, TypedDict
44
from zipfile import ZipFile
5+
import logging
56

67
import xmltodict
78
from bleak import AdvertisementData, BLEDevice
89

910
from .client import Client
1011

12+
_LOGGER = logging.getLogger(__name__)
13+
14+
COMMAND_TIME = 15
1115
SELECTS = [
1216
"product", # 1
1317
"grinder_ratio", # 2
@@ -54,6 +58,8 @@ def __init__(self, name: str, model: str, products: list, device: BLEDevice):
5458
self.values = None
5559
self.updates_connect: list = []
5660
self.updates_product: list = []
61+
self.updates_statistics = []
62+
self.statistics = {"total_products": None, "product_counts": {}}
5763

5864
@property
5965
def mac(self) -> str:
@@ -129,7 +135,8 @@ def select_option(self, attr: str, option: str):
129135
if not attribute:
130136
return None
131137

132-
value = next(i["@Value"] for i in attribute["ITEM"] if i["@Name"] == option)
138+
value = next(i["@Value"]
139+
for i in attribute["ITEM"] if i["@Name"] == option)
133140
self.set_value(attr, int(value, 16))
134141

135142
def set_value(self, attr: str, value: int):
@@ -182,10 +189,83 @@ def command(self) -> bytes:
182189
# data[0] = self.key
183190
# data[9] = 1
184191
# data[16] = 6
185-
# data[17] = self.key
192+
193+
# need to be set or the machine will go into a half broken state
194+
data[17] = self.client.key
186195

187196
return data
188197

198+
# Add method to register statistics updates
199+
def register_statistics_update(self, handler: Callable):
200+
"""Register a callback for statistics updates."""
201+
self.updates_statistics.append(handler)
202+
203+
async def read_statistics(self, force_update: bool = False):
204+
"""Read statistics from the machine."""
205+
206+
_LOGGER.debug("Reading Jura statistics...")
207+
208+
# Read statistics data from client
209+
decrypted_data = await self.client.read_statistics_data()
210+
if decrypted_data is None:
211+
_LOGGER.debug(
212+
"Failed to read statistics data, returning existing statistics")
213+
return self.statistics
214+
215+
# Convert all 3-byte chunks to integers
216+
product_counts_array = []
217+
for i in range(0, len(decrypted_data), 3):
218+
if i + 3 <= len(decrypted_data):
219+
# Convert 3 bytes to an integer
220+
count = int.from_bytes(decrypted_data[i:i+3], 'big')
221+
if count == 0xFFFF: # means 0 it seems
222+
count = 0
223+
product_counts_array.append(count)
224+
225+
# get total_count from first 3 bytes if available
226+
total_count = product_counts_array[0] if product_counts_array and product_counts_array[0] is not None else None
227+
_LOGGER.info(
228+
f"Total coffee count from data: {total_count if total_count is not None else 'undefined'}")
229+
230+
# remove aberrant values if any
231+
if total_count == 0 or total_count > 1000000:
232+
_LOGGER.info(
233+
"total 0 or too high, something's wrong, returning existing statistics")
234+
return self.statistics
235+
# get the names associated to the products counts
236+
product_counts = {}
237+
for i, count in enumerate(product_counts_array):
238+
if i == 0: # Skip the total
239+
continue
240+
241+
product = next(
242+
(p for p in self.products if int(p['@Code'], 16) == i), None)
243+
if product:
244+
product_counts[product['@Name']] = count
245+
_LOGGER.debug(
246+
f"Stat entry: Position {i} = {count} -> {product['@Name']}")
247+
else:
248+
_LOGGER.debug(
249+
f"No product found for code {i} with count {count}")
250+
251+
# Log the final counts at info log level
252+
for product, count in product_counts.items():
253+
_LOGGER.info(f"Product: {product}, Count: {count}")
254+
255+
# Save the statistics
256+
self.statistics = {
257+
"total_products": total_count,
258+
"product_counts": product_counts
259+
}
260+
261+
# Notify all statistics listeners
262+
_LOGGER.debug(
263+
f"Notifying {len(self.updates_statistics)} statistics listeners")
264+
for handler in self.updates_statistics:
265+
handler()
266+
267+
return self.statistics
268+
189269

190270
class EmptyModel(Exception):
191271
pass

custom_components/jura/sensor.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Sensor platform for Jura integration."""
2+
import logging
3+
import asyncio
4+
from datetime import timedelta
5+
from typing import Any, Callable, Optional
6+
7+
from homeassistant.components.sensor import (
8+
SensorDeviceClass,
9+
SensorEntity,
10+
SensorStateClass,
11+
)
12+
from homeassistant.config_entries import ConfigEntry
13+
from homeassistant.core import HomeAssistant, callback
14+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
15+
from homeassistant.helpers.event import async_track_time_interval
16+
17+
from .core import DOMAIN
18+
from .core.entity import JuraEntity
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
async def async_setup_entry(
24+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
25+
) -> None:
26+
"""Set up Jura sensor based on a config entry."""
27+
device = hass.data[DOMAIN][entry.entry_id]
28+
29+
# Create the total coffees sensor
30+
entities = [JuraTotalCoffeeSensor(device)]
31+
32+
# Create sensors for each product
33+
for product in device.products:
34+
product_name = product["@Name"]
35+
if product.get("@Active") != "false":
36+
entities.append(JuraProductCountSensor(device, product_name))
37+
38+
async_add_entities(entities)
39+
40+
# Set up automatic refresh
41+
update_interval = hass.data[DOMAIN].get("update_interval", 60)
42+
43+
async def refresh_statistics(*_):
44+
"""Refresh statistics regularly."""
45+
try:
46+
await device.read_statistics()
47+
except Exception as ex:
48+
_LOGGER.error(f"Error refreshing statistics: {ex}")
49+
50+
# Schedule regular updates
51+
entry.async_on_unload(
52+
async_track_time_interval(
53+
hass,
54+
refresh_statistics,
55+
timedelta(seconds=update_interval)
56+
)
57+
)
58+
59+
# Do an initial refresh
60+
hass.async_create_task(refresh_statistics())
61+
62+
63+
class JuraStatisticsSensor(JuraEntity, SensorEntity):
64+
"""Base class for Jura statistics sensors."""
65+
66+
def __init__(self, device, attr: str):
67+
"""Initialize the sensor."""
68+
super().__init__(device, attr)
69+
70+
# Register for updates on statistics
71+
device.register_statistics_update(self.internal_update)
72+
73+
@property
74+
def native_value(self) -> Any:
75+
"""Return the state of the sensor."""
76+
return self._get_value()
77+
78+
def _get_value(self) -> Any:
79+
"""Get the value for this sensor from statistics."""
80+
raise NotImplementedError("Subclasses must implement this method")
81+
82+
def internal_update(self):
83+
"""Override parent method to ensure statistics are refreshed."""
84+
_LOGGER.debug(f"Updating sensor {self._attr_name}")
85+
if self.hass is not None:
86+
self.async_write_ha_state()
87+
88+
89+
class JuraTotalCoffeeSensor(JuraStatisticsSensor):
90+
"""Sensor for total coffee count."""
91+
92+
_attr_icon = "mdi:coffee"
93+
_attr_state_class = SensorStateClass.TOTAL_INCREASING
94+
_attr_native_unit_of_measurement = "products"
95+
96+
def __init__(self, device):
97+
"""Initialize the sensor."""
98+
super().__init__(device, "total_product")
99+
self._attr_name = f"{device.name} Total Products"
100+
101+
def _get_value(self) -> int:
102+
"""Get the total coffee count."""
103+
value = self.device.statistics.get("total_products", 0)
104+
_LOGGER.debug(f"Total coffee value: {value}")
105+
return value
106+
107+
108+
class JuraProductCountSensor(JuraStatisticsSensor):
109+
"""Sensor for individual product count."""
110+
111+
_attr_icon = "mdi:coffee-outline"
112+
_attr_state_class = SensorStateClass.TOTAL_INCREASING
113+
_attr_native_unit_of_measurement = "products"
114+
115+
def __init__(self, device, product_name: str):
116+
"""Initialize the sensor."""
117+
self.product_name = product_name
118+
attr_name = f"product_{product_name.lower().replace(' ', '_')}"
119+
super().__init__(device, attr_name)
120+
self._attr_name = f"{device.name} {product_name} Count"
121+
122+
def _get_value(self) -> int:
123+
"""Get the count for this specific product."""
124+
value = self.device.statistics.get(
125+
"product_counts", {}).get(self.product_name, None)
126+
_LOGGER.debug(f"Product {self.product_name} count: {value}")
127+
return value

0 commit comments

Comments
 (0)