miramode 1.0.0__py3-none-any.whl
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.
- miramode/__init__.py +491 -0
- miramode/cli.py +252 -0
- miramode/py.typed +0 -0
- miramode-1.0.0.dist-info/METADATA +231 -0
- miramode-1.0.0.dist-info/RECORD +8 -0
- miramode-1.0.0.dist-info/WHEEL +4 -0
- miramode-1.0.0.dist-info/entry_points.txt +2 -0
- miramode-1.0.0.dist-info/licenses/LICENSE +21 -0
miramode/__init__.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Control Mira Mode digital showers and bath fillers over Bluetooth LE.
|
|
2
|
+
|
|
3
|
+
Current-generation Mira Mode / Kohler valves expose a GATT service
|
|
4
|
+
(``SERVICE_UUID``) carrying a simple binary protocol. Commands are written
|
|
5
|
+
to one characteristic and responses arrive as notifications on another,
|
|
6
|
+
one response per command.
|
|
7
|
+
|
|
8
|
+
Every message is a frame::
|
|
9
|
+
|
|
10
|
+
aa 55 <channel> <opcode> <length> <payload...> <checksum>
|
|
11
|
+
|
|
12
|
+
where the checksum is the two's complement of the sum of all preceding
|
|
13
|
+
bytes, so that the bytes of a whole valid frame sum to zero modulo 256.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from enum import IntEnum, IntFlag
|
|
23
|
+
|
|
24
|
+
from bleak import BleakClient, BleakScanner
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CommandFailed",
|
|
28
|
+
"DeviceInfo",
|
|
29
|
+
"DiscoveredDevice",
|
|
30
|
+
"Frame",
|
|
31
|
+
"MiraError",
|
|
32
|
+
"NotConnected",
|
|
33
|
+
"Opcode",
|
|
34
|
+
"Outlet",
|
|
35
|
+
"Preset",
|
|
36
|
+
"ResponseTimeout",
|
|
37
|
+
"Shower",
|
|
38
|
+
"Status",
|
|
39
|
+
"discover",
|
|
40
|
+
"encode_frame",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
LOG = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
SERVICE_UUID = "267f0001-eb15-43f5-94c3-67d2221188f7"
|
|
46
|
+
COMMAND_CHAR_UUID = "267f0002-eb15-43f5-94c3-67d2221188f7"
|
|
47
|
+
EVENT_CHAR_UUID = "267f0003-eb15-43f5-94c3-67d2221188f7"
|
|
48
|
+
|
|
49
|
+
_DEVICE_NAME_CHAR_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
|
|
50
|
+
_MODEL_CHAR_UUID = "00002a24-0000-1000-8000-00805f9b34fb"
|
|
51
|
+
_MANUFACTURER_CHAR_UUID = "00002a29-0000-1000-8000-00805f9b34fb"
|
|
52
|
+
|
|
53
|
+
PREAMBLE = b"\xaa\x55"
|
|
54
|
+
|
|
55
|
+
#: preamble, channel, opcode and payload length.
|
|
56
|
+
_HEADER_SIZE = 5
|
|
57
|
+
#: header plus the trailing checksum byte.
|
|
58
|
+
_ENVELOPE_SIZE = _HEADER_SIZE + 1
|
|
59
|
+
|
|
60
|
+
#: Presets store their name in a fixed-width, NUL-padded ASCII field.
|
|
61
|
+
_NAME_SIZE = 16
|
|
62
|
+
|
|
63
|
+
#: Flow is expressed as a percentage of the valve's maximum.
|
|
64
|
+
MAX_FLOW = 100
|
|
65
|
+
|
|
66
|
+
#: Highest preset slot the valve will answer for.
|
|
67
|
+
MAX_PRESET = 15
|
|
68
|
+
|
|
69
|
+
DEFAULT_CONNECT_TIMEOUT = 20.0
|
|
70
|
+
DEFAULT_RESPONSE_TIMEOUT = 5.0
|
|
71
|
+
DEFAULT_SCAN_TIMEOUT = 5.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Opcode(IntEnum):
|
|
75
|
+
"""Message types used by the valve."""
|
|
76
|
+
|
|
77
|
+
ACK = 0x01
|
|
78
|
+
GET_PRESET = 0x5D
|
|
79
|
+
SET_OUTLETS = 0xAB
|
|
80
|
+
RUN_PRESET = 0xB1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Status(IntEnum):
|
|
84
|
+
"""Result byte carried by an :attr:`Opcode.ACK` response."""
|
|
85
|
+
|
|
86
|
+
OK = 0x01
|
|
87
|
+
ERROR = 0x80
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Outlet(IntFlag):
|
|
91
|
+
"""Which outlets should be running.
|
|
92
|
+
|
|
93
|
+
The valve takes the running outlets as a single bitfield rather than
|
|
94
|
+
one field per outlet, so a command always states the complete desired
|
|
95
|
+
state: anything not named here is turned off.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
NONE = 0
|
|
99
|
+
FIRST = 1
|
|
100
|
+
SECOND = 2
|
|
101
|
+
THIRD = 4
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MiraError(Exception):
|
|
105
|
+
"""Base class for errors raised by this package."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NotConnected(MiraError):
|
|
109
|
+
"""Raised when a command needs a connection that isn't established."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ResponseTimeout(MiraError):
|
|
113
|
+
"""Raised when the valve doesn't answer a command in time."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CommandFailed(MiraError):
|
|
117
|
+
"""Raised when the valve explicitly rejects a command."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Frame:
|
|
122
|
+
"""A decoded protocol message."""
|
|
123
|
+
|
|
124
|
+
channel: int
|
|
125
|
+
opcode: int
|
|
126
|
+
payload: bytes
|
|
127
|
+
|
|
128
|
+
def __str__(self) -> str:
|
|
129
|
+
return (
|
|
130
|
+
f"channel={self.channel} opcode={self.opcode:#04x} "
|
|
131
|
+
f"payload={self.payload.hex(' ')}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class Preset:
|
|
137
|
+
"""A stored programme, e.g. a bath fill."""
|
|
138
|
+
|
|
139
|
+
index: int
|
|
140
|
+
name: str
|
|
141
|
+
#: The rest of the slot's contents, which hold the target temperature
|
|
142
|
+
#: and run time in a layout that isn't fully decoded yet.
|
|
143
|
+
data: bytes
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class DeviceInfo:
|
|
148
|
+
"""Identifying strings read from the standard GATT characteristics."""
|
|
149
|
+
|
|
150
|
+
name: str | None
|
|
151
|
+
manufacturer: str | None
|
|
152
|
+
model: str | None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class DiscoveredDevice:
|
|
157
|
+
"""A valve seen while scanning."""
|
|
158
|
+
|
|
159
|
+
address: str
|
|
160
|
+
name: str | None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _checksum(data: bytes) -> int:
|
|
164
|
+
"""Return the byte that makes ``data`` sum to zero modulo 256."""
|
|
165
|
+
return -sum(data) & 0xFF
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def encode_frame(opcode: int, payload: bytes = b"", channel: int = 0) -> bytes:
|
|
169
|
+
"""Build a complete frame, checksum included."""
|
|
170
|
+
if not 0 <= channel <= 0xFF:
|
|
171
|
+
raise ValueError(f"channel out of range: {channel}")
|
|
172
|
+
if len(payload) > 0xFF:
|
|
173
|
+
raise ValueError(f"payload too long: {len(payload)} bytes")
|
|
174
|
+
body = PREAMBLE + bytes((channel, int(opcode), len(payload))) + payload
|
|
175
|
+
return body + bytes((_checksum(body),))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _decode_frame(raw: bytes) -> Frame | None:
|
|
179
|
+
"""Decode one complete frame, or return None if it doesn't check out."""
|
|
180
|
+
if len(raw) < _ENVELOPE_SIZE or not raw.startswith(PREAMBLE):
|
|
181
|
+
return None
|
|
182
|
+
if len(raw) != _ENVELOPE_SIZE + raw[4]:
|
|
183
|
+
return None
|
|
184
|
+
if sum(raw) & 0xFF:
|
|
185
|
+
return None
|
|
186
|
+
return Frame(channel=raw[2], opcode=raw[3], payload=raw[_HEADER_SIZE:-1])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def encode_temperature(celsius: float) -> bytes:
|
|
190
|
+
"""Encode a temperature as tenths of a degree, big endian.
|
|
191
|
+
|
|
192
|
+
Zero is meaningful: the valve reads it as "leave the temperature
|
|
193
|
+
alone", which is what a plain stop command sends.
|
|
194
|
+
"""
|
|
195
|
+
tenths = round(celsius * 10)
|
|
196
|
+
if not 0 <= tenths <= 0xFFFF:
|
|
197
|
+
raise ValueError(f"temperature out of range: {celsius}")
|
|
198
|
+
return tenths.to_bytes(2, "big")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _decode_preset(payload: bytes) -> Preset | None:
|
|
202
|
+
"""Decode a preset slot, or return None if the slot isn't in use.
|
|
203
|
+
|
|
204
|
+
An unconfigured slot answers with a mismatched index and arbitrary
|
|
205
|
+
bytes where the name belongs, so the name field is required to be
|
|
206
|
+
printable ASCII with NUL padding.
|
|
207
|
+
"""
|
|
208
|
+
if len(payload) < 1 + _NAME_SIZE:
|
|
209
|
+
return None
|
|
210
|
+
raw_name = payload[1 : 1 + _NAME_SIZE]
|
|
211
|
+
if any(byte and not 0x20 <= byte < 0x7F for byte in raw_name):
|
|
212
|
+
return None
|
|
213
|
+
name = raw_name.split(b"\x00", 1)[0].decode("ascii").strip()
|
|
214
|
+
if not name:
|
|
215
|
+
return None
|
|
216
|
+
return Preset(
|
|
217
|
+
index=payload[0], name=name, data=bytes(payload[1 + _NAME_SIZE :])
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _FrameReader:
|
|
222
|
+
"""Reassembles frames from notification chunks.
|
|
223
|
+
|
|
224
|
+
A response usually arrives in a single notification, but nothing
|
|
225
|
+
guarantees that, so incoming bytes are buffered and frames are taken
|
|
226
|
+
out as they complete.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._buffer = bytearray()
|
|
231
|
+
|
|
232
|
+
def reset(self) -> None:
|
|
233
|
+
self._buffer.clear()
|
|
234
|
+
|
|
235
|
+
def feed(self, chunk: bytes) -> list[Frame]:
|
|
236
|
+
self._buffer += chunk
|
|
237
|
+
frames: list[Frame] = []
|
|
238
|
+
while True:
|
|
239
|
+
start = self._buffer.find(PREAMBLE)
|
|
240
|
+
if start < 0:
|
|
241
|
+
# Keep a trailing byte, which may be a split preamble.
|
|
242
|
+
del self._buffer[: max(0, len(self._buffer) - 1)]
|
|
243
|
+
break
|
|
244
|
+
del self._buffer[:start]
|
|
245
|
+
if len(self._buffer) < _ENVELOPE_SIZE:
|
|
246
|
+
break
|
|
247
|
+
size = _ENVELOPE_SIZE + self._buffer[4]
|
|
248
|
+
if len(self._buffer) < size:
|
|
249
|
+
break
|
|
250
|
+
raw = bytes(self._buffer[:size])
|
|
251
|
+
del self._buffer[:size]
|
|
252
|
+
frame = _decode_frame(raw)
|
|
253
|
+
if frame is None:
|
|
254
|
+
LOG.debug("Discarding malformed frame: %s", raw.hex(" "))
|
|
255
|
+
else:
|
|
256
|
+
frames.append(frame)
|
|
257
|
+
return frames
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def discover(
|
|
261
|
+
timeout: float = DEFAULT_SCAN_TIMEOUT,
|
|
262
|
+
) -> list[DiscoveredDevice]:
|
|
263
|
+
"""Scan for nearby valves.
|
|
264
|
+
|
|
265
|
+
Matches on the advertised service UUID, falling back to the name for
|
|
266
|
+
valves that advertise without it.
|
|
267
|
+
"""
|
|
268
|
+
found: dict[str, DiscoveredDevice] = {}
|
|
269
|
+
for device, advertisement in (
|
|
270
|
+
await BleakScanner.discover(timeout=timeout, return_adv=True)
|
|
271
|
+
).values():
|
|
272
|
+
name = advertisement.local_name or device.name
|
|
273
|
+
uuids = {uuid.lower() for uuid in advertisement.service_uuids or ()}
|
|
274
|
+
if SERVICE_UUID in uuids or (name and "mira" in name.lower()):
|
|
275
|
+
found[device.address] = DiscoveredDevice(device.address, name)
|
|
276
|
+
return sorted(found.values(), key=lambda d: (d.name or "", d.address))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Shower:
|
|
280
|
+
"""A connection to one valve.
|
|
281
|
+
|
|
282
|
+
Usable as an async context manager::
|
|
283
|
+
|
|
284
|
+
async with Shower(address) as shower:
|
|
285
|
+
await shower.run_preset(2)
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
address: str,
|
|
291
|
+
*,
|
|
292
|
+
channel: int = 0,
|
|
293
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
294
|
+
response_timeout: float = DEFAULT_RESPONSE_TIMEOUT,
|
|
295
|
+
) -> None:
|
|
296
|
+
self._address = address
|
|
297
|
+
self._channel = channel
|
|
298
|
+
self._connect_timeout = connect_timeout
|
|
299
|
+
self._response_timeout = response_timeout
|
|
300
|
+
self._client: BleakClient | None = None
|
|
301
|
+
self._reader = _FrameReader()
|
|
302
|
+
self._pending: asyncio.Future | None = None
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def address(self) -> str:
|
|
306
|
+
return self._address
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def is_connected(self) -> bool:
|
|
310
|
+
return self._client is not None and self._client.is_connected
|
|
311
|
+
|
|
312
|
+
async def __aenter__(self) -> Shower:
|
|
313
|
+
await self.connect()
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
317
|
+
await self.disconnect()
|
|
318
|
+
|
|
319
|
+
async def connect(self) -> None:
|
|
320
|
+
if self._client is not None:
|
|
321
|
+
return
|
|
322
|
+
client = BleakClient(self._address, timeout=self._connect_timeout)
|
|
323
|
+
await client.connect()
|
|
324
|
+
try:
|
|
325
|
+
self._reader.reset()
|
|
326
|
+
await client.start_notify(EVENT_CHAR_UUID, self._on_notification)
|
|
327
|
+
except Exception:
|
|
328
|
+
await client.disconnect()
|
|
329
|
+
raise
|
|
330
|
+
self._client = client
|
|
331
|
+
LOG.debug("Connected to %s", self._address)
|
|
332
|
+
|
|
333
|
+
async def disconnect(self) -> None:
|
|
334
|
+
client, self._client = self._client, None
|
|
335
|
+
if client is None:
|
|
336
|
+
return
|
|
337
|
+
self._fail_pending(NotConnected("Disconnected while awaiting a reply"))
|
|
338
|
+
try:
|
|
339
|
+
if client.is_connected:
|
|
340
|
+
await client.stop_notify(EVENT_CHAR_UUID)
|
|
341
|
+
except Exception:
|
|
342
|
+
LOG.debug(
|
|
343
|
+
"Ignoring error while stopping notifications", exc_info=True
|
|
344
|
+
)
|
|
345
|
+
await client.disconnect()
|
|
346
|
+
LOG.debug("Disconnected from %s", self._address)
|
|
347
|
+
|
|
348
|
+
def _require_client(self) -> BleakClient:
|
|
349
|
+
if self._client is None:
|
|
350
|
+
raise NotConnected("Not connected; call connect() first")
|
|
351
|
+
return self._client
|
|
352
|
+
|
|
353
|
+
def _fail_pending(self, error: Exception) -> None:
|
|
354
|
+
pending, self._pending = self._pending, None
|
|
355
|
+
if pending is not None and not pending.done():
|
|
356
|
+
pending.set_exception(error)
|
|
357
|
+
|
|
358
|
+
def _on_notification(
|
|
359
|
+
self, _characteristic: object, data: bytearray
|
|
360
|
+
) -> None:
|
|
361
|
+
for frame in self._reader.feed(bytes(data)):
|
|
362
|
+
LOG.debug("Received %s", frame)
|
|
363
|
+
pending, self._pending = self._pending, None
|
|
364
|
+
if pending is not None and not pending.done():
|
|
365
|
+
pending.set_result(frame)
|
|
366
|
+
else:
|
|
367
|
+
LOG.debug("Ignoring unsolicited frame")
|
|
368
|
+
|
|
369
|
+
async def _request(self, opcode: int, payload: bytes = b"") -> Frame:
|
|
370
|
+
"""Send a command and wait for the valve's reply."""
|
|
371
|
+
client = self._require_client()
|
|
372
|
+
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
373
|
+
self._pending = future
|
|
374
|
+
frame = encode_frame(opcode, payload, self._channel)
|
|
375
|
+
LOG.debug("Sending %s", frame.hex(" "))
|
|
376
|
+
try:
|
|
377
|
+
await client.write_gatt_char(
|
|
378
|
+
COMMAND_CHAR_UUID, frame, response=False
|
|
379
|
+
)
|
|
380
|
+
return await asyncio.wait_for(future, self._response_timeout)
|
|
381
|
+
except asyncio.TimeoutError:
|
|
382
|
+
raise ResponseTimeout(
|
|
383
|
+
f"No reply to command {opcode:#04x}"
|
|
384
|
+
) from None
|
|
385
|
+
finally:
|
|
386
|
+
if self._pending is future:
|
|
387
|
+
self._pending = None
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _check_ack(frame: Frame) -> None:
|
|
391
|
+
status = frame.payload[0] if frame.payload else None
|
|
392
|
+
if status == Status.OK:
|
|
393
|
+
return
|
|
394
|
+
if status == Status.ERROR:
|
|
395
|
+
raise CommandFailed("The valve rejected the command")
|
|
396
|
+
raise CommandFailed(f"Unexpected reply: {frame}")
|
|
397
|
+
|
|
398
|
+
async def set_outlets(
|
|
399
|
+
self,
|
|
400
|
+
outlets: Outlet,
|
|
401
|
+
temperature: float,
|
|
402
|
+
flow: int = MAX_FLOW,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Set exactly which outlets run, at a given temperature and flow.
|
|
405
|
+
|
|
406
|
+
Outlets absent from ``outlets`` are turned off.
|
|
407
|
+
"""
|
|
408
|
+
if not 0 <= flow <= MAX_FLOW:
|
|
409
|
+
raise ValueError(f"flow out of range: {flow}")
|
|
410
|
+
payload = encode_temperature(temperature) + bytes(
|
|
411
|
+
(flow if outlets else 0, int(outlets))
|
|
412
|
+
)
|
|
413
|
+
self._check_ack(await self._request(Opcode.SET_OUTLETS, payload))
|
|
414
|
+
|
|
415
|
+
async def stop(self) -> None:
|
|
416
|
+
"""Turn every outlet off, leaving the temperature setting alone."""
|
|
417
|
+
await self.set_outlets(Outlet.NONE, 0.0, 0)
|
|
418
|
+
|
|
419
|
+
async def run_preset(self, index: int) -> None:
|
|
420
|
+
"""Start a stored preset.
|
|
421
|
+
|
|
422
|
+
Factory-fitted presets are numbered from 1.
|
|
423
|
+
"""
|
|
424
|
+
self._check_ack(
|
|
425
|
+
await self._request(Opcode.RUN_PRESET, bytes((index,)))
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def read_preset(self, index: int) -> Preset | None:
|
|
429
|
+
"""Read one preset slot, returning None if it isn't configured."""
|
|
430
|
+
frame = await self._request(Opcode.GET_PRESET, bytes((index,)))
|
|
431
|
+
preset = _decode_preset(frame.payload)
|
|
432
|
+
if preset is None or preset.index != index:
|
|
433
|
+
return None
|
|
434
|
+
return preset
|
|
435
|
+
|
|
436
|
+
async def presets(
|
|
437
|
+
self,
|
|
438
|
+
indexes: Iterable[int] | None = None,
|
|
439
|
+
) -> list[Preset]:
|
|
440
|
+
"""Read every configured preset in ``indexes``.
|
|
441
|
+
|
|
442
|
+
This only reads from the valve; it doesn't run any water.
|
|
443
|
+
"""
|
|
444
|
+
if indexes is None:
|
|
445
|
+
indexes = range(MAX_PRESET + 1)
|
|
446
|
+
found = []
|
|
447
|
+
for index in indexes:
|
|
448
|
+
preset = await self.read_preset(index)
|
|
449
|
+
if preset is not None:
|
|
450
|
+
found.append(preset)
|
|
451
|
+
return found
|
|
452
|
+
|
|
453
|
+
def services(self) -> dict[str, list[str]]:
|
|
454
|
+
"""Map each GATT service the valve offers to its characteristics."""
|
|
455
|
+
client = self._require_client()
|
|
456
|
+
return {
|
|
457
|
+
service.uuid.lower(): sorted(
|
|
458
|
+
char.uuid.lower() for char in service.characteristics
|
|
459
|
+
)
|
|
460
|
+
for service in client.services
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async def device_info(self) -> DeviceInfo:
|
|
464
|
+
"""Read whatever identification the valve exposes.
|
|
465
|
+
|
|
466
|
+
Not every valve implements the standard device-information
|
|
467
|
+
characteristics, so any of these fields may be None.
|
|
468
|
+
"""
|
|
469
|
+
client = self._require_client()
|
|
470
|
+
available = {
|
|
471
|
+
uuid for uuids in self.services().values() for uuid in uuids
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async def read(uuid: str) -> str | None:
|
|
475
|
+
if uuid not in available:
|
|
476
|
+
return None
|
|
477
|
+
try:
|
|
478
|
+
value = await client.read_gatt_char(uuid)
|
|
479
|
+
except Exception:
|
|
480
|
+
LOG.debug("Could not read %s", uuid, exc_info=True)
|
|
481
|
+
return None
|
|
482
|
+
return value.decode("utf-8", "replace").strip() or None
|
|
483
|
+
|
|
484
|
+
values: Sequence[str | None] = await asyncio.gather(
|
|
485
|
+
read(_DEVICE_NAME_CHAR_UUID),
|
|
486
|
+
read(_MANUFACTURER_CHAR_UUID),
|
|
487
|
+
read(_MODEL_CHAR_UUID),
|
|
488
|
+
)
|
|
489
|
+
return DeviceInfo(
|
|
490
|
+
name=values[0], manufacturer=values[1], model=values[2]
|
|
491
|
+
)
|
miramode/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Command line interface for controlling Mira Mode valves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from . import (
|
|
12
|
+
DEFAULT_SCAN_TIMEOUT,
|
|
13
|
+
MAX_FLOW,
|
|
14
|
+
MAX_PRESET,
|
|
15
|
+
SERVICE_UUID,
|
|
16
|
+
MiraError,
|
|
17
|
+
Outlet,
|
|
18
|
+
Shower,
|
|
19
|
+
discover,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LOG = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _temperature(text: str) -> float:
|
|
26
|
+
try:
|
|
27
|
+
value = float(text)
|
|
28
|
+
except ValueError:
|
|
29
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
|
30
|
+
if not 0 < value < 100:
|
|
31
|
+
raise argparse.ArgumentTypeError(
|
|
32
|
+
f"{text} is not a plausible temperature in Celsius"
|
|
33
|
+
)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _percentage(text: str) -> int:
|
|
38
|
+
try:
|
|
39
|
+
value = int(text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
|
42
|
+
if not 0 <= value <= MAX_FLOW:
|
|
43
|
+
raise argparse.ArgumentTypeError(f"{text} is not between 0 and 100")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _preset_index(text: str) -> int:
|
|
48
|
+
try:
|
|
49
|
+
value = int(text)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
|
52
|
+
if not 0 <= value <= MAX_PRESET:
|
|
53
|
+
raise argparse.ArgumentTypeError(
|
|
54
|
+
f"{text} is not between 0 and {MAX_PRESET}"
|
|
55
|
+
)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _scan(args: argparse.Namespace) -> int:
|
|
60
|
+
devices = await discover(timeout=args.timeout)
|
|
61
|
+
if not devices:
|
|
62
|
+
print("No valves found.")
|
|
63
|
+
return 1
|
|
64
|
+
for device in devices:
|
|
65
|
+
print(f"{device.address} {device.name or '(unnamed)'}")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _info(args: argparse.Namespace) -> int:
|
|
70
|
+
async with Shower(args.address) as shower:
|
|
71
|
+
info = await shower.device_info()
|
|
72
|
+
services = shower.services()
|
|
73
|
+
for label, value in (
|
|
74
|
+
("Name", info.name),
|
|
75
|
+
("Manufacturer", info.manufacturer),
|
|
76
|
+
("Model", info.model),
|
|
77
|
+
):
|
|
78
|
+
if value:
|
|
79
|
+
print(f"{label + ':':14}{value}")
|
|
80
|
+
supported = SERVICE_UUID in services
|
|
81
|
+
print(
|
|
82
|
+
f"{'Protocol:':14}"
|
|
83
|
+
f"{'GCS (supported)' if supported else 'not GCS (unsupported)'}"
|
|
84
|
+
)
|
|
85
|
+
print("Services:")
|
|
86
|
+
for service, characteristics in sorted(services.items()):
|
|
87
|
+
print(f" {service}")
|
|
88
|
+
for characteristic in characteristics:
|
|
89
|
+
print(f" {characteristic}")
|
|
90
|
+
return 0 if supported else 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _presets(args: argparse.Namespace) -> int:
|
|
94
|
+
async with Shower(args.address) as shower:
|
|
95
|
+
presets = await shower.presets(range(args.first, args.last + 1))
|
|
96
|
+
if not presets:
|
|
97
|
+
print("No presets configured.")
|
|
98
|
+
return 1
|
|
99
|
+
for preset in presets:
|
|
100
|
+
print(f"{preset.index:>3} {preset.name}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _start(args: argparse.Namespace) -> int:
|
|
105
|
+
async with Shower(args.address) as shower:
|
|
106
|
+
await shower.run_preset(args.preset)
|
|
107
|
+
print(f"Started preset {args.preset}.")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _outlets(args: argparse.Namespace) -> int:
|
|
112
|
+
outlets = Outlet.NONE
|
|
113
|
+
if args.first:
|
|
114
|
+
outlets |= Outlet.FIRST
|
|
115
|
+
if args.second:
|
|
116
|
+
outlets |= Outlet.SECOND
|
|
117
|
+
async with Shower(args.address) as shower:
|
|
118
|
+
await shower.set_outlets(outlets, args.temperature, args.flow)
|
|
119
|
+
running = ", ".join(
|
|
120
|
+
name for name, on in (("1", args.first), ("2", args.second)) if on
|
|
121
|
+
)
|
|
122
|
+
print(
|
|
123
|
+
f"Running outlet(s) {running} at {args.temperature}C."
|
|
124
|
+
if running
|
|
125
|
+
else "All outlets off."
|
|
126
|
+
)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _stop(args: argparse.Namespace) -> int:
|
|
131
|
+
async with Shower(args.address) as shower:
|
|
132
|
+
await shower.stop()
|
|
133
|
+
print("All outlets off.")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _add_address(parser: argparse.ArgumentParser) -> None:
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"-a",
|
|
140
|
+
"--address",
|
|
141
|
+
required=True,
|
|
142
|
+
help="valve address (a MAC address, or a UUID on macOS)",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
147
|
+
parser = argparse.ArgumentParser(
|
|
148
|
+
prog="miramode",
|
|
149
|
+
description="Control Mira Mode digital showers and bath fillers.",
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"-v",
|
|
153
|
+
"--verbose",
|
|
154
|
+
action="store_true",
|
|
155
|
+
help="log the frames exchanged with the valve",
|
|
156
|
+
)
|
|
157
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
158
|
+
|
|
159
|
+
scan = subcommands.add_parser("scan", help="find nearby valves")
|
|
160
|
+
scan.add_argument(
|
|
161
|
+
"-t",
|
|
162
|
+
"--timeout",
|
|
163
|
+
type=float,
|
|
164
|
+
default=DEFAULT_SCAN_TIMEOUT,
|
|
165
|
+
help=f"seconds to scan for (default: {DEFAULT_SCAN_TIMEOUT:g})",
|
|
166
|
+
)
|
|
167
|
+
scan.set_defaults(handler=_scan)
|
|
168
|
+
|
|
169
|
+
info = subcommands.add_parser("info", help="show valve identification")
|
|
170
|
+
_add_address(info)
|
|
171
|
+
info.set_defaults(handler=_info)
|
|
172
|
+
|
|
173
|
+
presets = subcommands.add_parser(
|
|
174
|
+
"presets", help="list stored presets (reads only, runs no water)"
|
|
175
|
+
)
|
|
176
|
+
_add_address(presets)
|
|
177
|
+
presets.add_argument(
|
|
178
|
+
"--first",
|
|
179
|
+
type=_preset_index,
|
|
180
|
+
default=0,
|
|
181
|
+
help="lowest slot to read (default: 0)",
|
|
182
|
+
)
|
|
183
|
+
presets.add_argument(
|
|
184
|
+
"--last",
|
|
185
|
+
type=_preset_index,
|
|
186
|
+
default=MAX_PRESET,
|
|
187
|
+
help=f"highest slot to read (default: {MAX_PRESET})",
|
|
188
|
+
)
|
|
189
|
+
presets.set_defaults(handler=_presets)
|
|
190
|
+
|
|
191
|
+
start = subcommands.add_parser("start", help="run a stored preset")
|
|
192
|
+
_add_address(start)
|
|
193
|
+
start.add_argument(
|
|
194
|
+
"preset",
|
|
195
|
+
type=_preset_index,
|
|
196
|
+
help="preset slot; factory-fitted presets start at 1",
|
|
197
|
+
)
|
|
198
|
+
start.set_defaults(handler=_start)
|
|
199
|
+
|
|
200
|
+
outlets = subcommands.add_parser(
|
|
201
|
+
"outlets", help="run specific outlets at a chosen temperature"
|
|
202
|
+
)
|
|
203
|
+
_add_address(outlets)
|
|
204
|
+
outlets.add_argument(
|
|
205
|
+
"-1", "--first", action="store_true", help="run the first outlet"
|
|
206
|
+
)
|
|
207
|
+
outlets.add_argument(
|
|
208
|
+
"-2", "--second", action="store_true", help="run the second outlet"
|
|
209
|
+
)
|
|
210
|
+
outlets.add_argument(
|
|
211
|
+
"-t",
|
|
212
|
+
"--temperature",
|
|
213
|
+
type=_temperature,
|
|
214
|
+
required=True,
|
|
215
|
+
help="target temperature in Celsius",
|
|
216
|
+
)
|
|
217
|
+
outlets.add_argument(
|
|
218
|
+
"-f",
|
|
219
|
+
"--flow",
|
|
220
|
+
type=_percentage,
|
|
221
|
+
default=MAX_FLOW,
|
|
222
|
+
help=f"flow as a percentage (default: {MAX_FLOW})",
|
|
223
|
+
)
|
|
224
|
+
outlets.set_defaults(handler=_outlets)
|
|
225
|
+
|
|
226
|
+
stop = subcommands.add_parser("stop", help="turn every outlet off")
|
|
227
|
+
_add_address(stop)
|
|
228
|
+
stop.set_defaults(handler=_stop)
|
|
229
|
+
|
|
230
|
+
return parser
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
234
|
+
args = _build_parser().parse_args(argv)
|
|
235
|
+
# Only turn up our own logging: bleak's debug output is a firehose of
|
|
236
|
+
# per-platform GATT callbacks that buries the frames being exchanged.
|
|
237
|
+
logging.basicConfig(
|
|
238
|
+
format="%(message)s", level=logging.WARNING, stream=sys.stderr
|
|
239
|
+
)
|
|
240
|
+
if args.verbose:
|
|
241
|
+
logging.getLogger(__package__).setLevel(logging.DEBUG)
|
|
242
|
+
try:
|
|
243
|
+
return asyncio.run(args.handler(args))
|
|
244
|
+
except MiraError as error:
|
|
245
|
+
print(f"error: {error}", file=sys.stderr)
|
|
246
|
+
return 1
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
return 130
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
miramode/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: miramode
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Control Mira Mode digital showers and bath fillers over Bluetooth LE
|
|
5
|
+
Project-URL: Homepage, https://github.com/ryan-shaw/mira-mode-control
|
|
6
|
+
Project-URL: Repository, https://github.com/ryan-shaw/mira-mode-control
|
|
7
|
+
Project-URL: Issues, https://github.com/ryan-shaw/mira-mode-control/issues
|
|
8
|
+
Author: Ryan Shaw
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ble,bluetooth,home-automation,kohler,mira,mira-mode,shower,smart-home
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Home Automation
|
|
24
|
+
Classifier: Topic :: System :: Hardware
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: bleak>=0.22
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# mira-mode-control
|
|
31
|
+
|
|
32
|
+
[](https://github.com/ryan-shaw/mira-mode-control/actions/workflows/lint.yml)
|
|
33
|
+
|
|
34
|
+
Control Mira Mode digital showers and bath fillers from Python, over
|
|
35
|
+
Bluetooth Low Energy.
|
|
36
|
+
|
|
37
|
+
Mira Mode valves (now sold under Kohler) ship with no interface other than
|
|
38
|
+
BLE and the vendor's phone app, which means no Home Assistant, no
|
|
39
|
+
automation, and no way to start filling the bath from anywhere but the
|
|
40
|
+
bathroom. This package implements the valve's BLE protocol directly, so
|
|
41
|
+
you can drive it from a Raspberry Pi, a laptop, or anything else with a
|
|
42
|
+
Bluetooth radio.
|
|
43
|
+
|
|
44
|
+
> **Use at your own risk.** This drives real plumbing that produces real
|
|
45
|
+
> hot water. The protocol was reverse engineered, not documented by the
|
|
46
|
+
> vendor. Don't run it unattended until you've watched it behave.
|
|
47
|
+
|
|
48
|
+
## Supported hardware
|
|
49
|
+
|
|
50
|
+
This implements the protocol used by current-generation valves, which
|
|
51
|
+
advertise the GATT service `267f0001-eb15-43f5-94c3-67d2221188f7`. Check
|
|
52
|
+
yours with `miramode info` — it reports whether the valve speaks a
|
|
53
|
+
protocol this package understands.
|
|
54
|
+
|
|
55
|
+
Earlier Mira Mode valves advertise `bccb0001-ca66-11e5-88a4-0002a5d5c51b`
|
|
56
|
+
and use an unrelated, CRC-16 based protocol that this package does not
|
|
57
|
+
support.
|
|
58
|
+
|
|
59
|
+
Developed against a dual outlet shower and bath filler unit. Other valves
|
|
60
|
+
in the range should work, but only the outlets and presets your unit
|
|
61
|
+
actually has will do anything.
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Python 3.10 or newer
|
|
66
|
+
- A Bluetooth Low Energy adapter
|
|
67
|
+
- On Linux, BlueZ
|
|
68
|
+
- The valve must be paired with the host first, through your operating
|
|
69
|
+
system's Bluetooth settings. The valve refuses to accept commands over
|
|
70
|
+
an unbonded connection.
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
pip install miramode
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or, with [uv](https://docs.astral.sh/uv/) — `uv tool install miramode`
|
|
79
|
+
to get just the `miramode` command, or `uv add miramode` to use it as a
|
|
80
|
+
library in a project.
|
|
81
|
+
|
|
82
|
+
### From source
|
|
83
|
+
|
|
84
|
+
```console
|
|
85
|
+
git clone git@github.com:ryan-shaw/mira-mode-control.git
|
|
86
|
+
cd mira-mode-control
|
|
87
|
+
uv sync
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That installs the versions pinned in `uv.lock` into `.venv`. Prefix the
|
|
91
|
+
commands below with `uv run` to use it.
|
|
92
|
+
|
|
93
|
+
### Development
|
|
94
|
+
|
|
95
|
+
The same checks CI runs:
|
|
96
|
+
|
|
97
|
+
```console
|
|
98
|
+
uv run ruff check .
|
|
99
|
+
uv run ruff format --check .
|
|
100
|
+
uv run ty check
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use `uv run ruff format .` to apply formatting.
|
|
104
|
+
|
|
105
|
+
## Command line usage
|
|
106
|
+
|
|
107
|
+
Find your valve:
|
|
108
|
+
|
|
109
|
+
```console
|
|
110
|
+
$ miramode scan
|
|
111
|
+
1FE6A4BD-73A5-055E-6463-F4785E21D49D Mira 004C Bathroom
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Addresses are MAC addresses on Linux and Windows, and opaque UUIDs on
|
|
115
|
+
macOS. Every command below takes one with `-a`.
|
|
116
|
+
|
|
117
|
+
### Presets
|
|
118
|
+
|
|
119
|
+
Presets are the programmes stored in the valve — each carries its own
|
|
120
|
+
target temperature and run time, and they're how the vendor app starts
|
|
121
|
+
the shower or fills the bath. Listing them only reads from the valve and
|
|
122
|
+
runs no water:
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
$ miramode presets -a <address>
|
|
126
|
+
1 Default
|
|
127
|
+
2 Default Bathfill
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Factory-fitted presets are numbered from 1; slot 0 is not used. Start one
|
|
131
|
+
by number:
|
|
132
|
+
|
|
133
|
+
```console
|
|
134
|
+
miramode start -a <address> 2
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Outlets
|
|
138
|
+
|
|
139
|
+
To run an outlet directly, at a temperature you choose:
|
|
140
|
+
|
|
141
|
+
```console
|
|
142
|
+
miramode outlets -a <address> --first --temperature 39
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Use `--second` for the second outlet, and both flags together to run both
|
|
146
|
+
at once. A command states the complete desired state, so any outlet you
|
|
147
|
+
don't name is switched off. `--flow` sets the flow rate as a percentage,
|
|
148
|
+
defaulting to 100.
|
|
149
|
+
|
|
150
|
+
Which physical fixture is "first" depends on how your unit is plumbed.
|
|
151
|
+
|
|
152
|
+
To shut everything off, including a running preset:
|
|
153
|
+
|
|
154
|
+
```console
|
|
155
|
+
miramode stop -a <address>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Troubleshooting
|
|
159
|
+
|
|
160
|
+
`-v` logs every frame exchanged with the valve:
|
|
161
|
+
|
|
162
|
+
```console
|
|
163
|
+
$ miramode -v stop -a <address>
|
|
164
|
+
Connected to 1FE6A4BD-73A5-055E-6463-F4785E21D49D
|
|
165
|
+
Sending aa 55 00 ab 04 00 00 00 00 52
|
|
166
|
+
Received channel=1 opcode=0x01 payload=01
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Library usage
|
|
170
|
+
|
|
171
|
+
The API is async, built on [bleak](https://github.com/hbldh/bleak):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import asyncio
|
|
175
|
+
from miramode import Outlet, Shower
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def run_a_bath():
|
|
179
|
+
async with Shower("1FE6A4BD-73A5-055E-6463-F4785E21D49D") as shower:
|
|
180
|
+
for preset in await shower.presets():
|
|
181
|
+
print(preset.index, preset.name)
|
|
182
|
+
|
|
183
|
+
await shower.run_preset(2) # start the bath filling
|
|
184
|
+
await asyncio.sleep(300)
|
|
185
|
+
await shower.stop()
|
|
186
|
+
|
|
187
|
+
# or drive an outlet directly
|
|
188
|
+
await shower.set_outlets(Outlet.FIRST, temperature=39.0)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
asyncio.run(run_a_bath())
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Failures raise subclasses of `MiraError`: `NotConnected`,
|
|
195
|
+
`ResponseTimeout`, and `CommandFailed` when the valve rejects a command.
|
|
196
|
+
|
|
197
|
+
## Protocol notes
|
|
198
|
+
|
|
199
|
+
Messages in both directions share one frame layout:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
aa 55 <channel> <opcode> <length> <payload...> <checksum>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The checksum is the two's complement of the sum of the preceding bytes,
|
|
206
|
+
so a whole valid frame sums to zero modulo 256. Commands are written to
|
|
207
|
+
characteristic `267f0002-…` and replies arrive as notifications on
|
|
208
|
+
`267f0003-…`, one reply per command.
|
|
209
|
+
|
|
210
|
+
Three opcodes are implemented:
|
|
211
|
+
|
|
212
|
+
| Opcode | Meaning | Payload |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `0xab` | Set outlets | temperature (2 bytes, tenths of a degree, big endian), flow percentage, outlet bitfield |
|
|
215
|
+
| `0xb1` | Run preset | preset slot |
|
|
216
|
+
| `0x5d` | Read preset | preset slot; the reply echoes the slot then a 16 byte NUL-padded name |
|
|
217
|
+
|
|
218
|
+
The outlet field is a bitfield in a single byte — bit 0 is the first
|
|
219
|
+
outlet, bit 1 the second, bit 2 a third — rather than one byte per
|
|
220
|
+
outlet. A temperature of zero means "leave the setting alone", which is
|
|
221
|
+
what `stop` sends.
|
|
222
|
+
|
|
223
|
+
## Acknowledgements
|
|
224
|
+
|
|
225
|
+
Nigel Hannam's [protocol
|
|
226
|
+
documentation](https://github.com/nhannam/shower-controller-documentation)
|
|
227
|
+
was a useful reference for the older generation of these valves.
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
miramode/__init__.py,sha256=2Ve4LMMHkO6PsvaiN8Q0l-MX8wFLsIm4z2LkEv-2Vtk,15334
|
|
2
|
+
miramode/cli.py,sha256=k8kfqZYVbQJ76486aiXTSiGMzohFJNfZ_dvdKjHjwSo,7111
|
|
3
|
+
miramode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
miramode-1.0.0.dist-info/METADATA,sha256=vmUVPJMbmMBvVPRSx9_4TqveK1NJiPip__YF7cD34mQ,7000
|
|
5
|
+
miramode-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
miramode-1.0.0.dist-info/entry_points.txt,sha256=Ck9LyIcR5Ehl4GeHYxGMTtOgATiFTyAOs--71CHjRL8,47
|
|
7
|
+
miramode-1.0.0.dist-info/licenses/LICENSE,sha256=Y7yhFwGNqYRSMvFk9cxc_mxUD5oAbq-c6VeeTJTNoco,1066
|
|
8
|
+
miramode-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Shaw
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|