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 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
+ [![Lint](https://github.com/ryan-shaw/mira-mode-control/actions/workflows/lint.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ miramode = miramode.cli:main
@@ -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.