From BLE Reverse Engineering to a Public Spotify Connect Installer for the Chord 2go
I wasn’t interested in making my Chord 2go play Spotify once on my own bench. Bench hacks are cheap. The thing I wanted was harder: a public installer another owner could run from macOS or Windows, get stable Spotify Connect at Ogg Vorbis 320 kbps, and undo cleanly if they changed their mind. That meant the investigation had to go deep, but the release had to be boring in the best possible way: predictable, reversible, and honest about what it actually does.
The source is now public at github.com/arazmj/chord-2go-spotify, and the first packaged release is v0.1.0. It is unofficial, it is not affiliated with Chord Electronics or Spotify, and it absolutely does not claim Spotify Lossless. What it does do is add a stable Spotify Connect target to a 2go on tested firmware 1.6.1, with fixed install, status, and uninstall operations and enough safety rails that I felt comfortable publishing it.
The interesting part is how it got there. The 2go is not a dev board with a friendly shell. It is a closed audio appliance with Bluetooth control, LAN behavior, multiple competing audio services, and exactly zero interest in explaining itself. So the job started the way good reverse engineering often does: not with force, but with observation.
If you just want the tool, start with the repository and the v0.1.0 release. The rest of this post is the full engineering story behind why the public version is deliberately narrow, stable, and reversible.
Start on the wire you can watch
The 2go was a hard target for two reasons. First, it sits at the worst possible spot for casual tinkering: too closed to feel like a hobby board, but just open enough to tempt you into sloppy assumptions. Second, the thing I cared about was not merely “can I make it work?” but “can I publish something safe for other owners?” That changes your first move. If you go invasive too early, you contaminate the only evidence you have. You stop learning how the stock system behaves and start learning how it behaves after you have already disturbed it.
So I began on the LAN for the same reason a locksmith listens before drilling: you learn the shape of the mechanism without destroying the evidence. I watched the device in its normal life. DHCP. DNS. Outbound requests. Service announcements. What changed when Gofigure opened? What changed when it edited settings? What changed when it prepared an update? The goal was not to scrape secrets from the network. It was to separate responsibilities: which actions belonged to the phone, which belonged to the 2go, and which parts of the system were merely bystanders.
That non-invasive phase mattered more than it sounds. It told me where not to waste time. There was no obvious hidden Spotify client suddenly phoning home. There was no firmware blob shuttling wholesale over the network by accident. There was, instead, a device with a very particular control plane: Bluetooth for authorization and instruction, LAN for device-side work, and a stack of audio services that already treated the hardware as contested ground.
The phone was the remote, not the truck
The first truly useful clue came from updates. Gofigure clearly initiated something over BLE, but the bulk transfer was not BLE at all. Once the sequence started, the 2go fetched firmware itself. That sounds like a small detail. It wasn’t. It collapsed the problem into a much more interesting shape.
If the phone was only the remote control and the 2go was the thing actually downloading artifacts, then a public installer did not need to smuggle megabytes through Bluetooth. It needed an owner-authorized way to tell the device where to fetch a small, known payload on the local network. That is a very different design space. BLE stops being the cargo path and becomes the switchboard. The LAN stops being a place to observe only and becomes a place to serve a tightly scoped, checksummed payload the device can retrieve for itself.
That one observation also made the whole project safer. Shipping arbitrary binary data over a reverse-engineered BLE transport would have been fragile, slow, and harder to reason about. Letting the device do what it already knew how to do — fetch something over IP after an authorized instruction — was both cleaner and closer to the manufacturer’s own model for update behavior.
Learning just enough BLE to talk back
Once I knew BLE was the control plane, the reverse engineering target got narrower and, ironically, more tractable. I did not need to understand every attribute, every UI feature, or every possible command path. I needed enough of Gofigure’s encrypted BLE protocol to communicate with a device the owner had already authorized, read reliable preflight information, and send one carefully constrained instruction at the right time.
In practice that meant understanding the session shape around the owner-auth flow, the request/response framing, and the minimum crypto necessary to behave like a legitimate, already-authorized controller rather than a random scanner shouting into the air. Once that worked, I could reliably ask for the same facts Gofigure obviously knew already: the device name, model, firmware, and LAN address. Those are small details, but they are exactly the details a public installer needs to avoid guessing.
I am intentionally not publishing packet captures, device-specific authentication material, or packet-level reproduction steps. This is one of those places where “I know how it works” and “the world benefits from me writing down every step” are not the same statement. The public value here is the installer and its safety properties, not a generic playbook for driving every hidden endpoint the box exposes.
That boundary also kept the work honest. Reverse engineering can easily become a collector’s hobby — one more command, one more flag, one more proof that you can make the device twitch on demand. For a public tool, that instinct is poison. The goal was not maximum reach. The goal was minimum necessary authority.
The most important capability is the one I did not ship
Once I had an owner-authorized command path, I hit the first major product decision of the project. Yes, I had found a route that could do more than three fixed things. No, I was not going to publish it as a generic shell. A generic shell would have made for a flashier repository and a much worse security story. It would also have collapsed the boundary between “installer” and “remote execution framework,” which is exactly the line I wanted to keep bright.
So the public CLI freezes the capability surface into exactly three verbs:
subparsers.add_parser("install", parents=[shared], help="Install the payload.")
subparsers.add_parser("status", parents=[shared], help="Read remote status.")
subparsers.add_parser("uninstall", parents=[shared], help="Run the remote uninstall script.")
That constraint is not a missing feature. It is the feature. The tool preflights, installs, reports status, and uninstalls. It does not become a Swiss Army knife for undisclosed control paths. It does not publish a shell, an arbitrary upload interface, or an invitation to discover what else the device might do under owner authorization. The safest public reverse-engineering work is usually the work that throws away power on purpose.
If you read the repository and feel that the tool is opinionated, you are reading it correctly. It is designed to be a narrow bridge from a known starting state to a known ending state. That narrowness is why I felt comfortable attaching my name to it.
The Spotify flag that went nowhere
In the middle of the investigation I found one of those clues that can waste a week if you let it: a dormant Spotify-looking flag. For a minute it seemed plausible that the 2go once intended to ship Spotify support and merely forgot to expose it. That hypothesis is emotionally irresistible because it suggests the solution might be a hidden toggle rather than a real integration.
But a flag only matters if there is working code behind the switch. There wasn’t. No usable Spotify client shipped on the box in a form I could responsibly resurrect. The flag was dead code — a light switch screwed to drywall with no wire behind it. That ended the fantasy version of the project. The path forward was not “re-enable secret feature.” It was “install a known-good client and integrate it cleanly.”
Making an ARM appliance speak librespot
That known-good client became librespot. The stable public path ended up pinned to librespot 0.8.0, cross-built as a static ARMv7 binary so the 2go does not depend on whatever shared-library accident happens to be present on the device that day. The public repository pins the upstream state explicitly and builds for the actual target architecture rather than hand-waving it away.
COMMIT=d36f9f1907e8cc9d68a93f8ebc6b627b1bf7267d
TARGET=armv7-unknown-linux-musleabihf
...
cargo +"$TOOLCHAIN" zigbuild \
--release \
--locked \
--target "$TARGET" \
--no-default-features \
--features rustls-tls-webpki-roots,with-libmdns
I liked this approach for two reasons. First, it made the payload deterministic. Second, it let me validate the Spotify side before I committed to any persistent on-device changes. Early on, I staged the binary temporarily and launched it from a disposable location before touching systemd, manifests, or service restoration. That sounds almost too cautious, but it saved time. Persistent installs make every bug look architectural. Temporary staging lets you answer the simpler question first: does the binary actually run, discover, authenticate, and emit audio on this box?
Only after that answer became “yes, repeatedly” did the project graduate from an experiment into an installer. That order matters. Install last. Prove first.
The DAC wanted 32 bits and nothing less
Then the Hugo 2 taught me humility. My first instinct was the same instinct many Linux audio paths reward: start with a simple 16-bit PCM assumption and iterate upward only if the hardware demands it. On this stack, that assumption was wrong. Reliable playback required S32_LE, stereo, and 44.1 kHz. Sixteen-bit output did not become “slightly worse.” It just failed.
The fix in the public payload is brutally simple, which is exactly what I wanted after the mystery was gone:
exec "$APLAY_CMD" -q -D soundcard -f S32_LE -c 2 -r 44100 -t raw
This is one of those hardware lessons that looks obvious in retrospect and inscrutable in the moment. Once you know the lock only turns if you push up while twisting clockwise, the move seems trivial. Before that, you can spend hours convinced the key is wrong when the problem is really your wrist.
Audio was a one-lane bridge
Getting one track to play is not the same thing as making the appliance behave like an appliance. The 2go already had opinions about who owned the DAC: Roon, AirPlay, DLNA/MPD, and Bluetooth all wanted a turn. Those services are not decorative background daemons. They are competing claimants to one physical output. So I treated audio ownership like a one-lane bridge. When Spotify arrives, traffic from the other side has to pause in an orderly way. When Spotify leaves, the bridge has to reopen exactly as it was.
The public service entrypoint makes that intent explicit. It launches librespot with the stable 320 kbps configuration and hands sink events to a helper that arbitrates ownership of the output:
ExecStart=/usr/local/bin/librespot --name Chord-2go-Spotify --backend subprocess \
--device /usr/local/bin/librespot-aplay --format S32 --bitrate 320 --initial-volume 50 \
--cache /var/cache/chord-2go-spotify --onevent /usr/local/sbin/librespot-audio \
--emit-sink-events
Under the hood, that helper records which services were active, pauses MPD-style playback where possible, stops the active owners cleanly, and then restores them when Spotify stops or disconnects. In practice that means temporary yielding around Roon RAAT, AirPlay via shairport-sync, DLNA/UPnP and local MPD variants, and Bluetooth audio. I did not want “Spotify works” to really mean “Spotify works, but only if you never use anything else again.” The point was coexistence, not conquest.
This is also where the difference between a bench hack and a public tool becomes obvious. On the bench, you can kill services by hand, shrug, and reboot later. In a release, you have to treat other playback paths as first-class citizens because to the owner they are. If Spotify steals the DAC and fails to give it back, the installer has not succeeded. It has vandalized the listening room.
A demo is easy; an installer is hard
By this point I had proven that the binary could run and that the audio path could work. I still did not have something I could publish. Publishing starts when you can survive interruption: a reboot midway through testing, an install run twice, an uninstall weeks later, a modified file on disk, or a previously installed private experiment that should not be orphaned forever. Most of the real engineering time went there.
The device payload now stages its files, fingerprints them, records a manifest, captures backups of anything it replaces, and only commits after the new service is enabled, restarted, and verified active. Uninstall works in the opposite direction: it restores prior service state when it can, removes project-owned files when their fingerprints still match, and refuses to pretend certainty when the owner or a future firmware update has changed something underneath it. That fingerprint-aware rollback is not glamorous, but it is the difference between “I can remove my code” and “I can responsibly restore your device.”
I also had to think about persistence across reboots. If the stable path only worked until the next power cycle, it was not stable. So systemd became part of the design, but only after repeated manual proof. The service had to come back cleanly after reboot, reacquire the right state, advertise itself properly, and then relinquish control when the owner stopped using Spotify. Reboot testing sounds dull until you skip it and discover your heroic hack dies every Tuesday.
Another quiet requirement was migration. I had old private experiment artifacts from earlier branches. The public installer could not simply bulldoze them and hope for the best, nor could it leave them behind like land mines for future uninstalls. So the payload explicitly migrates known legacy experiment footprints into the new manifest/backups model. That is not a feature most users will ever notice, which is precisely why it is worth doing.
The lossless mirage
The most important failure in the whole project was one that looked, from the outside, like success. At one point the Spotify UI showed the magic word: Lossless. If I had been reckless, that screenshot could have become the headline and maybe even the release. It would also have been wrong.
Diagnostics proved actual playback remained OGG_VORBIS_320. The label had changed. The transport had not. That was the false summit of the project, and I am grateful it happened before I published anything because it forced the most important discipline on the whole effort: validate the media you are actually decrypting and playing, not the user interface label you wish were true.
That lesson sounds embarrassingly obvious in print, but it is exactly the kind of mistake reverse engineering invites. The closer you get to a desired outcome, the more tempting it becomes to stop at a plausible proxy. A convincing UI is one kind of evidence. Transport proof is another. Only one of them deserves a release note.
Where the private lossless branch actually got
That false summit did not mean the lossless line of inquiry was pure fantasy. In a private branch, the investigation went far enough to reach real FLAC and 24-bit FLAC metadata and to resolve versioned CDN storage locations rather than merely stare at an optimistic label. That was the moment the work crossed from UI theater into real transport evidence.
But the branch still hit a wall: decryption depended on PlayPlay-derived key material that I am not publishing and did not complete in a way I could responsibly turn into instructions. I want to be precise here. I am not publishing tokens, addresses, hashes, proprietary DLL details, cryptographic vectors, or circumvention steps. I am only saying, at a high level, that the remaining blocker was not “does the service have better assets?” but “can those assets be lawfully and safely turned into working playback on this device?” For the public project, the answer remained “not in a releasable way.”
That is why the public repository stops where it does. It contains the proven, stable Ogg Vorbis 320 kbps path. It does not contain the private notebook that explored where a lossless branch became technically interesting but still not publishable.
Fail safe or do not ship
Several of the ugliest bugs in this project shared the same pattern: they looked exciting right up until they failed in a way a real user would hate. A dead Spotify flag looked promising until it turned out to be cosmetic. The 16-bit output assumption looked reasonable until it produced silence. The lossless UI looked triumphant until it resolved to Ogg transport. An install flow looked healthy until remote diagnostics proved the script had actually failed. Every one of those moments pushed the design in the same direction.
The rule became simple: validate the thing that matters, and if validation fails, fall back safely. In practice that meant lossless experiments could not be allowed to cause silence, skipping, or request patterns that smelled enough like breakage to trigger rate limiting. If an experiment could not prove decrypted media, stable playback, and correct device-state restoration, it was demoted immediately to the boring path that worked: Spotify Connect over Ogg Vorbis up to 320 kbps. Boring won because boring kept playing music.
Why I open-sourced the boring part
That is the real reason I open-sourced only the proven stable Ogg 320 path. Not because there was no other research, but because private research and public tooling are different responsibilities. A private notebook gets to be incomplete, ugly, and full of dead ends. A public release does not.
The thing worth publishing was the part I could defend in daylight: owner-authorized preflight, deterministic payload transfer, stable audio output, service restoration, clear status, clean uninstall, and explicit documentation saying what the tool does not do. The thrillier branch remains private because it is more sensitive, less complete, and not something I could justify dropping into a public README just because it made for a better story.
What the public CLI actually does
The desktop CLI is intentionally small, but a lot happens inside that small surface area. It discovers nearby 2go devices over BLE, authenticates against an owner-authorized device, reads preflight facts like firmware and LAN address, and then blocks untested firmware unless the user explicitly passes --force. For the first release, firmware 1.6.1 is the tested line in the sand.
Once preflight passes, the tool automatically selects the local IPv4 route that can actually reach the device on the LAN. That sounds mundane, but it solved a real packaging problem: people do not run installers in idealized single-interface lab networks. They have Wi-Fi, Ethernet, VPNs, and firewalls. So the CLI chooses a usable host address, starts a temporary local HTTP server, and serves exactly two things: the embedded payload archive and its checksum.
The 2go then pulls that payload itself, verifies the checksum, extracts it, runs the appropriate fixed action, and posts back a bounded report. The desktop side does not trust vibes. It checks the reported action, the remote exit code, and the final success marker before it reports success. That last part fixed a subtle but important class of false positives: a service that happened to be active was never allowed to masquerade as a successful install if the remote script had actually failed.
Just as importantly, the CLI tries to fail like a product rather than like a pile of stack traces. Unsupported firmware gets a clear message. Route selection errors explain what LAN condition failed. Missing payload assets fail loudly at package-build time instead of silently at install time. The whole point of the fixed-command surface is that the tool can afford to be opinionated and explicit.
What lives on the device
The on-device payload is equally deliberate. It ships a pinned upstream librespot binary, a tiny playback wrapper for the Hugo 2-compatible output format, an audio-arbitration helper, a systemd unit, project documentation and licenses, and install/uninstall scripts that track what they changed. It also keeps its own cache and state directories instead of scattering files across the box and hoping future me remembers where they went.
The installation path is transactional by construction. Files are staged first. Fingerprints are computed before commit. Existing files are backed up when appropriate. The new service is enabled and validated. Only then is the manifest updated as the new source of truth. Uninstall is targeted rather than destructive: restore what should be restored, remove what the project can prove it created, and leave modified unknowns alone with a warning instead of pretending omniscience.
That same payload layer also handles legacy experiment migration so early private branches do not become permanent ghosts on the filesystem. If this all sounds slightly obsessive for a hobby audio integration, good. Filesystem hygiene is what lets hobby software age without turning into archaeology.
Packaging it for other people’s computers
Once the device story was stable, there was still one more translation to do: from “works from my source tree” to “works for another human on their own computer.” The public release therefore ships packaged host binaries for Apple silicon Macs, Intel Macs, and Windows x86-64 machines via PyInstaller:
chord-2go-spotify-macos-arm64.zip
chord-2go-spotify-macos-x86_64.zip
chord-2go-spotify-windows-x86_64.zip
The release automation lives in GitHub Actions. It builds the ARM device payload once, packages the host-side CLI per platform, generates SHA256SUMS.txt, emits an SBOM, and publishes build provenance attestations alongside the archives. That is a lot of machinery for a first release, but it directly supports the thing I cared about most: a user should be able to download a known artifact from a public tag, verify it, run it, and get the same result I got from source.
The first release is unsigned, which means macOS Gatekeeper and Windows SmartScreen can warn. I would rather state that plainly than play coy about it. A clear warning plus checksums, SBOMs, and provenance is a better story than overclaiming trust I have not yet earned through signing and reputation.
The failures that only appear when you try to ship
Release engineering produced its own pile of bugs, and I want to document them because they are the difference between “I demoed this” and “I released this.”
- Windows tried to run Linux payload tests. That was a category error in CI, and the fix was to split Linux device-payload tests into their own Ubuntu job while keeping host packaging cross-platform.
- An early cargo-zigbuild/Rust combination did not agree. The release workflow now pins Rust 1.85.0 with cargo-zigbuild 0.21.8 instead of hoping whatever happened to be current that week would cooperate.
- The ARM target was missing. Obvious once seen, invisible until a clean runner tells you the cross-compile cannot even start. The workflow now adds
armv7-unknown-linux-musleabihfexplicitly. - License material was missing from artifacts. The public archives now include the project license and third-party notices, and the device payload carries the upstream librespot license as well.
- Raw route-selection OSErrors were useless. They now surface as clear “could not determine a local IPv4 route” failures rather than as unexplained packaging noise.
- Cache ownership and lifecycle needed to be deterministic. The payload now creates and uses its own dedicated cache path rather than inheriting whatever ambient state happened to exist.
- Install reporting could look successful when it was not. The CLI now requires remote action markers, a zero exit code, and the final success marker before it says the device was installed successfully.
None of those bugs are romantic. All of them are the kind that quietly decide whether a first release feels trustworthy or amateur.
What I validated before publishing
I did not want the public launch to depend on a developer-only path, so validation covered both the source CLI and the packaged CLI. I tested first install, idempotent re-install, uninstall, restore, and reinstall. I tested reboot persistence. I tested the device pulling a public asset and verifying its checksum. I tested reinstalling from the public packaged release rather than from my working tree. I checked that the resulting target advertised correctly over mDNS and showed up in Spotify the way a normal user would expect.
That matrix sounds tedious because it is. It is also how you discover the difference between “my system converges to the right files” and “someone else can actually trust the release artifact.” For a reversible installer, idempotence matters just as much as first-run success.
Current limitations
- Playback today is Spotify Connect over Ogg Vorbis up to 320 kbps only.
- It is not Spotify Lossless.
- Firmware 1.6.1 is the tested target for this release. Other firmware requires
--forceand is untested. - You need BLE and the same LAN. The computer must be near the 2go for Bluetooth and reachable on the same network for the transfer step.
- The first public release is unsigned. Expect normal macOS and Windows trust prompts.
- Future firmware may break compatibility. This is an unofficial integration riding on behavior I do not control.
If those constraints still fit your use case, start with the repository and the v0.1.0 release.
Responsible disclosure and safety note
Use this only on a 2go you own or are explicitly authorized to manage. The public release is deliberately narrower than the private investigation. I am not publishing updater credentials, private IPs, exact exploit or circumvention payloads, device-specific credentials, tokens, or proprietary binaries. The repository documents a stable owner-authorized installer path, not a general-purpose exploitation toolkit.
The project is unofficial and is not affiliated with, endorsed by, sponsored by, or approved by Chord Electronics Ltd. or Spotify AB. It is simply the version of the work I could defend publishing: stable Spotify Connect at Ogg Vorbis 320 kbps, with rollback, uninstall, and clear limits.
AI attribution
I used GitHub Copilot CLI powered by GPT-5.6 Sol throughout the investigation and implementation. I sometimes described it conversationally as ChatGPT 5.6 (Sol), but the actual interface was GitHub Copilot CLI.
What I learned
The satisfying part of this project was not making the 2go do one improbable thing. Bench tricks are fun, but they age badly. The satisfying part was stripping the problem down until the public answer could be small, honest, and reversible. Reverse engineering gets romanticized as revelation — one magical payload, one hidden switch, one final screenshot. In reality it is usually subtraction. Learn which levers matter. Refuse to ship the ones that should stay private. Turn the rest into something another person can trust.
In that sense, the final release says as much about restraint as technique. The public tool is not the most powerful thing I found. It is the most defensible thing I found: owner-authorized, stable, recoverable, and truthful about still being Ogg 320. I mean it as praise when I say the end result is boring. Music systems deserve boring. They deserve installers that either work or fail clearly, that do not hijack the rest of the box, and that do not sell a fantasy codec in place of verified transport.
And maybe that is the real through-line between this project and other things I tinker with, whether it is kernel bugs, secure job runners, a guitar rig, or a 1950s turntable. The romance is always in the weird details, but the craft is in making the system behave. Here, behavior beat mythology. The Chord 2go got Spotify Connect. It got it publicly. And it got it in a form I can stand behind.