Articles

A touch-screen remote control for Linn Selekt DSM, using Rust on ESP32

Bombardier 7500 OLED Knob

While engineering the requirements for a client project to build a lighting control app to run on the infotainment / Cabin Management System (CMS) iPads on a Bombardier Global 6000 jet (like this), I came across this integrated screen/rotary dials built into the newest version of these planes.

Heltec ESP32-S3 Knob

While such custom hardware and embedded development was not something feasible on the given timeline (and would require physical changes to the aircraft), creating such a purpose-built gadget left me intrigued. Since I have no API-enabled smart lighting system at home, I instead opted to build a remote for my Linn Selekt DSM HiFi system instead. I knew that it does have an API, and having a dedicated piece of hardware seemed so much nicer than having to use the iPhone app (which does not have any live activities or similar to speed up control).

We all have desires, things we want to build, ways to express ourselves, ways that we find joy and meaning.

Scott Wu

As it turns out you can even get a basic variant of that hardware (IPS instead of OLED, but a solid “scroll wheel” with a ball-bearing around the display) on Amazon1. Unfortunately that device’s chip (ESP32-S3) is a little harder to get started with using my preferred Rust toolchain, so I opted for a ESP32-C6 board with an OLED display (but sadly no wheel) instead.

Luckily the overall development environment is stable at this point and the basic setup to get started is well documented. With those docs and Claude I was able to get a basic “app” running on that device in no time at all.

The streamer has a documented control protocol and with a bit of debugging real responses a client class was built in no time. On the UI layer the generated code was a bit convoluted in the “AI does not tire of writing code”-sense, e.g. display and touch handling were entirely distinct, even though of course there should be a close relation between what’s rendered on screen and what areas receives touches. But with my previous knowledge of the Flutter rendering stack, I guided the implementation in a direction I felt comfortable with, building small widgets which only redraw their own area of the screen, and having input aligned with the rendering.

One downside of the hardware I picked is that it doesn’t have a built-in battery, which would be really cool for a remote. So for now I have to tack one to the back, but I hope that the next generation of ESP32-S31 boards will come in a nicer packaging so that I can revisit this again.
Having the physical rotating knob would be an especially nice upgrade, and overall the displays could always be a bit bigger and higher resolution.

But after a bit of tinkering the whole package works now: It connects to WiFi and can be powered off via the hardware button and thus lasts a long time without needing a charge.

Linn OLED Remote

So overall I am happy about how this turned out and how it made me change my mind on what’s possible to build. Looking forward to what’s next!

How many ideas does one person have in a day, and how many of those things do they actually get to do? Until that proportion is 100%, you know there is a pretty meaningful bottleneck in terms of the drudgery of execution.

Scott Wu

The full code is available on GitHub.

Footnotes

  1. For that specific board there is even a fully implemented Roon remote control up on GitHub.

Building an Odometer for Playlist Counts in SwiftUI

For a playlist count feature in an upcoming app I wanted it to update a little more prominently as a feedback when skipping a song or adding new items to the queue. This would also apply to every normal “next song” transition, but in that case few people are likely to watch.

I knew about SwiftUI’s .contentTransition(.numericText()) but was dismayed that the number always rolled in from below, which didn’t make much sense for the usual case of -1 when the next song started playing. Turns out I was not using it correctly: One just needs to pass in the count as the value parameter, and then the effect would align the direction accordingly.

Unfortunately before finding that tiny fix, I recalled an article by Livsy about building an Odometer in SwiftUI building atop that inbound effect. The main point was that SwiftUI directly goes from current to next, whereas for a more interactive feel it can be nice to count up (or down) to show the intermediate steps. That approach works well and is a nice and compact improvement atop the core behavior.

But what kept bothering me was that SwiftUI would always use a blurred transition between the numbers instead of a real “wrap around wheel” style like we’ve seen on the iOS date / time picker.

Custom Odometer

So I set out to build my own variant based on that effect. While trying out various approaches to handle this, the most important parts were breaking up the number into separate digits to be able to control them individually and then building the animation for each one. Each digit view contains the numbers 0-9 in a wrap around way using a combined effect of rotation3DEffect, offset, scale and opacity. To keep it clean only the active digit is shown on rest, and the adjacent one becomes visible while it animates in place.

Then came a lot of playing around with the widget and adjusting the animation curves. For the default transition (±1) I just let SwiftUI handle it across 1 or 2 digits using .animation(…, value: digit). For bigger changes we step through all the intermediate numbers at a slightly faster clip, but still slow enough that one can make out the individual steps. I capped this at 20 which is plenty for the current use-case and didn’t become boring yet (though maybe I am currently too enamoured with this view). For distances beyond that we enter a kind of “reset” where each digit just rotates to the destination in the fastest way possible (not necessarily up or down as given by the count change). Here we step through with aligned timing/distances, so one digit/wheel might settle before the others (which to me makes sense if the distance was shorter). In order to manage this transition and “trust” the animation without stepping through many cases in testing each time I opted to first model the transitional steps on a data layer (which could easily be tested), and then just apply each successive step after a short timeout.

Demo

The final piece looks as follows (at the bottom, with the stock animations shown atop for comparison):

.contentTransition(.numericText()), .contentTransition(.numericText(value: count)), and OdometerView

Source Code

And this is the code to implement this:

Speeding up batch inserts in SQLite using dynamic statements

indexed_entity_store’s writeMany used to insert a batch of items in a single transaction, but using n individual REPLACE INTO statements (and then the corresponding index updates). In my testing I saw it take ~10ms for 1000 items with a small JSON payload (which serialization time is included in the total). This is considered best practice and does seem okay for most practical one-time batch imports of data (such that one did not have to resort to an async initial setup to avoid multi-frame drops).

Initially I wondered why, even inside the transaction, we had to loop over the individual inserts and could not execute a single statement which would write all data at once (up to a point I suppose, as there is surely a practical limit on how large a query can get, though than a similar constraint might apply to the in-progress transaction side.)

As it turns out we can insert multiple row just fine in a single statement, it’s just a little bit unusual to write as SQLite does not allow binding to lists of values.
So from our single insert compiled statement (REPLACE INTO entity (type, key, value) VALUES (?, ?, ?)) we have to switch to a dynamically generated on that accounts for the number of inserts we want to do: REPLACE INTO entity (type, key, value) VALUES (?1, ?, ?), (?1, ?, ?), … (?1, ?, ?). That way we can call execute this statement with a concatenated parameter list of all the entities we want to write.1

This pattern has been implemented in this PR, and indeed shows some nice improvements for my tests of batch sizes 1,000 and 10,000:

Batch sizeTransaction (old)Single statement (new)
1,000writeMany took 7.41ms
writeMany again took 17.59ms
writeMany took 2.47ms
writeMany again took 5.38ms
10,000writeMany took 69.68ms
writeMany again took 134.04ms
writeMany took 21.74ms
writeMany again took 43.21ms

Overall this resulted in a nice 3x speed-up. That is a fine start, but does not really change the ballpark speed of the operation in the general case.

Further I wondered whether the small payload sizes used in the example would benefit either one of the approach. So I ran another test where each entity had a ~10kB payload JSON.

Batch sizeTransaction (old)Single statement (new)
1,000writeMany took 261.57ms
writeMany again took 474.71ms
writeMany took 133.56ms
writeMany again took 193.89ms
10,000writeMany took 2334.00ms
writeMany again took 5128.63ms
writeMany took 1159.16ms
writeMany again took 1849.37ms

In this case the new approach still resulted in a 2x speed-up and did not run into any size limits. I did not measure the peak memory usage, but very likely this would have been higher in the new case, were all serialized entities are passed to the SQLite library in 1 call vs. the loop-approach, where Dart’s GC has a chance to clean up each individual entity after passing it off.

Lastly I wondered how much of a penalty in these real-life tests was the overall JSON serialization (which one can exchange for a smaller and/or more efficient storage format). So in the last comparison I just saved the primary payload string straight to the database, without any JSON serialization:

Batch sizeTransaction (old)Single statement (new)
1,000 (small)writeMany took 6.27ms
writeMany again took 12.00ms
writeMany took 1.87ms
writeMany again took 3.29ms
1,000 (large)writeMany took 248.43ms
writeMany again took 451.07ms
writeMany took 86.97ms
writeMany again took 138.00ms
10,000 (small)writeMany took 59.21ms
writeMany again took 121.93ms
writeMany took 12.74ms
writeMany again took 32.15ms
10,000 (large)writeMany took 1925.63ms
writeMany again took 4741.23ms
writeMany took 743.32ms
writeMany again took 1432.79ms

Interestingly this did not result in a big speed-up compared to building the JSON values in the previous test run. But somehow the new approach benefited much more from this than the old one, making it even faster especially for larger payloads.

Overall I would have expected to be able to gain more by doing less transitions from Dart to the SQLite C-library, but this confirms just how fast the in-process ffi approach is and bigger gains would have to come from elsewhere.
The biggest difference in the approach seems to driven by the library’s use of indices (to efficiently find entities later). When I disable them for testing, both approaches come very close to each other, but do not get significantly faster than the “single insert with search indices” approach. That suggests that there is a rather penalty being paid for updating the index table individually for each row entry (inside the transaction).

Footnotes

  1. The ?1 in all these cases is the entity type, which is the same across all rows, and thus we provide it only once to keep the parameter list length a bit down.