Skip to main content

Control Mira Mode digital showers and bath fillers over Bluetooth LE

Project description

mira-mode-control

Lint

Control Mira Mode digital showers and bath fillers from Python, over Bluetooth Low Energy.

Mira Mode valves (now sold under Kohler) ship with no interface other than BLE and the vendor's phone app, which means no Home Assistant, no automation, and no way to start filling the bath from anywhere but the bathroom. This package implements the valve's BLE protocol directly, so you can drive it from a Raspberry Pi, a laptop, or anything else with a Bluetooth radio.

Use at your own risk. This drives real plumbing that produces real hot water. The protocol was reverse engineered, not documented by the vendor. Don't run it unattended until you've watched it behave.

Supported hardware

This implements the protocol used by current-generation valves, which advertise the GATT service 267f0001-eb15-43f5-94c3-67d2221188f7. Check yours with miramode info — it reports whether the valve speaks a protocol this package understands.

Earlier Mira Mode valves advertise bccb0001-ca66-11e5-88a4-0002a5d5c51b and use an unrelated, CRC-16 based protocol that this package does not support.

Developed against a dual outlet shower and bath filler unit. Other valves in the range should work, but only the outlets and presets your unit actually has will do anything.

Requirements

  • Python 3.10 or newer
  • A Bluetooth Low Energy adapter
  • On Linux, BlueZ
  • The valve must be paired with the host first, through your operating system's Bluetooth settings. The valve refuses to accept commands over an unbonded connection.

Installation

pip install miramode

Or, with uvuv tool install miramode to get just the miramode command, or uv add miramode to use it as a library in a project.

From source

git clone git@github.com:ryan-shaw/mira-mode-control.git
cd mira-mode-control
uv sync

That installs the versions pinned in uv.lock into .venv. Prefix the commands below with uv run to use it.

Development

The same checks CI runs:

uv run ruff check .
uv run ruff format --check .
uv run ty check

Use uv run ruff format . to apply formatting.

Command line usage

Find your valve:

$ miramode scan
1FE6A4BD-73A5-055E-6463-F4785E21D49D  Mira 004C Bathroom

Addresses are MAC addresses on Linux and Windows, and opaque UUIDs on macOS. Every command below takes one with -a.

Status

To see what the valve is doing right now, without changing anything:

$ miramode status -a <address>
Running:      yes, outlet 1
Temperature:  38.4C
Target:       41.0C
Flow:         75%

When nothing is running, only the measured temperature is shown, since the valve reports no target and a resting flow value while idle. Add -v to see the undecoded reply alongside it.

To follow it instead of taking a single reading — useful while a bath fills — use watch, which prints a line whenever the state changes and runs until interrupted:

$ miramode watch -a <address>
19:18:26  idle            25.5C
19:18:29  outlet 1        25.5C -> 40.0C  flow 60%
19:18:36  outlet 1        31.2C -> 40.0C  flow 60%
19:18:39  idle            25.5C

--interval sets how often it re-reads, defaulting to two seconds.

Presets

Presets are the programmes stored in the valve — each carries its own target temperature and run time, and they're how the vendor app starts the shower or fills the bath. Listing them only reads from the valve and runs no water:

$ miramode presets -a <address>
  1  Default           38.0C  outlet 2      30:00  flow 88%
  2  Default Bathfill  42.0C  outlet 1  50 litres  flow 88%

A preset either runs for a time or delivers a volume, so each shows one or the other. This is also the easiest way to find out which outlet feeds which fixture on your unit: above, the bath fill runs outlet 1, so outlet 2 is the shower.

Flow is on the same 0 to 100 scale that outlets --flow takes. The vendor app shows a quarter of this, on a 0 to 25 scale, so a preset reading 88% here appears as 22 in the app.

Factory-fitted presets are numbered from 1; slot 0 is not used. Start one by number:

miramode start -a <address> 2

To create your own, or replace an existing one:

miramode save-preset -a <address> 3 --name "Evening Bath" \
    --temperature 40.5 --volume 65 --first --flow 72

A preset either runs for a time or delivers a volume, so give exactly one of --volume (litres) or --duration (seconds, or mm:ss). Pick the outlets with --first and --second as for outlets. The slot is read back afterwards and the stored preset reported, rather than trusting the valve's acknowledgement.

save-preset writes every field, so it replaces a preset rather than amending it — there is no partial update. Slots occupied by the presets your valve shipped with are fair game, but they are not recoverable afterwards, so note down what presets reports before overwriting one.

miramode delete-preset -a <address> 3

Outlets

To run an outlet directly, at a temperature you choose:

miramode outlets -a <address> --first --temperature 39

Use --second for the second outlet, and both flags together to run both at once. A command states the complete desired state, so any outlet you don't name is switched off. --flow sets the flow rate as a percentage, defaulting to 100.

Which physical fixture is "first" depends on how your unit is plumbed; presets will tell you, since each preset reports the outlet it uses.

To shut everything off, including a running preset:

miramode stop -a <address>

Troubleshooting

-v logs every frame exchanged with the valve:

$ miramode -v stop -a <address>
Connected to 1FE6A4BD-73A5-055E-6463-F4785E21D49D
Sending aa 55 00 ab 04 00 00 00 00 52
Received channel=1 opcode=0x01 payload=01

Library usage

The API is async, built on bleak:

import asyncio
from miramode import Outlet, Shower


async def run_a_bath():
    async with Shower("1FE6A4BD-73A5-055E-6463-F4785E21D49D") as shower:
        for preset in await shower.presets():
            print(preset.index, preset.name)

        await shower.run_preset(2)  # start the bath filling
        await asyncio.sleep(300)
        await shower.stop()

        # or drive an outlet directly
        await shower.set_outlets(Outlet.FIRST, temperature=39.0)


asyncio.run(run_a_bath())

Failures raise subclasses of MiraError: NotConnected, ResponseTimeout, and CommandFailed when the valve rejects a command.

Protocol notes

Messages in both directions share one frame layout:

aa 55 <channel> <opcode> <length> <payload...> <checksum>

The checksum is the two's complement of the sum of the preceding bytes, so a whole valid frame sums to zero modulo 256. Commands are written to characteristic 267f0002-… and replies arrive as notifications on 267f0003-…, one reply per command.

Four opcodes are implemented:

Opcode Meaning Payload
0xab Set outlets temperature (2 bytes, tenths of a degree, big endian), flow percentage, outlet bitfield
0xb1 Run preset preset slot
0x5d Read preset preset slot; the reply is described below
0x2b Read state a constant 0x02; the reply is described below
0xdd Write preset the same 61 byte layout the read returns
0x44 Read name none; the reply is a 16 byte NUL-padded name
0x40 Read serial number a constant 0x01; the reply is NUL-padded ASCII digits
0x41 Read manufacture date none; the reply is four packed bytes

The manufacture date packs years since 2000, then the day, then a nibble holding the month followed by twelve bits of minutes past midnight. The month has to be the nibble rather than the day, since a day needs five bits — and the serial number embeds the same digits, which confirms it.

A preset reply is 61 bytes:

Bytes Meaning
0 the slot, echoed back
1-31 name, NUL-padded ASCII
33-34 duration in seconds, or zero if the preset delivers a volume
35-36 volume in litres, or zero if the preset runs for a time
37 outlets in bits 2-4, plus the top two bits of the temperature
38 low byte of the temperature, in tenths of a degree
39 flow

The outlet bits sit two places higher here than in the bitfield sent to 0xab. An unconfigured slot answers with zeroes where the name belongs, and an invalid one echoes the wrong slot number.

Writing a preset sends that same layout back, so every preset read off a valve re-encodes to the bytes it arrived as — which is how the encoding was checked. Clearing a slot writes just the slot number and zeroes. Note the name field is 32 bytes on the wire but only its first 31 are read back, so names are limited to 31 characters. The valve stores temperature to a tenth of a degree, though the vendor app rounds it to whole degrees.

The state reply carries more than is decoded here. These offsets into its payload were confirmed by commanding known values and reading them back, and everything else is left in State.raw:

Bytes Meaning
10-11 target temperature, tenths of a degree, big endian
12 flow percentage
13 outlet bitfield, same layout as the one sent to 0xab
14-15 measured water temperature, tenths of a degree, big endian

Note that replies to 0x2b use the same 0x01 opcode as an ordinary acknowledgement, just with a long payload, so a reply cannot be identified by its opcode alone.

The outlet field is a bitfield in a single byte — bit 0 is the first outlet, bit 1 the second, bit 2 a third — rather than one byte per outlet. A temperature of zero means "leave the setting alone", which is what stop sends.

Acknowledgements

Nigel Hannam's protocol documentation was a useful reference for the older generation of these valves.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

miramode-2.0.0.tar.gz (46.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

miramode-2.0.0-py3-none-any.whl (19.2 kB view details)

Uploaded Python 3

File details

Details for the file miramode-2.0.0.tar.gz.

File metadata

  • Download URL: miramode-2.0.0.tar.gz
  • Upload date:
  • Size: 46.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for miramode-2.0.0.tar.gz
Algorithm Hash digest
SHA256 82f8152f65112ee622aa7e4c2a819c3bf15351694babe7cda9cdb9f9f180465b
MD5 9d4e570480d2858ea1ab928cd2c15795
BLAKE2b-256 96462e8572fc42c0fd8839370cb119299156b64df7ba2bc1202b652641812848

See more details on using hashes here.

File details

Details for the file miramode-2.0.0-py3-none-any.whl.

File metadata

  • Download URL: miramode-2.0.0-py3-none-any.whl
  • Upload date:
  • Size: 19.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for miramode-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ef49078d548a0fbefa36fd8ae0d4abe7d162201e1f78592e8ce3d8760f9844e1
MD5 94508868242e9a3422516b25284f6dbf
BLAKE2b-256 c8595da52f03a870a1729d4fd5ac0a9f97a141dade0573bdfa1e7c5074b939fd

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page