Vulkan on Mali-G52 — limited Panfrost / panvk-bifrost in Brave

panvk — the Mesa Vulkan driver for Arm Mali GPUs — supports the modern Valhall generation upstream. The older Bifrost generation (Mali-G31, G51, G52, G72, G76) is implemented far enough to enumerate adapters but Mesa keeps it behind an explicit opt-out: PAN_I_WANT_A_BROKEN_VULKAN_DRIVER=1. That name is not subtle. It is the upstream maintainers stating, accurately, that this code path is not a Khronos-conformant Vulkan implementation.

This post announces a downstream package, mesa-panvk-bifrost, that takes that opt-out path and turns it into something specific: good enough to host Brave’s Vulkan GPU process on Mali-G52 hardware. Not good enough for general-purpose Vulkan, not conformant, not upstream-bound.

What ships

mesa-panvk-bifrost is a downstream-patched Mesa 26.0.6 Vulkan ICD, co-installed at /usr/lib/panvk-bifrost/ alongside the system Mesa. It does not replace anything. Opt-in is per-process, either via VK_ICD_FILENAMES or via the brave-vulkan launcher that ships with the package.

Available as an Arch Linux ARM package from the marfrit repo (packages.reauktion.de):

pacman -S mesa-panvk-bifrost

On a PineTab2 (RK3566, Mali-G52 r1 MC1): Brave’s chrome://gpu reports „Vulkan backend — Mali-G52 r1 MC1“ under Integrated GPU.

What „limited and non-certified“ actually means here

Non-certified. PAN_I_WANT_A_BROKEN_VULKAN_DRIVER=1 still has to be set; the launcher (brave-vulkan) sets it for you, but the label is accurate. For context only: a full dEQP-VK 1.3.10.0 sweep against r5 ran 2.26 million tests at a 97.65% runnable-pass rate — shape, not credentials. Not a Khronos submission, not a conformance claim.

Limited. Bifrost only. Primary target is Mali-G52 r1 MC1 (Bifrost gen-2, PAN_ARCH 7) in RK3566 — PineTab2, PineNote, Quartz64-B, ODROID-M1S. Same code path expected to work on G31, G72, G76; none hands-on-validated here. Valhall hardware (Mali-G610 etc.) is served by upstream panvk directly — this package is not for those.

Downstream. These patches are not being upstreamed. They live in the marfrit-packages overlay and ship via the ALARM repo above.

Lineage

  • r1–r4 — extension enablement (VK_KHR_robustness2, VK_EXT_transform_feedback) plus a NIR pass that decomposes XFB primitives into shapes Bifrost can emit.
  • r5fragmentStoresAndAtomics = true (Chromium Dawn gate). +32 atomic-operations CTS tests, zero regressions.
  • r6VK_EXT_legacy_dithering advertised. Five-line backport from Mesa main.
  • r7 — XFB packed-varying channel-extract SIGSEGV fix.
  • r9maxImageDimension3D bumped 512 → 2048. The revision that actually unblocked Dawn adapter acceptance. (r8 hit a CTS version-skew issue and was abandoned mid-Phase-3.)

Where the code lives

If your machine is one of the Bifrost SBCs above and you want the Chromium GPU process running on real Vulkan rather than llvmpipe, this is now an option.

Eight ARM cores were doing video decode. Then they stopped.

Every modern ARM SoC ships with a dedicated video decoder block sitting next to the CPU cores. On the Rockchip side: rkvdec (RK3399 — H.264, HEVC, VP9), plus hantro-vpu (RK3399 / RK3568 / RK3588 — MPEG-2, VP8, plus a separate RK3588 instance for AV1). On the Broadcom side: a small HEVC decoder block behind rpi-hevc-decon the Pi 5 / CM5. None of these are exciting silicon. They’re 2018-vintage fixed-function pipelines mostly bought for the box checking. But they’re there, they cost a tenth of a watt to wake up, and they decode video.

For about six months our fleet shipped with them mostly idle. The ARM cores chewed software decode and overheated for the privilege.

This is a status note on getting them to do their job.

The current state, eight hours into a session

Device SoC What the silicon decodes today How
Pinebook Pro RK3399 H.264, HEVC, VP8, VP9, MPEG-2 — including H.264 Hi10P + HEVC Main10 rkvdec + hantro via libva-v4l2-request-fourier
CoolPi CM5 GenBook RK3588 H.264, HEVC, VP8, VP9, MPEG-2, AV1 rkvdec (VDPU381 H.264/HEVC/VP9) + hantro (legacy + dedicated AV1 instance)
PineTab2 RK3566 H.264, MPEG-2, VP8 hantro
Pi 5 CM5 portable BCM2712 HEVC rpi-hevc-dec (kernel side at v4 since July 2025, still not in mainline as of May 2026)

Each cell required either a kernel patch, a userspace plumbing fix, or both. Some still don’t work and probably never will. Two stories from today’s iteration:

Story one: H.264 Hi10P on the RK3399 silicon „didn’t decode“

The Pinebook Pro’s RK3399 advertises Hi10P (10-bit H.264) in its kernel ctrl table. Decode-empirically, it produced uniform-black frames. The phase-7 close note said „kernel advertisement is aspirational“ — possibly the silicon doesn’t actually do this, possibly Rockchip’s marketing got ahead of the hardware.

Today’s empirical retest: the first five frames of the test fixture are a fade-in. The decode was correct; it was decoding correctly to black, because the source was black. Seek past frame six and the silicon produces bit-exact-correct decoded frames.

Lesson learned: Do not use Big Buck Bunny intro frames for bit-exactness verification. Future contributors are encouraged to verify they are not testing the title card.

The actual bug was elsewhere. FFmpeg’s Kwiboo n8.1 V4L2 hwaccel maps the RK3399 10-bit NV15 capture format to AV_PIX_FMT_YUV420P10 but then deliberately blanks the transfer-format list — the author expected consumers to call av_hwframe_map and chain a swscale unpack, but hwdownload doesn’t do that chaining. Result: kernel decodes fine, userspace never sees the bytes.

The fix is sixty lines: implement NV15→P010 unpack inside v4l2request_transfer_data_from, advertise P010 in transfer_get_formats.
Shipped as patch 0002 on ffmpeg-v4l2-request-fourier pkgrel=5. Twenty-frame mid-fixture decode is now byte-identical to libavcodec software reference. The Phase 5 reviewer suggested a hardening guard for malformed callers, which is now also in.

If you have an anime fansub collection from 2014, the Pinebook Pro now decodes it in hardware. We assume this is a niche use case.

Story two: VP9 on RK3588 took eight months of upstream review

VP9 hardware decode on the RK3588’s VDPU381 block was implemented by Detlev Casanova and the Collabora team; the kernel side merged into Linux 7.0 mainline in February 2026. AV1 support followed in the same window. The actual RK3588 H.264 + HEVC + AV1 path is mainline as of this writing.

VP9 — different story. Same hardware, same block; AV1 had to land first apparently for political reasons we won’t pretend to understand. As of May 2026 the VP9-on-VDPU381 work is on an out-of-tree fork maintained by D.V.A.B. Sarma. Collabora’s own blog post says „we hope to send a v1 to linux-media soon.“ It is currently v8. v1 has not been sent.

We grabbed the three patches as kernel-agentscope-tagged additions to
fleet/ampere.yaml, rebuilt the kernel package, and verified: twenty-frame VP9 decode via the rkvdec block is byte-identical to the software reference.

The new linux-ampere-fourier-7.0rc3.kafr2-1 kernel package ships with these patches integrated. Available now at packages.reauktion.de/arch/aarch64/ for any RK3588 host on the [marfrit] repo.

What the numbers actually look like

Workload CPU on ARM cores (software decode) CPU on ARM cores (VPU-offloaded) CPU freed
1080p30 H.264, RK3399 (2×A72+4×A53) 60–80% on a single fast core ~5% (mostly demux + audio) One A72 core’s worth
1080p30 VP9, RK3588 (4×A76+4×A55) 25–45% spread across cores ~3% ~one A76 core, fanning out
1080p24 AV1, RK3588 30–60% (8-core spread, dav1d) „speed=1.36x“ through hwdownload, fanless Several A76 + A55 cores
4K HEVC, RK3399 basically doesn’t bit-exact, fanless All eight cores‘ worth in the limit

The watt numbers are the same shape. Software VP9 at 1080p draws roughly four to seven watts of CPU on the Pi 5 / RK3588 class of SoC, sustained. Hardware decode of the same content draws under one watt. For a battery-powered ARM laptop that’s a three-to-five-times session-time improvement; for the global Pi 5 install base it’s electrons that were being spent on YouTube reviews of Apple products and are now not.

Whether that matters depends on your stance on YouTube reviews of Apple products.

What’s still software-decoded, on purpose

A few codec/host combinations are scoped out and we recommend they stay scoped out:

  • HEVC on RK3588. Fixable in principle — there’s a kernel oops in rkvdec_hevc_prepare_hw_st_rps from an uninitialized stack variable that has a one-line fix; the libva side needs another small patch to populate the EXT_SPS_RPS controls. We have all three issues filed, all three closed EWONTFIX. Reason: the fleet doesn’t use HEVC for anything we haven’t already covered. YouTube doesn’t serve HEVC. Netflix serves HEVC but only behind Widevine L1, which we don’t have on ARM, and the
    silicon for the one HEVC use case we have (archive playback on the RK3399 laptop) already works.
  • H.264 Hi10P on the libva path (RK3588). Decode-side libva ctrl-submission is byte-different from ffmpeg’s; the kernel rejects with V4L2_BUF_FLAG_ERROR. Fixable with a two-to-four-hour debug session. The kdirect (ffmpeg -hwaccel v4l2request) path works bit-exact. Anyone who needs Hi10P decode through a browser-side libva consumer on RK3588 can speak up; nobody has. The use case for ten-bit H.264 is currently anime fansubs. The use case for libva-routed ten-bit H.264 anime fansubs is even narrower.
  • HEVC on the Raspberry Pi 5. Kernel side is ready — Jonas Karlman’s rpi-hevc-decdriver has been on the linux-media patchwork at v4 since July 2025. It is still not merged. The V4L2 stateless HEVC controls don’t carry enough of the bitstream’s syntax fields for our backend to populate them losslessly; the strict driver rejects what the lenient drivers accept. We forked a userspace path called daedalus-fourier that toys with running codec firmware on the VideoCore VII programmable cores (the QPUs Broadcom marketed as a GPU but never shipped vendor codec firmware for, possibly because the licensing math at scale stopped making sense). Wax-and-feathers research-track. Don’t hold your breath.

Architecturally

A working hardware decode stack on the Rockchip / Pi 5 fleet has the following layers:

                ┌─────────────────────┐
                │  firefox / chromium │  ← VAAPI consumer
                │   /  mpv vaapi /    │     (browser does the demux,
                │   ffmpeg-vaapi      │     hands a frame at a time)
                └──────────┬──────────┘
                           │ libva ABI
                ┌──────────▼──────────────────┐
                │ libva-v4l2-request-fourier  │  ← us
                │  (our libva backend)        │
                └──────────┬──────────────────┘
                           │ V4L2 Request API
                ┌──────────▼──────────────────┐
                │  rkvdec / hantro-vpu /      │  ← Rockchip kernel driver
                │  rpi-hevc-dec               │
                └──────────┬──────────────────┘
                           │ register pokes
                ┌──────────▼──────────────────┐
                │ Silicon: VDPU381 / VEPU121  │  ← The 2018 fixed
                │  / hantro G1 / VideoCore    │     function block
                └─────────────────────────────┘

Each layer required a separate fork. Each fork is shipped as a package in marfrit-packages.
The kernels are linux-fresnel-fourier(RK3399) / linux-ampere-fourier(RK3588); the userspace side ships as libva-v4l2-request-fourier+ ffmpeg-v4l2-request-fourier+ mpv-fourier+ firefox-fourier+ chromium-fourier. None of these forks would be necessary if upstream landed faster. We did not say upstream is unmotivated. We are saying we wanted hardware decode in 2024 and have hardware decode in 2026.

Install

# Arch / ALARM with the marfrit overlay enabled:
sudo pacman -Syu linux-ampere-fourier            # RK3588 (CoolPi GenBook)
sudo pacman -Syu linux-fresnel-fourier            # RK3399 (Pinebook Pro)
sudo pacman -Syu ffmpeg-v4l2-request-fourier      # any RK
sudo pacman -Syu libva-v4l2-request-fourier       # any RK
sudo pacman -Syu firefox-fourier mpv-fourier      # consumers

Browser-side, set LIBVA_DRIVER_NAME=v4l2_request in the environment, set the three patched-in media.* prefs in about:config (or just install firefox-fourierwhich has them baked).

Repo setup at packages.reauktion.de; pacman config snippets in the marfrit-packagesREADME.

… done.

The fleet now decodes everything it usefully consumes in hardware. The Cortex-A76 cores can go back to compiling kernels, running language models, or doing nothing — they’re rated for it. The electrons formerly used for software video decode are now available for tasks of marginally greater social value, like playing back nerd reviews of overpriced laptops.

Hardware acceleration is supposed to be a solved problem. It mostly isn’t. But it’s a little more solved than it was this morning.


If you’re running Rockchip silicon on Linux and want hardware decode that actually works, the packages above are public. File issues at the relevant git.reauktion.de/marfrit/* repo.
Patches welcome; preferably for the codecs we said we don’t need.

The BESser patches: seven days of staging-prep for the PineTab2 Wi-Fi driver

The Pine64 PineTab2 ships with a Bestechnic BES2600WM combo chip for Wi-Fi and Bluetooth. The Linux driver for it lives at drivers/staging/bes2600/ in the DanctNIX linux-pinetab2 fork, and as a DKMS package in Mobian’s bes2600-dkms. Both descend from the same out-of-tree drop Bestechnic published in 2022 and which, to date, has not been upstreamed.

The driver works. It mostly works. It works in the way that any 2010s-vintage out-of-tree SDIO Wi-Fi driver works: well enough for the chip to associate, exchange frames, and reach the internet, and badly enough that you eventually start reading the source. What follows is a tour of the patch series that came out of that reading, in the rough order the issues surfaced. Patches live in marfrit/kernel-agent/patches/driver/bes2600/. Each subdirectory ships in two flavors: a -danctnix suffix for the in-tree build and a non-suffixed one for the Mobian DKMS variant, because the two trees disagree about timer APIs and a few function signatures.

Stage 1: the random disconnect

The entry point was a Wi-Fi connection that would simply die every once in a while and not come back without a rmmod bes2600 && modprobe bes2600. Logs showed bes2600_bh_lmac_active_monitor declaring „link break between lmac and host“ — the firmware watchdog noticed that the LMAC had stopped acknowledging host pointers. So far, so reasonable. What it then did about that was less reasonable.

The recovery path called bes2600_chrdev_wifi_force_close(), which scheduled sdio_scan_work, which on the PineTab2 is a literal one-line stub:


bes_warn("...this function does nothing\n");

The companion bes2600_sdio_on() path then toggled pdata->powerup, which is NULL on this board because the Wi-Fi reset GPIO is owned by sdio_pwrseq and not by the bes2600 device-tree node. (The DT file’s own comment notes that the pin „is claimed by sdio_mmcseq, It is better to move it to U-Boot so the OS can use it.“ Future work, presumably.)

Net result: the chip is never actually reset, the SDIO core never finds out the card is gone, and a subsequent rmmod leaves the SDIO function objects dangling. The recovery handler was, in effect, a no-op with logging.

lmac-recover-via-mmc-hw-reset calls mmc_hw_reset() on the SDIO function instead. The MMC core re-probes, the driver re-binds, and the firmware re-loads. Disconnect events that previously required a manual modprobe now self-heal in about three seconds.

Stage 2: the scan-defer logic that didn’t defer

While instrumenting the disconnect, a second symptom appeared: bursts of failed scans during roaming. The firmware would WSM_REQUIRED_CONFIRM on a scan, the driver would log a warning, and mac80211 would retry roughly every 12 seconds. The kernel had a backoff guard in place — BES2600_SCAN_BACKOFF_JIFFIES = 10 * HZ — but the retry cadence was just outside that window, so every retry slipped through, the firmware rejected again, and the cycle continued until mac80211 gave up and sent DEAUTH_LEAVING reason=3.

scan-defer-on-reject softens the WARN and introduces a reject counter so that the third in-window reject trips a real defer. scan-defer-backoff-tune widens the window from 10 s to 30 s — enough to catch the 12 s mac80211 cadence — and decays the counter on quiet periods so a slow trickle of legitimate failures doesn’t permanently lock out scans.

These two together turned roam-burst disconnects from „user-visible WiFi outage“ into „two log lines and a slightly later association.“

Stage 3: the DMA out-of-bounds read

With KFENCE enabled in the kernel config, a different symptom showed up: a clean panic on heavy TCP transfer.


BUG: KFENCE: out-of-bounds read in __pi_memcpy_generic
Out-of-bounds read at ... (704B right of kfence-#...):
  swiotlb_tbl_map_single
  ...
  bes_sdio_memcpy_to_io_helper [bes2600]
  sdio_tx_work [bes2600]

The TX path rounds the DMA transfer length up to the host’s block size and passes the rounded length to sg_set_buf(). The tx_buffer->buf pointer, however, aliases an skb whose actual allocation matches the unrounded length. The DMA engine then reads up to one block past the end of the buffer. On boards without KFENCE this is invisible — the read is harmless padding — but the bug is real and would trip any future sanitizer. tx-sdio-dma-oob routes oversized transfers through a bounce buffer of the rounded length.

Stage 4: the power-management arc

Power management on the bes2600 is a small theatre of state machines that occasionally lose track of each other. Five patches address overlapping symptoms:

  • pm-state-resync — fixes lost wake events when the firmware ACK arrives out of order with the host state update.
  • pm-timeout-silence — replaces a deeply nested 30-second timeout WARN with a single rate-limited info-level log line. The timeout was a known, recoverable condition; logging it as WARN_ON was just generating noise.
  • pm-wake-consume-state — ensures the wake-handler actually consumes the pending wake state instead of leaving it set, which previously caused phantom re-wakes.
  • pm-gate-on-handshake — gates LP-mode entry on a successful per-device handshake rather than firing it unconditionally.
  • pm-detect-firmware-unsupported — detects when the loaded firmware does not actually implement the PM protocol and disables PM gracefully rather than wedging.

None of these are individually exciting; together they cut the PM-related kernel log noise by about 90% and stopped two specific suspend/resume hangs.

Stage 5: the factory-calibration cleanup

The driver loads per-device calibration data from a 30-field text file. Upstream-wise this is also where things get embarrassing: the original code path was filp_open() plus kernel_read() against a hard-coded /lib/firmware/bes2600_factory.txt, which is a kernel-mainline anti-pattern dating back to before request_firmware() was ubiquitous. Worse, the path was being constructed against NULL device contexts, which generated the boot-time spam (NULL device *): read and check /lib/firmware/bes2600_factory.txt error on every probe.

The factory-series, factory-thread-dev, and factory-drop-kernel-write patches route the read through request_firmware(), rename the file to match firmware-class conventions (bes2600/bes2600_factory.txt), and thread a real struct device * through so the firmware loader has somewhere to log. A companion patch flips STANDARD_FACTORY_EFUSE_FLAG from default-on to default-off, because the file that the PineTab2 actually ships has 30 fields and the parser was expecting 31 (with the missing ##select_efuse_flag section generating a parse error on every load — which the driver, naturally, swallowed silently).

drop-dpd-file-paths and drop-orphan-file-io remove the remaining filp_open() call sites elsewhere in the tree.

Stage 6: the housekeeping

Two upstreaming-prep changes round out the series:

  • remove-chardev-user-interface — drops the /dev/bes2600 character device. It exposed a small ad-hoc ioctl surface that no userspace ever stabilised against, and which would block the driver from leaving staging/. Anyone who needs the equivalent can use nl80211 debug attributes.
  • enable-testmode — sets CONFIG_BES2600_TESTMODE=y by default for in-tree builds, because the test hooks were already compiled in for DKMS but not for danctnix.

Plus one trivial debian-copyright-fsf-address update so lintian stops complaining, which matters only if you build the Debian DKMS package.

Genealogy footnote

The bes2600 driver is, at the source level, a fork of the ST-Ericsson CW1200 (drivers/net/wireless/st/cw1200/): same author (Dmitry Tarnyagin), same WSM host/firmware protocol, same SDIO bus backend, and surviving Kconfig markers (CONFIG_BES2600_USE_STE_EXTENSIONS, CONFIG_BES2600_WSM_DEBUG) that read as ST-Ericsson archaeology. ST-Ericsson wound down in 2013; Bestechnic was founded in 2015. The IP lineage is presumably a license or an asset purchase, but no linux-wireless RFC has ever connected the two trees, and the bes2600 maintainers have not commented on it.

What was left out: firmware reverse-engineering

Everything above is host-side. The bes2600 firmware itself — the blob the host downloads onto the chip’s internal MCU at probe time — is opaque, undocumented, and almost certainly the source of more than one of the symptoms the host-side patches work around. (The „link break between lmac and host“ condition, for instance, would be far more cheaply diagnosed if the LMAC side were inspectable.)

Reverse-engineering it is a legitimate next step. The firmware images are roughly 600 KB, fit on a single SDIO download, and the chip’s MCU is documented to be a single Cortex-M class core. None of that work has been done here, on the grounds that the host-side cleanup is already a self-contained body of work and the firmware RE would dwarf it by an order of magnitude. Parked for a future hacking weekend that we will all enthusiastically agree should happen sooner rather than later, and then will not happen for another year.

In the meantime the host-side patches are upstreamable, individually testable, and bisectable. The full series applies cleanly against linux-pinetab2 6.19.10-danctnix1-1 and a representative subset has been validated on Mobian DKMS via the -danctnix/non-suffixed variants. Mirrored in marfrit/kernel-agent; the original umbrella issue thread is at marfrit/besser for anyone who enjoys reading commits in reverse.

Eurotronic Comet WiFi: Ditching the Cloud for Local Home Assistant Control

I have three Eurotronic Comet WiFi thermostats. They work fine. The app works fine. But every morning I’d watch my Home Assistant logs fill up with connection attempts going out to some mqtt3.eurotronic.io endpoint and think: this is not how I want my heating to work. My radiators should not depend on a server somewhere in Germany staying online. So I fixed it.

This post is the writeup I wish existed when I started. It took a weekend of packet sniffing, failed HACS integrations, and one very satisfying Pi-hole rule to get here.

What’s Actually Inside These Things

The Comet WiFi runs on a Dialog Semiconductor DA16200 WiFi chip. That’s important because it immediately rules out a few popular options: no Tuya compatibility, no custom firmware path, nothing in ESPHome. The DA16200 is not an ESP8266. You’re not flashing this thing.

What the thermostat does do is speak MQTT — and only MQTT — to a fixed set of cloud hostnames: mqtt.eurotronic.io, mqtt1.eurotronic.io through mqtt5.eurotronic.io. No local API. No mDNS. No REST endpoint. Just MQTT, pointed permanently at the cloud.

The moment I confirmed this with Wireshark I knew exactly what to do.

The Interception: Pi-hole + Local Mosquitto

The plan is elegant: intercept the DNS queries for those cloud hostnames and point them at your own Mosquitto broker. The thermostats never know the difference. They connect, authenticate, and start chattering away — just to your broker instead of Eurotronic’s.

In Pi-hole, add custom DNS records for every variant:

mqtt.eurotronic.io   → 192.168.0.10
mqtt1.eurotronic.io  → 192.168.0.10
mqtt2.eurotronic.io  → 192.168.0.10
mqtt3.eurotronic.io  → 192.168.0.10
mqtt4.eurotronic.io  → 192.168.0.10
mqtt5.eurotronic.io  → 192.168.0.10

Replace 192.168.0.10 with whatever IP your Mosquitto instance is running on. Don’t skip any of the numbered variants — the firmware will try several of them before giving up, and you want all paths leading home.

On the Mosquitto side, since all devices in a single Eurotronic installation share the same MQTT username and password (yes, really — one credential set for all your thermostats), and since you now own the broker, you can simply enable anonymous access:

allow_anonymous true
listener 1883

Counterintuitively, this is actually more secure than the cloud setup. Before, your thermostat data was transiting someone else’s infrastructure. Now it never leaves your LAN.

The MQTT Protocol

Once the thermostats are talking to your broker, subscribe to # and watch the traffic. You’ll see topics structured like this:

02/PREFIX/MAC/V/A0   ← values coming FROM the device
02/PREFIX/MAC/S/A0   ← commands going TO the device

The PREFIX and MAC are device-specific — just watch the broker traffic after your thermostats connect and you’ll spot them immediately. The registers you actually care about:

  • A0 — target temperature (setpoint)
  • A1 — current measured temperature
  • A5 — window open detection
  • A6 — battery level

Temperature values are encoded as hex, prefixed with #, where the hex value equals temperature × 2. So 21.0°C becomes #2a (42 in decimal, 0x2a in hex). 18.5°C is #25. Weird encoding, but consistent.

For polling, publish to the S/AF topic. Two useful payloads:

  • #01000000 — returns the current active setpoint cleanly
  • #02000000 — triggers an immediate current temperature report

You might find documentation elsewhere suggesting #0b on S/A0 for polling. I did too. It’s slow, unreliable, and sometimes returns schedule data mixed in with the current value. Avoid it. The AF approach is much cleaner.

One critical rule: never use the retain flag on any messages you publish. Retained messages get replayed to the thermostat every time it reconnects — which means your retained setpoint command will constantly override whatever the device’s internal heating schedule is trying to do. It’s a subtle bug that’ll have you wondering why your thermostat is ignoring its schedule.

Home Assistant Integration — Skip the HACS Plugin

There’s a HACS integration called comet_wifi_integration. I tried it. It has bugs, and more critically it uses QoS 2 for MQTT delivery, which HA’s MQTT client handles poorly. Messages get dropped, entities get stuck, it’s frustrating.

The better approach: use Home Assistant’s built-in MQTT climate entity via YAML. It’s rock solid and gives you full control.

In your mqtt.yaml:

climate:
  - name: Wohnzimmer
    temperature_command_topic: "02/PREFIX/MAC/S/A0"
    temperature_command_template: >-
      {{ "#%02x" % ((value | float * 2) | int) }}
    temperature_state_topic: "02/PREFIX/MAC/V/A0"
    temperature_state_template: "{{ int(value[1:3],base=16)/2 }}"
    current_temperature_topic: "02/PREFIX/MAC/V/A1"
    current_temperature_template: "{{ int(value[1:3],base=16)/2 }}"

The templates handle the hex encoding automatically. The command template converts a float like 21.0 into #2a. The state templates do the reverse. Swap PREFIX and MAC with your actual device values, repeat for each thermostat.

For polling, add an automation that fires every 15 minutes:

alias: Poll Comet WiFi Thermostats
trigger:
  - platform: time_pattern
    minutes: "/15"
action:
  - service: mqtt.publish
    data:
      topic: "02/PREFIX/MAC/S/AF"
      payload: "#01000000"
  - service: mqtt.publish
    data:
      topic: "02/PREFIX/MAC/S/AF"
      payload: "#02000000"

Battery and window sensors follow the same pattern using MQTT sensor and binary_sensor entities — same topic structure, same hex decoding.

One Thing to Keep in Mind

The Comet WiFi has an internal heating schedule that runs independently of anything you do via MQTT. If you set a temperature through Home Assistant, it’ll hold — until the thermostat’s next scheduled time slot kicks in and overrides it. This is the same behavior you’d get with the official app. It’s not a bug in your setup; it’s just how the device works. Plan your automations around it, or accept that the schedule has the final word.

The Result

Three thermostats, fully local. Real-time temperature readings in Home Assistant. Target temperature control. Battery monitoring. Window-open detection. Zero cloud dependency. The whole setup survives internet outages without a hiccup.

How We Got Here

Full disclosure: I built this integration in a pair-programming session with Claude Code, Anthropic’s CLI coding assistant. Claude handled the broker setup, DNS configuration, and initial HA integration code — but the existing community documentation and the HACS plugin both had gaps that only showed up during real-world testing. The #0b polling command that everyone recommends? Unreliable. The QoS 2 MQTT subscriptions in the custom integration? Silently broken in HA. The retain flag? A landmine waiting to blow up your heating schedule.

Each of these issues required hands-on debugging — me watching thermostat displays, checking if temperatures actually changed, opening windows to test sensors — while Claude analyzed the MQTT broker logs and iterated on the configuration. The S/AF polling commands and the distinction between #01000000 and #02000000 came from sniffing what the official Eurotronic app actually sends, which turned out to be completely different from what the community had documented.

It took more reverse engineering than it should have — Eurotronic publishes nothing about this protocol — but once you have the DNS intercept in place and understand the hex encoding, the rest falls into place quickly. If you’re sitting on a pile of Comet WiFi thermostats wondering why there’s no clean local integration, this is your path forward.

P.S. — The Jinja2 Trap

If your battery sensors show suspiciously low values, check your template. In Python, int("3C", 16) means „parse as base 16“ and returns 60. In Jinja2, the same syntax means „use 16 as the default if parsing fails.“ The correct Jinja2 for hex conversion is {{ value[1:] | int(base=16) }}, not {{ int(value[1:], 16) }}. This applies to battery values but not temperatures — the temperature registers happen to use only digits 0-9 in their hex encoding at typical room temperatures, so the bug is invisible until a value contains A-F.

Markus & Claude

Successfully used @AnthropicAI Claude Code to develop mainline Linux kernel patches for the CoolPi CM5 GenBook (RK3588)! 🎉Patches available at: https://github.com/marfrit/misc_patches

Now tackling the Radxa Rock 5 ITX+ — dual 4K display support on mainline is next. Huge thanks to @Collabora for their incredible upstream RK3588 work 🙏

And suspend for the GenBook is next as soon as an appropriate UART cable arrives…

coolpi loader

Das Mysterium des GenBook Boots ist gelöst: das OEM image ist eines für unterschiedliche Geräte des Herstellers. Der Bootloader des Herstellers ist Gerätespezifisch und ändert extlinux.conf so, dass der richtige Device Tree geladen wird. Gewöhnungsbedürftiger Hack!

cool-pi GenBook

[    0.000000] Booting Linux on physical CPU 0x0000000000 [0x412fd050]
[    0.000000] Linux version 6.18.6-1-aarch64-ARCH (builduser@arch-nspawn-106937) (aarch64-unknown-linux-gnu-gcc (GCC) 15.2.1
20251112, GNU ld (GNU Binutils) 2.45.1) #1 SMP PREEMPT_DYNAMIC Mon Jan 19 13:22:47 UTC 2026
[    0.000000] random: crng init done
[    0.000000] Machine model: CoolPi CM5 GenBook

Something strange – the original boot loader seems to overwrite extlinux.conf first 27 bytes with

default coolpi_rk3588_gbook

but extlinux treats the following line

default arch

as the one being evaluated.

LVM RAID6

Do. Not. Use. At this time, a LVM raid array cannot be reconstructed with a missing physical volume. That’s a 0% chance of data recovery. Which is less than BTRFS‘ 50% chance of RAID6 recovery.

Hasu USB to USB Controller Converter » 1upkeyboards

  Turn almost any USB keyboard into a programmable keyboard! This converter, created by Hasu, allows you to change the keymap and add functions through TMK firmware. NO soldering required. Externally attached. Add up to 7 layers and up to 32 Fn keys. Supports 6KRO (or NKRO keyboards that will work in 6KRO mode). Media/System control keys and ‘Fn’ key are not recognized by the converter, but will still function as originally programmed on the board.   Please check Hasu’s geekhack thread below for the current list of compatible and incompatible keyboards as well as additional information.

Quelle: Hasu USB to USB Controller Converter » 1upkeyboards