Alessandro Catorcini
← The notebook

Tinkering

Getting Joule to work with Home Assistant

The $150 Paperweight

The ChefSteps Joule is a beautifully designed sous vide circulator with one fatal flaw: it has no physical controls. No buttons to set temperature. No screen to show status. No knob to turn. Every interaction requires the companion app — an app built by a startup (ChefSteps) that was acquired by Breville, whose cloud infrastructure is shutting down in March 2026.

When the servers go dark, every Joule becomes a $150 paperweight. Unless someone figures out how to talk to it directly over Bluetooth.

This is the story of that attempt: building a Home Assistant integration that communicates with the Joule over Bluetooth Low Energy (BLE), and discovering that “just send some protobuf over BLE” is never as simple as it sounds. It took 30+ releases, 70+ commits, and three full days of debugging before I found the problem — and when I did, the fix was changing exactly two default values.

A note on what this is, and isn’t: everything below was done on a device I own, to keep that device working after its cloud is switched off — interoperability, not intrusion. I studied a protocol on the wire, broke no encryption, and circumvented no access control: the Joule authorizes a client by a physical button-press on the unit itself, something only whoever is standing next to it can do. This is a personal, non-commercial project.


Chapter 1: Building the House Before the Foundation

September 11, 2024 — The First Commit

A basic Home Assistant custom integration scaffold with placeholder BLE code. It sat dormant for 17 months. The ChefSteps app still worked. There was no urgency.

February 18, 2026 — The Urgency Arrives

ChefSteps announced the cloud shutdown. Suddenly, “nice to have” became “build it this weekend or your sous vide is dead.” In a single intense day, the integration was rebuilt from scratch following Home Assistant’s best practices:

  • DataUpdateCoordinator pattern — single BLE connection owner, entities read from shared state
  • Config flow — MAC address input with BLE validation
  • Five entities: temperature sensor, cooking switch, target temperature, cook time, temperature unit selector
  • Full test suite — 47 tests, 84% coverage
  • HACS support, CI/CD workflows, Lovelace dashboard card with cooking controls
  • User documentation

The integration was architecturally complete. Beautiful coordinator pattern. Clean entity separation. Comprehensive tests. It just needed to actually talk to the device.

I was confident that would be the easy part. Send protobuf, receive data. How hard could it be?


Chapter 2: The Protobuf Layer

February 19 — Decoding the Protocol

The Joule communicates using Protocol Buffers (protobuf) over BLE. The message format is documented by two existing open-source projects: chromeJoule, a Chrome extension that controlled the Joule via WebSocket, and JouleUWP, a Windows UWP app. Between them they describe the wire format in the clear.

I built a hand-rolled protobuf encoder/decoder — no external dependencies, just Python’s struct and dataclasses. The key message type is StreamMessage, a root envelope wrapping every command:

StreamMessage {
    handle: fixed32      // session identifier
    end: bool            // end-of-message marker
    senderAddress: bytes // who's sending
    recipientAddress: bytes // who's receiving
    // oneof: the actual command
    StartKeyExchangeRequest | SubmitKeyRequest | BeginLiveFeedRequest | ...
}

The BLE characteristic map (from chromeJoule):

  • 4322 — Write: send protobuf commands
  • 4323 — Read: receive protobuf responses
  • 4325 — Notify: “data ready” signals

Version 0.6.0 shipped. The theory: subscribe to 4325 notifications, send BeginLiveFeedRequest on 4322, read CirculatorDataPoint from 4323. Temperature data flows into Home Assistant. Done.

Everything that followed proved that theory wrong.


Chapter 3: The Silent Treatment

February 28 — The Day of Twenty Commits

This single day produced 20+ commits as I systematically worked through every BLE theory. It began with a migration from pygatt (which shells out to gatttool — unavailable in HA’s container) to bleak (HA’s standard async BLE library).

The first write went through. The Joule connected. Notifications were subscribed. A BeginLiveFeedRequest was written to characteristic 4322. The device acknowledged the GATT write.

And then… nothing.

No notification. No data. No error. The Joule accepted my data and did absolutely nothing with it. Temperature: 0.0°C. The connection dropped after 25 seconds.

Hypothesis 1: Wrong Write Mode

BLE has two write modes: “write with response” (the device ACKs at the application layer) and “write without response” (fire and forget). Maybe the Joule expects one but not the other?

Test: Tried both. Neither produced a response.

Result: Ruled out. But this testing revealed a real bug — I was using response=False on a characteristic that only supports [write] (Write Request), meaning my writes were being silently dropped by the GATT server. Fixed, but it didn’t solve the silence.

Hypothesis 2: Wrong Connection Method

Raw BleakClient.connect() might not register with Home Assistant’s BLE manager, preventing notification routing.

Test: Switched to establish_connection() from bleak-retry-connector plus async_ble_device_from_address() — HA’s canonical BLE path.

Result: Cleaner connection lifecycle, but still no notifications. Not the root cause, but the correct approach.

Hypothesis 3: Wrong Characteristic

Maybe the Joule sends data on a different characteristic than 4325?

Test: Subscribed to every notify-capable characteristic on the device.

Result: Nothing from any of them.

Hypothesis 4: Service Changed Blocking

A diagnostic subscription to the Service Changed characteristic (UUID 0x2A05) was hanging indefinitely, blocking everything after it.

Test: Removed the diagnostic subscription.

Result: This was a real bug — the hang prevented the actual notification subscription from executing. Fixed in v0.7.4. But fixing it didn’t produce any Joule responses.

Hypothesis 5: BLE Pairing Required

Maybe the Joule requires an encrypted BLE link via OS-level pairing before it processes commands?

Test: Called pair() on the BLE client.

Result: The device rejected with error 102 (AuthenticationCanceled). Worse, the pairing attempt blocked for 30 seconds, by which time the BLE supervision timeout had already killed the connection.

Key learning: the Joule does NOT use OS-level BLE pairing.

Hypothesis 6: StreamMessage Framing

Deeper reading of the chromeJoule source revealed potential framing issues — maybe end=True is required, maybe handle must be non-zero, maybe a Ping/Pong handshake is needed first.

Test: Set end=True, handle=1, added a Ping before operational commands.

Result: Still silent. But these seemed like correct fixes based on the reference projects, so I kept them.

By the end of February 28, I had 20+ commits and zero bytes received from the device. Every GATT write was acknowledged at the link layer. The device’s BLE stack was processing my packets. But the application firmware was ignoring every single one.


Chapter 4: The Key Exchange Revelation

The Key Exchange, Documented in the Open

Cross-referencing the open-source chromeJoule and JouleUWP projects against a packet capture of my own Joule and app session surfaced the critical missing piece: application-level key exchange authentication.

The Joule doesn’t use standard BLE pairing. It has its own protocol:

1. App sends StartKeyExchangeRequest
2. Joule's button LED starts flashing
3. User physically presses the button on top of the Joule
4. Joule sends StartKeyExchangeReply containing a secret_key
5. App sends SubmitKeyRequest with that key
6. Joule sends SubmitKeyReply confirming authentication
7. Only NOW does the Joule process any other commands

Without completing this handshake, the Joule silently discards ALL writes. The BLE stack ACKs them (the link layer processes the write) but the application firmware ignores the protobuf content entirely.

This explained everything. Every version from v0.7.0 through v0.8.x was sending perfectly valid protobuf commands to a device that wasn’t listening. I was shouting into a locked room.

The reference projects also documented the notification-triggered-read pattern: notifications on 4325 are signals (“data is ready”), not data. The actual protobuf response must be read from 4323 after each notification.

v0.9.0: Implementing the Real Protocol

I implemented the full authentication flow:

  • Send StartKeyExchangeRequest
  • Show a persistent notification telling the user to press the Joule button
  • Wait up to 60 seconds
  • On StartKeyExchangeReply: extract secret key, send SubmitKeyRequest
  • On SubmitKeyReply: mark as authenticated, persist key for reconnections
  • After auth: send BeginLiveFeed to start streaming temperature data

The code was correct. The protocol was right. But no notification ever arrived. The key exchange request was written, the Joule was sitting there, and… nothing. The button never flashed. The device didn’t even acknowledge the existence of the command.

I had solved the “what to send” problem. But the device still wasn’t processing what I sent.


Chapter 5: Chasing Ghosts

The next phase was the most demoralizing. I knew the protocol. I had the right commands. But something about how I was sending them was wrong, and the device gave me absolutely no feedback — just silence and a disconnection after 25 seconds.

The Address Problem (v0.9.2-v0.9.4)

Hypothesis: Maybe the addresses in the StreamMessage are wrong.

The reference projects showed that addresses are 8 bytes, not 6-byte MACs. The recipientAddress is extracted from BLE manufacturer advertising data (company ID 0x0159), and senderAddress is an arbitrary 8-byte app identifier.

I’d been sending 6-byte MAC addresses in 8-byte fields. But when I queried the BLE scanner for manufacturer data… mfr_keys=[]. The Joule wasn’t advertising manufacturer data to my adapter.

I went nuclear: five different address constructions from the MAC (padded, reversed, different suffixes) crossed with multiple message formats. Every variant sent, then waited 60 seconds.

Result: Zero responses to any variant.

The CCCD Hunt (v0.9.5-v0.9.7)

Hypothesis: Maybe bleak’s start_notify() isn’t actually enabling notifications at the GATT level.

A GATT descriptor dump showed the CCCD (Client Characteristic Configuration Descriptor) on 4325 was 0x0000 before my subscribe call. Suspicious. But after adding verification: CCCD was 0x0100 after subscribe — notifications were correctly enabled.

Result: CCCD ruled out. Bleak was doing its job.

The MTU Problem (v0.9.8-v0.9.9)

Hypothesis: My messages are too big for the MTU.

Added MTU logging: MTU = 23 (20-byte ATT payload). My full StreamMessage was 30 bytes.

At MTU = 23:

  • Write Command (no response) for >20 bytes: silently truncated
  • Write Request (with response) for >20 bytes: triggers Long Write — device ACKs but may not process

Maybe none of my 30-byte messages were reaching the application layer intact! Built compact variants that fit within 20 bytes. Attempted MTU negotiation via bleak internals and D-Bus. The device refused. MTU stayed at 23.

Result: Even with messages guaranteed to fit in a single ATT packet — zero notifications. And I later confirmed the iPhone also operates at MTU=23, so this was never the problem.

Proto2 Required Fields (v0.10.0)

Hypothesis: Maybe my encoding is technically wrong.

Found a real bug: the proto2 definition has required fields for senderAddress and recipientAddress. My “compact” messages omitted these entirely (no tag in the wire format) because b"" is falsy in Python:

if message.sender_address:  # b"" is falsy — skipped!
    result += encode_field_bytes(5, message.sender_address)

Proto2 required means the field tag MUST be present. The Joule’s nanopb parser would silently reject any message missing a required field.

Fixed: always encode fields 5 and 6, even when empty (2a 00 / 32 00).

Result: Message encoding was now proto2-compliant. Still no response.

The ESPHome Proxy Red Herring (v0.10.1)

Hypothesis: Maybe the BLE adapter matters.

The setup included a NUC with local BT adapters plus ESPHome Bluetooth Proxies. HA was auto-selecting a proxy for the connection. Proxies might not support notifications, manufacturer data, or MTU negotiation.

Added adapter preference logic: prefer local BlueZ adapters over ESPHome proxies.

Result: Connections went through the local adapter. ENOMEM errors from too many tracked BT devices disappeared. But the Joule’s behavior was identical — zero notifications, key exchange timed out, disconnection at ~25 seconds.

The Cloud Pivot That Wasn’t

Before continuing, I assessed the alternative: skip BLE, use WiFi/cloud.

  1. ChefSteps cloud is shutting down — could die any day
  2. WiFi setup requires BLE — the SetWifiRequest is sent over Bluetooth
  3. Breville+ app uses a different, un-reverse-engineered backend
  4. No open-source WiFi implementation exists

Verdict: BLE is the only path. There’s no escape hatch.


Chapter 6: The PacketLogger Breakthrough

March 1 — Capturing the Official App

After exhausting every application-layer theory, I needed to see what a working implementation actually does at the wire level. Used Apple’s Bluetooth PacketLogger on my own iPhone running the official ChefSteps app to capture a complete, successful connection to my own Joule.

The capture revealed the official app’s exact GATT operation sequence:

  1. Service Changed indications — The very first GATT operation: write 0x0200 (enable indications) to the CCCD on UUID 0x2A05. I never did this.
  2. SubmitKeyRequest with stored key — On reconnections, the app skips StartKeyExchangeRequest entirely and submits a stored key directly.
  3. MTU = 23 on iPhone too — Confirming MTU isn’t a NUC-specific problem.
  4. Empty addresses work — The app sends empty sender/recipient, same as my recent versions.

v0.11.0: Service Changed Indications

The Service Changed characteristic (UUID 0x2A05) is part of the standard GATT protocol. Some BLE peripherals require the client to enable indications on this characteristic before they process application-level commands. It’s a readiness signal.

Implemented enable_service_changed_indications() and an options flow to import the captured auth key (AABBCCDDEEFF00112233, placeholder — the real per-device key is redacted here).

Result: The CCCD write succeeded, but… still no response to key submission. Three iterations followed:

  • v0.11.1: BlueZ rejected a raw CCCD write with NotPermitted. Switched to start_notify() which uses BlueZ’s D-Bus API correctly.
  • v0.11.2: Verified CCCD readback shows 0x0200 (indications, not just notifications). Added empty-address SubmitKey variant matching iOS. Added 0.5s delay after Service Changed.

Still silent. Service Changed indications were correctly enabled, but the Joule still ignored my commands.


Chapter 7: The Smoking Gun

Parsing the Packet Capture at the Byte Level

With Service Changed ruled out as the sole fix, I wrote a custom Python parser for Apple’s PacketLogger .pklg binary format and extracted every BLE operation — 1,762 packets covering the complete iOS app session.

And there, buried in the hex dumps of ATT Prepare Write operations, I found it.

The Two Differences

Difference 1: The Handle

The iOS app generates a large random 31-bit handle for each StreamMessage:

iOS:  0d c2dd0a00  →  handle = 0x000ADDC2 (712,130)
Mine: 0d 01000000  →  handle = 0x00000001 (1)

The chromeJoule source confirms this: Math.floor(Math.random() * Math.pow(2, 31)). A random session identifier, different for each message. I had been sending 1 for every message since v0.7.9.

Difference 2: The end Field

The iOS app never sends field 4 (end) in any StreamMessage. My code always included end=true:

Mine: ... 20 01 ...  →  field 4 = true (2 bytes)
iOS:  ... (absent) ...  →  field 4 not present

Two bytes. The end field was two bytes I was adding that the official app never sends.

The Side-by-Side

iOS  (24 bytes): 0d c2dd0a00 2a00 3200 92080c 0a0a AABBCCDDEEFF00112233
Mine (26 bytes): 0d 01000000 2001 2a00 3200 92080c 0a0a AABBCCDDEEFF00112233
                              ^^^^
                              end=true — NOT IN iOS APP

The fields after end are identical: empty sender, empty recipient, SubmitKeyRequest with the same 10-byte key. The messages are structurally the same. But mine has two extra bytes that the Joule’s nanopb parser apparently doesn’t expect in this context.

The Irony

Remember v0.7.9? “Fix StreamMessage framing: add end=True, handle=1.” That commit — where I “fixed” the framing based on my reading of the chromeJoule source — was the one that broke everything. I read the reference code, saw an end property, and assumed it needed to be true. I saw a handle field and set it to 1 instead of generating a random value.

The chromeJoule project’s makeStreamMessage() doesn’t set end by default. And it generates a random handle. I had the source code right in front of me and still misread it.

The Fix

Two lines changed in joule_proto.py:

# Before
handle: int = 1      # field 1, fixed32
end: bool = True      # field 4, bool

# After
handle: int = 0       # field 1, fixed32 — 0 = auto-generate random
end: bool = False      # field 4, bool — omitted when False

And in encode_stream_message():

handle = message.handle if message.handle != 0 else random.randint(1, 2**31 - 1)

That’s it. That’s the fix that took 30+ versions and three days to find.


Chapter 8: First Contact

v0.12.0 — It Works

14:39:37.041 SubmitKey-empty-addrs (24 bytes): 0d23ca85342a00320092080c0a0aAABBCCDDEEFF00112233
14:39:37.116 Write succeeded for SubmitKey-empty-addrs
14:39:37.147 NOTIFICATION on 4325: 4 bytes, raw=57dae480
14:39:37.233 READ from 4323: 32 bytes
14:39:37.233 Got SubmitKeyReply from 4323-read! result=0
14:39:37.233 Authentication successful (empty addrs)!

The first notification. After three days, 70+ commits, and a dozen chapters of debugging, the Joule spoke back.

14:39:37.233 BeginLiveFeed (30 bytes)
14:39:37.308 Write succeeded for BeginLiveFeed
14:39:37.357 NOTIFICATION on 4325: 4 bytes
14:39:38.020 READ from 4323: 98 bytes
14:39:38.021 CirculatorDataPoint from 4323-read: bath_temp=21.63, step=0

21.63°C — room temperature. The water in the Joule’s chamber. Real data, streaming live, ~1 notification per second. The connection was stable. No more 25-second wall. No more silence.

The Joule was alive.


Chapter 9: What I Learned

The Hypothesis Scorecard

# Hypothesis Versions Result
1 Wrong write mode (response flag) v0.7.1 Real bug found — wrong mode, but not the root cause
2 Wrong connection method v0.7.2 Correct fix — but not the root cause
3 Wrong characteristic v0.7.3 Ruled out
4 Service Changed blocking v0.7.4 Real bug found — hung the setup, but not the root cause
5 BLE pairing required v0.7.7, v0.9.5 Ruled out (device rejects pairing)
6 StreamMessage framing (end, handle) v0.7.9 INTRODUCED THE ROOT CAUSE
7 Application-level key exchange required v0.9.0 Correct — essential protocol step
8 Wrong address format (6 vs 8 bytes) v0.9.3 Real issue, but not the root cause
9 Missing manufacturer data v0.9.4 Not available from my adapter, but not required
10 CCCD not enabled v0.9.6-v0.9.7 Ruled out (verified enabled)
11 MTU truncation v0.9.8-v0.9.9 Real concern, but iOS also uses MTU=23
12 Proto2 required fields omitted v0.10.0 Real bug found — but not the root cause
13 ESPHome proxy interference v0.10.1 Real issue — but not the root cause
14 BlueZ ENOMEM v0.10.1 Real issue — but not the root cause
15 Cloud/WiFi alternative Not viable (shutting down)
16 Service Changed indications required v0.11.0-v0.11.2 Required — but not sufficient alone
17 Random handles + no end field v0.12.0 THE ROOT CAUSE

I found and fixed six real bugs along the way (write mode, connection method, Service Changed hang, proto2 fields, ESPHome proxy, ENOMEM). Each was a legitimate problem worth fixing. But none of them was the reason the Joule wouldn’t talk.

The root cause was a two-byte field (end=true) that I added in v0.7.9, based on a misreading of the reference source code. It sat there, silently breaking every message, through 30+ subsequent versions and dozens of other fixes.

Why Was This So Hard to Find?

  1. Zero feedback from the device. The Joule’s BLE stack ACKs every write at the link layer. The GATT server processes the packet correctly. Then the application firmware silently discards it. No error code. No rejection. No indication that anything is wrong. You’re debugging against a black box that says “yes, got it” to everything but acts on nothing.

  2. The fix looked like a bug. When I read the reference source and saw the end field, adding end=true seemed obviously correct. It’s a boolean that marks the end of a message. Of course it should be true for standalone messages. I “fixed” the framing and moved on, never questioning it again.

  3. Multiple real bugs masked the root cause. I kept finding and fixing legitimate issues — wrong write modes, proto2 encoding, CCCD problems, proxy connections. Each fix felt like progress. Each one was necessary but insufficient. The real problem was hiding behind layers of other problems.

  4. No way to capture traffic on HAOS. The Home Assistant Operating System doesn’t expose btmon or other HCI-level tools. I could see what my code sent but couldn’t compare it to what a working app sends at the wire level. It took an iOS PacketLogger capture on a separate device to see the actual bytes.

  5. The difference was tiny. Two bytes. In a 24-byte message, the difference between mine and the iOS app’s was 20 01 (end=true) at offset 5. Everything else was structurally correct. You need byte-level analysis of a known-good capture to spot something like that.

The Lesson That Cracked It

The thing that finally cracked the case wasn’t more code analysis or another hypothesis — it was looking at raw hex bytes from a working device and spotting a two-byte discrepancy. No amount of reasoning about the protocol could substitute for the ground truth of a real capture.

So the lesson I’d tattoo on my own forehead for next time: when you’re debugging against a silent device, capture a working implementation’s traffic first. Every hour of blind hypothesis testing I burned could have been saved by ten minutes of PacketLogger.

On Protobuf Strictness

The Joule uses nanopb, a C-based protobuf implementation for embedded systems. Unlike Google’s canonical protobuf library (which is lenient about unknown fields), nanopb on this device appears to reject messages with unexpected fields outright. The end field (field 4) was technically valid protobuf — it just wasn’t expected by the Joule’s message handler, which apparently treats any StreamMessage with field 4 present as malformed.

This is a reminder that protobuf “compatibility” guarantees (unknown fields are ignored) only apply when both sides implement the spec correctly. Embedded protobuf implementations may not.


Chapter 10: Beyond the Adapter

The Proxy Problem

With the Joule working reliably over a local Bluetooth adapter, there was one remaining limitation: Home Assistant’s ESPHome Bluetooth Proxies. Many HA users run ESPHome devices as BLE proxies — cheap ESP32 boards scattered around the house that extend Bluetooth range. HA’s BLE stack transparently routes connections through them.

For the Joule, proxies were a problem. The integration’s notification-triggered-read pattern — subscribe to 4325, get a signal, read 4323 — relies on BLE notifications being forwarded from the ESP32 back to HA over WiFi. ESPHome proxies silently drop these notifications. No error, no indication — they just never arrive. The same silence I’d spent three days debugging, but this time with a known cause.

Since v0.10.1, the integration had simply preferred local adapters over proxies. If you had a local Bluetooth adapter, the Joule worked. If you only had proxies… it didn’t.

v0.18.0: Polling as a Substitute

The key insight: the Joule updates characteristic 4323 server-side regardless of whether anyone is listening for notifications. The data is always there — I just couldn’t get the “data ready” signal through a proxy. The fix was obvious: poll.

When connected via an ESPHome proxy, the coordinator now starts a background task that reads 4323 every second. If the data has changed since the last read, it’s decoded and processed — exactly the same path as notification-triggered reads, just initiated by me instead of the device.

The implementation required:

  • Proxy detection in the BLE layer: local adapters have MAC-formatted source addresses (AA:BB:CC:DD:EE:FF), proxies have hex IDs
  • Background polling task using async_create_background_task() — a background task that doesn’t block HA’s event loop shutdown
  • Deduplication: the Joule returns the same bytes on 4323 until new data arrives, so identical consecutive reads are filtered
  • Lifecycle management: the poller starts after subscribe, stops on reconnect (to avoid stale tasks), and cancels cleanly on shutdown

The polling approach means commands (write-with-response on 4322) still work through proxies — the ESP32 handles those fine. It’s only the notification path that’s broken, and polling substitutes for that.

Seven new tests cover the proxy poller: startup, deduplication, reconnect cleanup, shutdown, and the faster poll interval used in _try_write_and_wait() when waiting for command responses.

The Scorecard Update

# Hypothesis Versions Result
13 ESPHome proxy interference v0.10.1, v0.18.0 Real issue — now worked around with 4323 polling

What was once just “prefer local adapters” is now a full proxy-compatible mode. The Joule works with whatever Bluetooth path HA provides.


Chapter 11: The Ghost in the Cache

102°F Becomes 217°F

With proxy support deployed, the first real-world test revealed a new problem: the Lovelace card displayed a water temperature of 217°F when the actual temperature was 102°F. The math was unmistakable: 102 × 1.8 + 32 = 215.6 ≈ 217. A classic double °C→°F conversion.

The card code had a manual conversion:

const currentTempDisplay = unit === "°F"
  ? ((parseFloat(currentTempState.state) * 9) / 5 + 32).toFixed(1)
  : currentTemp;

But the sensor entity declares device_class=TEMPERATURE with native_unit_of_measurement=°C. Home Assistant automatically converts such sensor states to the user’s preferred unit system. So currentTempState.state was already in °F — and the card converted it again.

The Fix That Didn’t Fix

The fix was straightforward: check the sensor’s actual unit_of_measurement attribute before converting, and only convert when the sensor’s unit differs from the desired display unit. Card v0.7.1 shipped in v0.18.1.

But the user reported it was still broken. The same 217°F.

Suspecting a deeper issue, I added a coordinator-level conversion — if the integration’s unit preference was °F, convert bath_temp from °F to °C before the sensor sees it (v0.18.2). This broke everything else: target temperature controls showed values in the wrong unit, and switching between °F and °C produced inverted results. The Joule reports bath_temp in °C always. The coordinator conversion was wrong, reverted in v0.18.3.

The Real Problem: Browser Cache

The card JS is served from /local/joule-sous-vide-card.js. Browsers cache aggressively. The user’s browser was still running the old v0.7.0 card with the unconditional conversion, despite the integration files being updated. The v0.7.1 fix was correct all along — it just never loaded.

The final fix (v0.18.4): append the integration version as a query parameter to the Lovelace resource URL — /local/joule-sous-vide-card.js?v=0.18.4. On startup, the integration removes stale resource entries and registers the versioned URL. Every future update automatically busts the browser cache.

The Meta-Lesson

Three releases chasing a temperature bug that was already fixed. The card fix worked. The coordinator “fix” was wrong. The real culprit was HTTP caching — a problem in a completely different layer of the stack. When a fix doesn’t seem to take effect, verify it’s actually running before adding more fixes on top.

Epilogue: The Joule Lives

The integration now works. The Joule connects over unencrypted BLE, authenticates with a stored key (or a fresh key exchange requiring a button press), and streams live temperature data at ~1 Hz — either via notifications (local adapter) or background polling (ESPHome proxy). Home Assistant shows the current water temperature, and the cooking controls (start/stop, target temp, cook time) are ready for the next sous vide session.

The ChefSteps cloud can shut down whenever it wants. The Joule doesn’t need it anymore.


Technical Details

The Complete GATT Sequence (Working)

1. Connect to Joule (MTU=23, unencrypted)
2. Enable Service Changed indications (0x0200 to CCCD on 0x2A05)
3. Subscribe to notifications on 4325
4. Send SubmitKeyRequest on 4322 (24 bytes, random handle, no end field)
5. Receive notification on 4325 (4-byte signal)
6. Read SubmitKeyReply from 4323 (32 bytes)
7. Send BeginLiveFeedRequest on 4322
8. Receive CirculatorDataPoint every ~1 second via notification+read

Message Format (v0.12.0)

SubmitKeyRequest (24 bytes):
  0d XXXXXXXX    field 1: handle (random fixed32)
  2a 00          field 5: senderAddress (empty bytes)
  32 00          field 6: recipientAddress (empty bytes)
  9208 0c        field 130: SubmitKeyRequest (12 bytes)
    0a 0a YYYY.. field 1: secret_key (10 bytes)

Tools Used

  • chromeJoule — Protocol reference (protobuf definitions, SDK patterns)
  • bleak — Python async BLE library
  • Apple Bluetooth PacketLogger — HCI packet capture on iOS
  • Home Assistant — The platform it all runs on

This post is dedicated to everyone who has ever debugged a BLE peripheral that ACKs your writes but ignores your commands. May your packet captures be swift and your hex dumps revealing.

#home-assistant#bluetooth#reverse-engineering#ble#protobuf#sous-vide