echolocal on Echo 2 (radar): Bringing the Fork Up to a Real Second-Generation Dot

A while back, I played with EchoMuse on my Echo Dot 2 (biscuit). It was fine. I wasn’t too keen on needing a central controller and the sheer complexity of the stack in return for fairly mediocre sound quality.

About a week later, I saw a post on XDA saying the Echo 2 had gotten the same root and TWRP treatment. My first thought was, “I can’t wait for someone to port EchoMuse onto it”, followed almost immediately by, “Why don’t I try to do it myself?”

That turned into me finding echolocal. A project that runs completely standalone, using esphomeapi to mimic an ESPHome device and allowing native integration directly with Home Assistant. No central controller, no extra orchestration layer — the Echo is the device, and Home Assistant talks to it the same way it would talk to any ESPHome node.

A quick note before I dive in: I am not the original creator of echolocal. That credit belongs to @ygelfand. All I’ve done is ported the radar branch — making it work on the second-generation Echo 2 instead of just the Echo Dot 2. The architecture, the design choices and the bulk of the codebase are theirs. If anything in this post reads well, that’s because they built a very good project for me to port onto a new device. If anything reads poorly, that’s on me!

Since then, I have been quietly working on the radar fork — porting echolocal to the second-generation Echo 2 (codename radar_puffin, also MT8163, but with a different audio codec, a different kernel image, and a wholly different speaker tuning).

It turned out to be considerably more work than I expected, and considerably more interesting. This post is about what changed and why.

Introduction

The fork lives at github.com/vithurshanselvarajah/echolocal, on the radar branch. Six commits, no PRs yet, no new features — just the work of getting the existing system to run on a device that looks almost, but not quite, like the one it was written for.

Like last time, this is mostly an exercise in not assuming things that happen to be true on biscuit happen to be true on radar.

Sometimes that means adding a new boot image. Sometimes it means discovering that the speaker EQ the vendor ships is structured differently than the loader thought. Sometimes it means finding that an amplifier gate is wired to a GPIO that quietly defaults to “off” on radar and “on” on biscuit.

The shape of the problem

The two devices are close enough to share a build target but different enough that nothing shipped as “Echo Dot 2” works on radar out of the box.

  • SoC: both are MT8163, but radar routes its audio through a TLV320AIC3204 codec instead of the AIC3x variant biscuit uses.
  • Boot image: radar’s stock boot partition is bigger (115 MB slot vs. 16 MB) and the OTA repartitioned it. The 16 MB pair is still there as boot_a_x / boot_b_x, which is how a naive flash writes to the wrong place.
  • Speaker tuning: both devices ship vendor EQ files in /vendor/etc/audio-algorithms, but the format differs:
    • biscuit uses 1024-tap FIRs, 6 buckets (50..100 in 10% steps), and one FIR shared across all buckets.
    • radar uses 2048-tap FIRs, 7 buckets (30, 40, 50, 60, 70, 80, 100), and per-bucket FIRs because the filter shape — not just the gain — changes with volume.
  • Amplifier gate: radar ties the external speaker amp to MFP2 on the codec, which defaults high (amp off) so the device does not reboot if the codec ever enables output without a defined path. Without flipping it, there is no sound.
  • Boot image requirement: both devices need androidboot.selinux=permissive on the kernel cmdline, but radar’s stock boot image does not have it. biscuit’s does.

Each of those is small. Together, they added up to a real porting effort.

Per-device Model, Board, and boot image map

The first commit I needed was a structural one.

The layout package hard-coded a single device’s identity: Manufacturer = "EchoLocal", Platform = "echolocal", DefaultName = "Echo Dot". The boot image registry in internal/host/bootimg had a single entry keyed on biscuit_puffin.

That worked because, until now, the only supported device was biscuit.

The radar branch restructures both:

  • Per-device Model and Board. layout now exposes a Device struct with Codename, Model, Board and the home-assistant-facing identity (Manufacturer, Platform, DefaultName). The Makefile, the echoctl install flow, and the bootimg registry all read from it.
  • Per-device boot image map. bootimg.Ours is now a map[string]Image. bootimg.For(device) returns the right image for a codename and a clear error if it does not know it. The flash stage also learns the partition size from the device so it does not silently write a 16 MB image into a 115 MB slot.
var Ours = map[string]Image{
    "biscuit_puffin": { /* existing 16 MB image */ },
    "radar_puffin": {
        SHA256:  "d43e09549323d86c4bd20d88f1efd37642122ed8b5ee740f338ab95978936451",
        Size:    9762304,
        Cmdline: "androidboot.selinux=permissive",
        Build:   0,
        Device:  "radar_puffin",
    },
}

The radar entry has Build: 0. That is intentional: Amazon ships new builds, and refusing a device on a later one would leave every firmware bump needing a release here before it could be installed at all. The hash and size still gate it; the build is informational.

A tool for patching boot images

The shipped biscuit image already had androidboot.selinux=permissive on its kernel cmdline. Radar’s stock boot image does not.

Without that flag, the kernel boots enforcing, SELinux refuses our transitions, and echod never gets to listen on the API port.

The fix is to patch the cmdline. But the cmdline lives at offset 64 of the Android boot header, in a 512-byte NUL-padded region, and I did not want the answer to “how do I patch the cmdline” to be “open the image in a hex editor”.

So cmd/mkbootimg was added. It is a small Go program that takes an input image, an output image, and an optional new cmdline.

mkbootimg boot.img boot-patched.img            # appends androidboot.selinux=permissive
mkbootimg boot.img boot-patched.img "my cmd"   # replaces the cmdline entirely

The interesting bit is what it refuses to do. It checks the ANDROID! magic, refuses to write an empty cmdline, and refuses a cmdline longer than 512 bytes. It also computes the SHA-256 of the output and prints it, which is what gets pasted into bootimg.Ours.

This is one of those utilities that exists because it has to, and because doing it manually is the kind of mistake that bricks a device.

The tool is shipped as a normal cmd/mkbootimg binary in this repository. It runs on a Linux box and writes a file you can then adb push to the device.

Shipping the radar boot image

Once the patching tool existed, the next step was actually shipping a radar boot image.

The image in the repo is the stock FireOS 6 boot partition from a radar_puffin device, trimmed of trailing zero padding and with androidboot.selinux=permissive appended to the kernel cmdline. It is 9 762 304 bytes — smaller than the 115 MB slot it lives in, which is fine: the slot just has more room than we need.

The hash and size are pinned in bootimg.Ours. The flash stage verifies both before writing, plus the cmdline substring and the ANDROID! magic, so a truncated or substituted image is refused before a device is touched.

The install flow now does the right thing on radar without the user having to know about it:

  1. Probe ro.product.device and ro.build.version.incremental.
  2. Look up the right image in bootimg.Ours.
  3. Verify the partition size is one of the known slots (16 MB or 115 MB).
  4. Write the image into the right boot_a / boot_b slot.

Enabling the MFP amp gate on radar

The single most “why is there no sound” commit was the MFP amp gate.

TLV320AIC3204 has a multi-function pin (MFP2) that the vendor configures as an amplifier gate. It defaults to high on radar, which means the external speaker amplifier is disabled — the codec is producing audio, the mixer is set, the kernel is writing samples, and nothing comes out of the speaker because the amp is gated off.

The default is deliberate. A codec that enables its output without a defined path can latch up and reboot the SoC. The vendor chose to leave the amp off until something explicitly enables it, and to log a fault if the codec ever tries to enable output while the amp is still gated.

The fix is to drive MFP2 low, which is what setting the MFP Gpio Mute mixer control to Off does on this codec. It is added to the speaker init sequence and re-asserted on every path switch, because the kernel may re-mute MFP2 on close.

The control does not exist on biscuit, which is why this did not come up before. The apply path logs and continues on missing controls, so the same code runs cleanly on both devices.

This is the kind of fix that only matters if you have actually listened to the speaker and noticed it was silent.

The really interesting one: speaker EQ on radar

By far the most painful commit was getting the vendor’s EQ to load on radar.

internal/lib/asp reads the speaker tuning out of /vendor/etc/audio-algorithms and builds the FIR-plus-compressor chain the playback loop applies. The tuning is the vendor’s, and not ours to ship, so the loader reads it off the device at boot.

On biscuit, this worked. On radar, asp.Load silently returned an error, the write loop’s tuned gate was always false, and the speaker played untuned — a 27 dB shelf at 160 Hz with nothing to keep the driver from bottoming out.

There were three separate problems hiding in one silent failure.

1. radar ships 2048-tap EQ files; biscuit ships 1024. The loader hard-coded 1024 and rejected anything else. I had assumed the tap count was a project constant. It is not — the vendor picks it per device. The fix is to read the first file, learn its length, and verify the rest match. The valid tap counts are {1024, 2048}.

2. radar’s EQ buckets each carry their own FIR. This one took longer to see. The residual between radar’s EQ_50 and EQ_70 is around 9%, not the near-zero it is on biscuit. The vendor is not just changing gain with volume — they are changing the filter shape, presumably because the driver behaviour at low excursion is not the same as at high excursion.

The old code reused EQ_50’s taps for every volume. That is approximately correct on biscuit, where the only thing that changes is the gain. On radar, it is wrong at any volume above the lowest one.

The fix is to build one chain per bucket, with that bucket’s FIR and that bucket’s compressor/limiter settings, and have the write loop pick the right chain for the current volume.

3. radar ships 7 buckets (30, 40, 50, 60, 70, 80, 100) instead of biscuit’s 6 (50..100 in 10% steps). The old bucket discovery assumed the biscuit layout and crashed on the missing EQ_90. The new discovery scans the directory, parses EQ_*.cfg filenames, and sorts by volume boundary. A bucket that is not on disk falls through to the next higher one.

The radar tuning also ships non-zero comp_inVol / lim_inVol values in its MBCL block, which the old loader rejected. Those are now applied.

The end result on the device is a clean dump of which bucket a given volume maps to:

loaded: 7 buckets, bucket@0.30 = 0  (EQ_30)
                bucket@0.45 = 2  (EQ_50)
                bucket@0.55 = 3  (EQ_60)
                bucket@0.65 = 4  (EQ_70)
                bucket@0.75 = 5  (EQ_80)
                bucket@0.85 = 6  (EQ_100)
                bucket@0.95 = 6  (EQ_100, no EQ_90)

The 0.95 case is the “bucket fall-through” rule in action: there is no EQ_90, so 95% lands in the next available bucket (EQ_100).

Two new on-device diagnostic tools

Diagnosing the EQ issue required reading things off the device that no tool exposed. Two small commands were added to fill that gap.

cmd/aspprobe tries asp.Load on a directory and reports the bucket count plus which bucket a given volume fraction maps to. It is what produced the dump above. It takes one argument (the directory) and an optional volume fraction:

aspprobe /vendor/etc/audio-algorithms 0.65

If the loader fails, it prints the error and exits non-zero, which is what made the silent failure visible in the first place.

cmd/dumpdaceq reads the DAC biquad coefficient blob out of the codec’s mixer and prints it as a Go byte slice, ready to paste into internal/hardware/speaker/paths.go.

// biquad coefficients on card 0: 105 bytes
var speakerEQ_radar = []byte{
    128, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ...
}

The coefficients read back as zeros until something writes them. Without dumpdaceq, the only way to see what was in there was to attach a logic analyser to the I²S bus. With it, the value is one ALSA read away.

Both tools are short — under 100 lines each — and exist primarily so that the next person who hits a similar issue does not have to start from scratch.

What the tests cover

The EQ rework came with tests in internal/lib/asp/asp_test.go:

  • The radar scenario: a 2048-tap EQ file, a missing EQ_90, 7 buckets with non-zero band input gains, and an MBCL with non-zero comp_inVol / lim_inVol.
  • The biscuit scenario (regression): 1024-tap, 6 buckets, the layout the loader was originally written against.

The new boot image work came with tests in internal/host/bootimg/bootimg_test.go:

  • The radar image passes Verify against the known hash, size, magic, and cmdline.
  • A truncated file is refused before any device is touched.
  • A file with the right hash but the wrong cmdline is refused with a clear error.
  • For("") and For("unknown_device") both return useful errors naming the supported devices.

The flash stage’s tests got a matching update: a wrong-size partition is refused, and the per-device image is picked automatically without the install flow needing to special-case radar.

What I learned

The thing I keep relearning on these projects is that a configuration that worked on one device is not a configuration — it is a coincidence that happened to survive one board revision.

Every place I had hard-coded something — “EQ files are 1024 taps”, “buckets are 50..100”, “MFP2 is irrelevant on this codec”, “the boot image is 16 MB” — was somewhere the fork had to be generalised to support a second device. The bootstrap loader learned the tap count from disk. Bucket discovery learned to scan. The speaker init learned to apply a control that may not exist. The boot image registry learned to be a map.

The other thing I learned is that silent failures are expensive. The EQ issue would have been obvious if asp.Load had logged why it returned an error. It was not obvious at all because the error was swallowed and the playback loop just played untuned audio. aspprobe exists because of that.

I am not entirely sure how a project that started as “let me run a local voice satellite instead of Amazon’s cloud” ended up with a per-device boot image map and a kernel-cmdline patching tool, but I am glad it did. The radar port is now sitting on a real Echo 2 in my living room, talking to Home Assistant, with the actual vendor EQ chain applied. The tuning makes a noticeable difference — the speaker does not bottom out at 80% volume any more, which it did on the untuned output.

There is still work to do. The original maintainer of the project is currently busy reworking his project to be able to make it multi device compatible. Once his rework is complete i’ll likely spend another week rebasing my branch before i can raise a PR to merge into his project.

But for now. Voice control, Speaker, LED’s, light sensor all work and work reliably. I’ll give myself a little pat on the back and move onto the next project till the maintainer of the original project is ready for me to merge my changes.

If you want to check out my work. Have a look at my fork below (branch: radar)

Leave a comment