Reverse engineering my e-scooter and rewriting the firmware in rust

Last year, I bought myself an Egret GT. It’s an e-scooter that touts a range of 100km and has very large tyres which makes driving it quite comfortable. To make sure you know that it’s a high-end e-scooter, it comes with a 320x480 LCD display used as a HUD, on which the speed, driving mode, battery level and range are displayed.
Now because I have to break tinker with everything I own, I eventually decided to start figuring out how this thing worked. I can’t remember exactly why, but it was possibly due to the fact that holding the ‘down’ button on the keypad while powering the scooter would cause it to enter a firmware update mode. If you clicked a button to exit this menu, you would enter the normal ‘driving’ mode, and would be able to use the scooter without entering the PIN. While I always secure the scooter with a reasonably good lock, this still irked me a bit.
The first thing I started on was the mobile app, which allows you to unlock the scooter remotely, change a few settings, and view the battery level. I won’t bore you with the process, but what I found from skimming through the bluetooth handlers of the app was the following:
- The scooter can perform firmware updates over bluetooth, and seemingly there exists a few different places a firmware update can go (display, controller, button panel).
- Some metrics which are not shown in the app or on the scooter are transmitted over bluetooth, such as the time spent in each driving mode, device temperature, motor current, battery voltage, battery charging history. Details such as the total driving time, odometer, and charge history are transmitted to the manufacturer and stored attached to the scooter’s ID, this behaviour is not clearly mentioned in the app :)))))))
- The scooter doesn’t know its Vehicle Identification Number until the app connects and sets it. If you set this using a bluetooth debug app yourself, the Egret app can be spoofed to think the scooter is a different model. I tried to spoof the VIN of the 45km/h model of the scooter to see if the speed limit was implemented with such a simple check, but this didn’t work.
Eventually I became bored at playing with the bluetooth interface and turned to the USB-C port on the display. The manufacturer states that this is just for charging phones, and after some testing with different devices I did conclude that if the data pins were connected, the display unit wouldn’t act as either a USB host or device. But I knew better, and ordered a USB-C breakout board. When this arrived, I plugged it in and probed each pin with an oscilloscope. To my surprise, two of the USB-C pins were being used as a CAN bus (which smells horribly noncompliant).
TODO: photo of scope on scooter
To sniff this can traffic, I threw together an abomination (pictured below) using an ESP32-C6, a SN65HVD230, and a MCP2515^0.

I put together a quick program which initialised the CAN peripherals and logged every can message. Then I plugged my CAN logger into the scooter and recorded the messages during startup:
1CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]2CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]3CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]4CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]5CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]6CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]7CAN_FRAME:513,false,[0, 0, 0, 0, 0]8CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]9CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]10CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]11CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]12CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]13CAN_FRAME:513,false,[0, 0, 0, 0, 0]14CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]15CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]16CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]17CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]18CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]19CAN_FRAME:513,false,[0, 0, 0, 0, 0]20CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]21CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]22CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]23CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]24CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]25CAN_FRAME:513,false,[0, 0, 0, 0, 0]26CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]27CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]28CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]29CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]30CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]31CAN_FRAME:513,false,[0, 0, 0, 0, 0]32CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]33CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]34CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]35CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]36CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]37CAN_FRAME:513,false,[0, 0, 0, 0, 0]38CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]39CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]40CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]41CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]42CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]43CAN_FRAME:774,false,[55, 0, 0, 0, 2, 0, 0, 0]44CAN_FRAME:513,false,[0, 0, 0, 0, 0]45CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]46CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]47CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]48CAN_FRAME:494,false,[60, 0, 0, 0, 0, 0, 0, 0]49CAN_FRAME:495,false,[4c, 44, 2e, 43, 52, 2e, 53, 38]50CAN_FRAME:495,false,[30, 37, 2e, 43, 2e, 32, 2e, 31]51CAN_FRAME:495,false,[45, 47, 2e, 32, 2e, 32, 2e, 31]52CAN_FRAME:495,false,[31, 0, 0, 0, 0, 0, 0, 0]53CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]54CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]55CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]56CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]57CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]58CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]59CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]60CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]61CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]62CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]63CAN_FRAME:1856,true,[4b, 0]64CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]65CAN_FRAME:1025,false,[74, bd, 0, 0, e4, b, 0, 0]66CAN_FRAME:1857,true,[4b, 0, 0, 0, 69, 99, 52, 42]67CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]68CAN_FRAME:1860,true,[1]69CAN_FRAME:1861,true,[1, 15, 57, 20, 50, 59, 54, 34]70CAN_FRAME:774,false,[54, 0, 0, 0, 2, 0, 0, 0]71CAN_FRAME:513,false,[0, 0, 0, 0, 2]72CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, ff, 1f]73CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]74CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]1CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]2CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]3CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]4CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]5CAN_FRAME:1025,false,[74, bd, 0, 0, 16, c, 0, 0]6CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]7CAN_FRAME:513,false,[0, 0, 0, 0, 0]8CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]9CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]10CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]11CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]12CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]13CAN_FRAME:513,false,[0, 0, 0, 0, 0]14CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]15CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]16CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]17CAN_FRAME:1025,false,[73, bd, 0, 0, 3, c, 0, 0]18CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]19CAN_FRAME:513,false,[0, 0, 0, 0, 0]20CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]21CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]22CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]23CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]24CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]25CAN_FRAME:513,false,[0, 0, 0, 0, 0]26CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]27CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]28CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]29CAN_FRAME:1025,false,[72, bd, 0, 0, ef, b, 0, 0]30CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]31CAN_FRAME:513,false,[0, 0, 0, 0, 0]32CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]33CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]34CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]35CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]36CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]37CAN_FRAME:513,false,[0, 0, 0, 0, 0]38CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]39CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]40CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]41CAN_FRAME:1025,false,[74, bd, 0, 0, e9, b, 0, 0]42CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]43CAN_FRAME:774,false,[55, 0, 0, 0, 2, 0, 0, 0]44CAN_FRAME:513,false,[0, 0, 0, 0, 0]45CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, 21, 0]46CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]47CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]48CAN_FRAME:494,false,[60, 0, 0, 0, 0, 0, 0, 0]49CAN_FRAME:495,false,[4c, 44, 2e, 43, 52, 2e, 53, 38]50CAN_FRAME:495,false,[30, 37, 2e, 43, 2e, 32, 2e, 31]51CAN_FRAME:495,false,[45, 47, 2e, 32, 2e, 32, 2e, 31]52CAN_FRAME:495,false,[31, 0, 0, 0, 0, 0, 0, 0]53CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]54CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]55CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]56CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]57CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]58CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]59CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]60CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]61CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]62CAN_FRAME:495,false,[0, 0, 0, 0, 0, 0, 0, 0]63CAN_FRAME:1856,true,[4b, 0]64CAN_FRAME:1024,false,[0, 40, 87, 4, 0, 0, 0, 0]65CAN_FRAME:1025,false,[74, bd, 0, 0, e4, b, 0, 0]66CAN_FRAME:1857,true,[4b, 0, 0, 0, 69, 99, 52, 42]67CAN_FRAME:1028,false,[20, 4e, 0, 0, 1, 0, b9, b]68CAN_FRAME:1860,true,[1]69CAN_FRAME:1861,true,[1, 15, 57, 20, 50, 59, 54, 34]70CAN_FRAME:774,false,[54, 0, 0, 0, 2, 0, 0, 0]71CAN_FRAME:513,false,[0, 0, 0, 0, 2]72CAN_FRAME:515,false,[0, 0, 0, 0, 0, 0, ff, 1f]73CAN_FRAME:528,false,[0, 0, 0, 0, 0, 0, 0, 0]74CAN_FRAME:768,false,[0, 5a, 64, 5a, 64, 0, 0, 0]The CAN bus proved to be quite noisy, so to figure out what was going on I built a small tool using EGUI to show a plot of can messages against time. By plotting each can message as a dot with the y-axis as the can message ID, it becomes very easy to identify which messages are commands, responses, and periodic data.

Unfortunately at this point I still didn’t have a good idea which purpose each message had. But by sniffing the bus while running the scooter, I was able to quickly figure out which messages were used in communicating the throttle, driving mode, and motor speed:
0x300: Sent by the display to the controller. Contains the current driving mode (walk, eco, drive, sport), whether the headlight is on, and in walk mode contains a counter in the last nibble. Sending a message where the fourth byte is
a5instead of the usual5acauses the controller to reset.An example is which decodes to:
Driving mode Walk ( 0x00_90)Headlight Operating ( 0x64)Walk counter 0 0x306: Sent by the display to the controller. Contains the throttle position, the blinker lights, and the speed limit of the scooter. The speed limit has no effect on the standard GT controller, but on the GTS it sets the speed limit to 25, 35, or 45km/h. For some reason the throttle level is transmitted as a 9 bit unsigned integer with the MSB being the first bit of the second byte.
An example is which decodes to:
Throttle 511 Left blinker true Right blinker false Speed limit 25km/h ( 0)0x201: Contains motor speed, and some status flags.
An example is which decodes to:
Motor speed 1031 Walk mode false Headlight on false Brake light on true
At this point I was now able to do some amusing stuff, like controlling the scooter’s motor remotely, but this isn’t very practical or interesting. This project kind of stalled at this point until I noticed that it was possible to buy replacement motor controller and display units online. I couldn’t resist the opportunity, so I ordered replacements of both.
The first component I tore down was the controller. This was particularly difficult as the rear plate was secured very tightly with crosshead screws, of which the heads of two stripped immediately, requiring me to dremel a slot. The device was also filled with some type of potting compound, but very thankfully the compound was actually quite soft and could easily be scraped away.



After removing the potting compound, I was presented with quite the gift: None of the active components had had their markings etched away, and there was a row of four pads on the back side of the board. The MCU was marked with APM32E103xCxE (a STM32F103 clone), therefore these pins are very likely the SWD port. By using OpenOCD^1 I was able to dump the flash and the RAM^2 contents shortly after boot.
With the firmware dumped I could start analysing it with Ghidra^3. I very quickly found the main CAN message handler, which allowed me to further document the purpose of each CAN message.

I also discovered that a total of three applications live on the controller MCU: A bootloader located at 0x8000000, an ‘updater’ at 0x8003000, and the main application at 0x8006200. The bootloader sets up the CAN bus and listens for a short time to see if any ‘update’ packets arrive, to see if a firmware update over the CAN bus is in progress. For some reason both the bootloader and ‘updater’ firmware contain a mechanism to update the application firmware over CAN bus, both use a different update scheme. Another funny note is that at 0x8006000 the length of the application firmware is stored, but not as a four or eight byte unsigned integer as you’d inspect, but instead as an ascii string of the base-10 representation of the number. Even wilder is that the entire region after the length up to 0x80061ff is padded with ascii space characters, and terminated with \r\n.

After exploring a small amount further, I decided to turn my attention to the display unit. The majority of the code in the controller appears to be the FOC motor control code, and I didn’t feel particularly comfortable modifying the safety critical part of the device, especially after discovering that the controller contains some fairly reasonable safety precautions, such as shutting down if the display stops sending valid throttle positions after a short period.
Cracking open the display unit required much more effort than the controller. It’s constructed from a reasonably tough and thick (2mm) injection molded body, so I used a dremel to cut into the back side. I had assumed the front screen cover was heat welded on, and so I also started using a dremel around the edge, but once I had cut a slot and had some leverage, I was able to simply pry the cover off as it was only glued.

The board for the display was quite interesting as it had several unused through hole pin header rows and multiple microcontrollers. I identified the chips to be the following:
- Main MCU: AT32F415
- Bluetooth MCU: CH573
- NFC reader IC: FM17520
- CAN Transceiver
- SPI flash chip: W25Q128FV
One debug header was the SWD port for the main MCU, so I repeated the process of dumping the firmware there. Another provided access to the SPI flash, so I also dumped this, but it only contained only the bitmap images used by the GUI shown on the display.
The firmware for the display unit was very useful, from it I was able to ascertain:
- The display unit firmware is structured as a bootloader and a main application at 0x8008000.
- The GUI is drawn using SEGGER EMWin.
- The bluetooth MCU communicated over GPIOA 2 and 3 using UART at 57500k, using a simple framing scheme. When a bluetooth attribute is read, the CH573 sends a request message with a number indicating a handler in the main MCU firmware. The main MCU sends back a response message with the same command number and the response body.
- The NFC module also communicates over UART at 115200k, with a slightly different protocol. I didn’t look into this much further.
- The button panel on the handlebars of the scooter communicates with the display unit also over UART, at 9600k. The only message it sends is a simple bitfield of the buttons that are pressed. Interestingly, it handles the blinking of the indicators itself; It blinks the lights and also has two bits in its message which indicates the blinker state. It appears to also be able to receive firmware updates.
- The CAN bus is connected over pins GPIOA 11 and 12.
- The display is a ST7796 controller, connected over a parallel interface; All 16 pins on GPIOB are used as a parallel data bus, which allows the firmware to update the state of all pins in just one instruction.
- The ADC reads from three channels: An ambient light sensor on ch12, the throttle voltage on ch13, and the battery voltage on ch15. The firmware only reads the battery voltage to trigger an error message when it is too low, for all other usages of the battery level the firmware reads a variable updated by a CAN message sent by the battery. (Yeah, the battery is on the bus.)
- The firmware of the display unit is, like the controller, updated over CAN. And again like the controller, the actual update code lives in the bootloader; The application firmware simply reboots itself if it sees an update initiation message, the bootloader then sees the next message and starts the update process. Yes, this also means that it’s possible to modify the firmware of any scooter without authentication :)))))

Through extensive cross referencing of both the display and controller firmware, I was able to build up a mostly complete understanding of the CAN messages, the only messages I didn’t complete were some related to the apple find my feature, which I’m not particularly interested in because I don’t have an iphone and instead built my own tracker device using openhaystack, which has the extra benefit of not triggering any ‘tracker following’ messages as it rotates identity every 30 minutes :)
At this point I was now pretty sure I wanted to develop my own firmware for the display unit, I’d fully mapped out GPIO pins and peripheral configurations, and I’d also reverse engineered the UART protocol of the bluetooth MCU. I’d even put together a block diagram of all the individual components of the scooter that communicate:
There was a small problem though, the display unit MCU is the AT32F415, which is a STM clone, but it seems to not be a clone of a specific STM chip, but instead a mish-mash of STM32 peripherals, most appear to match up with the STM32F1, but the RTC seems to be from a STM32F3. This is annoying because it means I can’t just jumpstart to writing firmware using Embassy, instead I need to first build my own HAL^4.
Kossnikita had already started on this using a fork of stm32-rs, so I was thankfully able to take this and start adding support for the peripherals I needed. I must admit I mostly cheated here; for most of the peripherals I started by taking the implementation from Embassy, and then I, with both the datasheet of the stm32f1 and the at32f415 open, updated the peripheral code to match the register names used by the AT32. There’s very likely a better way here, such as adding the chip as an entry in stm32-metapac, which is a subproject of Embassy which processes SVD files to create PAC^5 crates, but I initially assumed the AT32 was more different than it is.
I started by bringing up each peripheral, the clocks and timers first, as a timer allows me to add an embassy-time-driver implementation. Then the ADC^6, external GPIO interrupts, UART, CAN, and RTC peripherals. With the HAL drivers implemented I could then start building the application code. To begin with I brought up the display, using the mipidsi crate for the display driver. With the display working I then went to write encoders and decoders for the CAN and bluetooth messages.
To implement the encoders and decoders, I used deku as it allows you to declare byte and bit level parsers for structs using a quite concise macro:
1/// 5132#[derive(deku::DekuRead, deku::DekuSize, defmt::Format, Clone, PartialEq, Eq)]3#[cfg_attr(test, derive(deku::DekuWrite, Debug))]4#[deku(bit_order = "lsb", endian = "little")]5pub struct ControllerSpeed {6 /// In km/h * 1007 #[deku(pad_bytes_after = "2")]8 pub motor_speed: u16,910 #[deku(bits = 1)]11 pub walk_mode: bool,1213 #[deku(bits = 1)]14 pub headlight_on: bool,1516 #[deku(bits = 1, pad_bits_after = "5")]17 pub brake_light_on: bool,18}1/// 5132#[derive(deku::DekuRead, deku::DekuSize, defmt::Format, Clone, PartialEq, Eq)]3#[cfg_attr(test, derive(deku::DekuWrite, Debug))]4#[deku(bit_order = "lsb", endian = "little")]5pub struct ControllerSpeed {6 /// In km/h * 1007 #[deku(pad_bytes_after = "2")]8 pub motor_speed: u16,910 #[deku(bits = 1)]11 pub walk_mode: bool,1213 #[deku(bits = 1)]14 pub headlight_on: bool,1516 #[deku(bits = 1, pad_bits_after = "5")]17 pub brake_light_on: bool,18}The neat thing about doing this in rust is that I could then take these definitions and use them in a completely different program to decode the CAN logs into something human readable:
1L1 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })2L2 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })3L3 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })4L4 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })5L5 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })6L6 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })7L7 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })8L8 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })9L9 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })10L10 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })11L11 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })12L12 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })13L13 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })14L14 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })15L15 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })16L16 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })17L17 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })18L18 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })19L19 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })20L20 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })21L21 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })22L22 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })23L23 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })24L24 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })25L25 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })26L26 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })27L27 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })28L28 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })29L29 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })30L30 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })31L31 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })32L32 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })33L33 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })34L34 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })35L35 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })36L36 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })37L37 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })38L38 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })39L39 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })40L40 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })41L41 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })42L42 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })43L43 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 85, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })44L44 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })45L45 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })46L46 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })47L47 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })48L48 id=494 ext=false from=display unknown [60, 00, 00, 00, 00, 00, 00, 00]49L49 id=495 ext=false from=unknown unknown [4c, 44, 2e, 43, 52, 2e, 53, 38]50L50 id=495 ext=false from=unknown unknown [30, 37, 2e, 43, 2e, 32, 2e, 31]51L51 id=495 ext=false from=unknown unknown [45, 47, 2e, 32, 2e, 32, 2e, 31]52L52 id=495 ext=false from=unknown unknown [31, 00, 00, 00, 00, 00, 00, 00]53L53 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]54L54 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]55L55 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]56L56 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]57L57 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]58L58 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })59L59 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]60L60 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]61L61 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]62L62 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]63L63 id=1856 ext=true from=display unknown [4b, 00]64L64 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })65L65 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3044 })66L66 id=1857 ext=true from=unknown unknown [4b, 00, 00, 00, 69, 99, 52, 42]67L67 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })68L68 id=1860 ext=true from=display unknown [01]69L69 id=1861 ext=true from=battery unknown [01, 15, 57, 20, 50, 59, 54, 34]70L70 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 84, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })71L71 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: true, brake_light_on: false })72L72 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 8191 })73L73 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })74L74 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })1L1 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })2L2 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })3L3 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })4L4 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })5L5 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3094 })6L6 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })7L7 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })8L8 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })9L9 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })10L10 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })11L11 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })12L12 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })13L13 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })14L14 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })15L15 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })16L16 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })17L17 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48499, current_ma: 3075 })18L18 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })19L19 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })20L20 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })21L21 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })22L22 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })23L23 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })24L24 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })25L25 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })26L26 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })27L27 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })28L28 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })29L29 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48498, current_ma: 3055 })30L30 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })31L31 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })32L32 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })33L33 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })34L34 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })35L35 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })36L36 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })37L37 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })38L38 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })39L39 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })40L40 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })41L41 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3049 })42L42 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })43L43 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 85, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })44L44 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: false, brake_light_on: false })45L45 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 33 })46L46 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })47L47 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })48L48 id=494 ext=false from=display unknown [60, 00, 00, 00, 00, 00, 00, 00]49L49 id=495 ext=false from=unknown unknown [4c, 44, 2e, 43, 52, 2e, 53, 38]50L50 id=495 ext=false from=unknown unknown [30, 37, 2e, 43, 2e, 32, 2e, 31]51L51 id=495 ext=false from=unknown unknown [45, 47, 2e, 32, 2e, 32, 2e, 31]52L52 id=495 ext=false from=unknown unknown [31, 00, 00, 00, 00, 00, 00, 00]53L53 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]54L54 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]55L55 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]56L56 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]57L57 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]58L58 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })59L59 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]60L60 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]61L61 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]62L62 id=495 ext=false from=unknown unknown [00, 00, 00, 00, 00, 00, 00, 00]63L63 id=1856 ext=true from=display unknown [4b, 00]64L64 id=1024 ext=false from=battery BatteryCommandState(BatteryCommandState { command: 16384, state: 1159, estimated_range: 0 })65L65 id=1025 ext=false from=battery BatteryVoltageCurrent(BatteryVoltageCurrent { voltage_mv: 48500, current_ma: 3044 })66L66 id=1857 ext=true from=unknown unknown [4b, 00, 00, 00, 69, 99, 52, 42]67L67 id=1028 ext=false from=battery BatteryCapacityTemp(BatteryCapacityTemp { capacity_mah: 20000, battery_charged: true, battery_charging: false, battery_temp: 270 })68L68 id=1860 ext=true from=display unknown [01]69L69 id=1861 ext=true from=battery unknown [01, 15, 57, 20, 50, 59, 54, 34]70L70 id=774 ext=false from=display DisplayThrottle(DisplayThrottle { throttle: 84, left_blinker: false, right_blinker: false, speed_limit: 0, magic: DekuConst })71L71 id=513 ext=false from=controller ControllerSpeed(ControllerSpeed { motor_speed: 0, walk_mode: false, headlight_on: true, brake_light_on: false })72L72 id=515 ext=false from=controller ControllerSpeedMode(ControllerSpeedMode { unknown: 8191 })73L73 id=528 ext=false from=controller ControllerSpeedLimit(ControllerSpeedLimit { speed_limit: false })74L74 id=768 ext=false from=display DisplaySpeedMode(DisplaySpeedMode { mode: 0, mode_high: 90, headlight: 100, magic: Normal, speed_mode_byte: 0, walk_counter: 0 })Now that the protocols are implemented, it becomes quite easy to write state machines using Embassy to handle incoming messages and update relevant state. For example, this is the state machine which handles maintaining the ‘system state’ of the scooter, which is all the passively read values such as the battery level or the throttle position.
1#[derive(PartialEq, Eq, defmt::Format, Clone)]2pub struct SystemState {3 /// motor speed, in deca meters per hour (speed / 100 = km/h)4 pub motor_speed: u16,5 pub headlight_on: bool,6 pub brake_light_on: bool,78 pub controller_temp: u8,9 pub system_voltage: SystemVoltage,10 pub controller_speed_limit_mode: bool,1112 pub battery_current: i16,13 pub battery_debug: BatteryDebug,14 pub battery_info: BatteryInfo,1516 pub throttle: Throttle,17 pub ambient_light: AmbientLight,1819 pub buttons: Buttons,20 /// in km21 pub odometer: u16,2223 /// in km24 pub predicted_range: u16,25}2627#[embassy_executor::task]28async fn system_state_updater() {29 let can_messages = CAN_MESSAGES.receiver();30 let bt_commands = BT_COMMANDS.receiver();31 let mut adc_readings = crate::adc::ADC_READINGS.subscriber().unwrap();32 let state_updated = STATE_UPDATES.sender();33 let mut buttons_reader = BUTTON_STATE_WATCH.receiver().unwrap();3435 let mut update_private_state_ticker =36 Ticker::every(Duration::from_secs(PRIVATE_STATE_UPDATE_PERIOD_SECS));3738 let mut private_state = PrivateState::default();3940 loop {41 let updated = match select::select5(42 can_messages.receive(),43 bt_commands.receive(),44 adc_readings.next_message_pure(),45 buttons_reader.changed(),46 update_private_state_ticker.next(),47 )48 .await49 {50 select::Either5::First(can_msg) => {51 update_state(|s| s.update_from_can_message(&can_msg));52 private_state.update_from_can_message(&can_msg);53 true54 }55 select::Either5::Second(_) => false,56 select::Either5::Third(reading) => {57 update_state(|s| s.update_from_adc_reading(reading))58 }59 select::Either5::Fourth(buttons) => {60 update_state(|s| s.buttons = buttons);61 true62 }63 select::Either5::Fifth(_) => {64 private_state.periodic_update();65 update_state(|s| private_state.update_public(s));66 true67 }68 };6970 if updated {71 state_updated.send(());72 }73 }74}7576impl SystemState {77 pub fn update_from_can_message(&mut self, msg: &CanMessage) {78 match msg {79 CanMessage::ControllerStatus(ControllerStatus { battery_level, .. }) => {80 self.battery_info.level_from_controller = *battery_level;81 }82 CanMessage::ControllerSpeed(ControllerSpeed {83 motor_speed,84 headlight_on,85 brake_light_on,86 ..87 }) => {88 self.motor_speed = *motor_speed;89 self.headlight_on = *headlight_on;90 self.brake_light_on = *brake_light_on;91 }92 CanMessage::ControllerTempMotor(ControllerTempMotor { temp, voltage }) => {93 self.controller_temp = *temp;94 self.system_voltage.from_controller = *voltage;95 }96 CanMessage::ControllerSpeedMode(ControllerSpeedMode { .. }) => {}97 CanMessage::ControllerSpeedLimit(ControllerSpeedLimit { speed_limit }) => {98 self.controller_speed_limit_mode = *speed_limit;99 }100 CanMessage::BatteryCommandState(BatteryCommandState {101 command,102 state,103 estimated_range,104 }) => {105 self.battery_debug = BatteryDebug {106 command: *command,107 state: *state,108 estimated_range: estimated_range.truncate(),109 }110 }111 CanMessage::BatteryVoltageCurrent(BatteryVoltageCurrent {112 voltage_mv,113 current_ma,114 }) => {115 self.system_voltage.from_battery = voltage_mv.truncate();116 self.battery_current = current_ma.truncate();117 }118 CanMessage::BatteryChargeLevel(BatteryChargeLevel {119 relative_soc,120 absolute_soc_mah,121 }) => {122 self.battery_info.relative_soc = relative_soc.truncate();123 self.battery_info.absolute_soc = absolute_soc_mah.truncate();124 }125 CanMessage::BatteryStateOfHealth(BatteryStateOfHealth {126 relative_soh,127 absolute_soh_mah,128 }) => {129 self.battery_info.relative_soh = *relative_soh;130 self.battery_info.absolute_soh = absolute_soh_mah.truncate();131 }132 CanMessage::BatteryCapacityTemp(BatteryCapacityTemp {133 capacity_mah,134 battery_charged,135 battery_charging,136 battery_temp,137 }) => {138 self.battery_info.capacity = *capacity_mah;139 self.battery_info.charged = *battery_charged;140 self.battery_info.charging = *battery_charging;141 self.battery_info.temperature = *battery_temp;142 }143 _ => {}144 }145 }146}1#[derive(PartialEq, Eq, defmt::Format, Clone)]2pub struct SystemState {3 /// motor speed, in deca meters per hour (speed / 100 = km/h)4 pub motor_speed: u16,5 pub headlight_on: bool,6 pub brake_light_on: bool,78 pub controller_temp: u8,9 pub system_voltage: SystemVoltage,10 pub controller_speed_limit_mode: bool,1112 pub battery_current: i16,13 pub battery_debug: BatteryDebug,14 pub battery_info: BatteryInfo,1516 pub throttle: Throttle,17 pub ambient_light: AmbientLight,1819 pub buttons: Buttons,20 /// in km21 pub odometer: u16,2223 /// in km24 pub predicted_range: u16,25}2627#[embassy_executor::task]28async fn system_state_updater() {29 let can_messages = CAN_MESSAGES.receiver();30 let bt_commands = BT_COMMANDS.receiver();31 let mut adc_readings = crate::adc::ADC_READINGS.subscriber().unwrap();32 let state_updated = STATE_UPDATES.sender();33 let mut buttons_reader = BUTTON_STATE_WATCH.receiver().unwrap();3435 let mut update_private_state_ticker =36 Ticker::every(Duration::from_secs(PRIVATE_STATE_UPDATE_PERIOD_SECS));3738 let mut private_state = PrivateState::default();3940 loop {41 let updated = match select::select5(42 can_messages.receive(),43 bt_commands.receive(),44 adc_readings.next_message_pure(),45 buttons_reader.changed(),46 update_private_state_ticker.next(),47 )48 .await49 {50 select::Either5::First(can_msg) => {51 update_state(|s| s.update_from_can_message(&can_msg));52 private_state.update_from_can_message(&can_msg);53 true54 }55 select::Either5::Second(_) => false,56 select::Either5::Third(reading) => {57 update_state(|s| s.update_from_adc_reading(reading))58 }59 select::Either5::Fourth(buttons) => {60 update_state(|s| s.buttons = buttons);61 true62 }63 select::Either5::Fifth(_) => {64 private_state.periodic_update();65 update_state(|s| private_state.update_public(s));66 true67 }68 };6970 if updated {71 state_updated.send(());72 }73 }74}7576impl SystemState {77 pub fn update_from_can_message(&mut self, msg: &CanMessage) {78 match msg {79 CanMessage::ControllerStatus(ControllerStatus { battery_level, .. }) => {80 self.battery_info.level_from_controller = *battery_level;81 }82 CanMessage::ControllerSpeed(ControllerSpeed {83 motor_speed,84 headlight_on,85 brake_light_on,86 ..87 }) => {88 self.motor_speed = *motor_speed;89 self.headlight_on = *headlight_on;90 self.brake_light_on = *brake_light_on;91 }92 CanMessage::ControllerTempMotor(ControllerTempMotor { temp, voltage }) => {93 self.controller_temp = *temp;94 self.system_voltage.from_controller = *voltage;95 }96 CanMessage::ControllerSpeedMode(ControllerSpeedMode { .. }) => {}97 CanMessage::ControllerSpeedLimit(ControllerSpeedLimit { speed_limit }) => {98 self.controller_speed_limit_mode = *speed_limit;99 }100 CanMessage::BatteryCommandState(BatteryCommandState {101 command,102 state,103 estimated_range,104 }) => {105 self.battery_debug = BatteryDebug {106 command: *command,107 state: *state,108 estimated_range: estimated_range.truncate(),109 }110 }111 CanMessage::BatteryVoltageCurrent(BatteryVoltageCurrent {112 voltage_mv,113 current_ma,114 }) => {115 self.system_voltage.from_battery = voltage_mv.truncate();116 self.battery_current = current_ma.truncate();117 }118 CanMessage::BatteryChargeLevel(BatteryChargeLevel {119 relative_soc,120 absolute_soc_mah,121 }) => {122 self.battery_info.relative_soc = relative_soc.truncate();123 self.battery_info.absolute_soc = absolute_soc_mah.truncate();124 }125 CanMessage::BatteryStateOfHealth(BatteryStateOfHealth {126 relative_soh,127 absolute_soh_mah,128 }) => {129 self.battery_info.relative_soh = *relative_soh;130 self.battery_info.absolute_soh = absolute_soh_mah.truncate();131 }132 CanMessage::BatteryCapacityTemp(BatteryCapacityTemp {133 capacity_mah,134 battery_charged,135 battery_charging,136 battery_temp,137 }) => {138 self.battery_info.capacity = *capacity_mah;139 self.battery_info.charged = *battery_charged;140 self.battery_info.charging = *battery_charging;141 self.battery_info.temperature = *battery_temp;142 }143 _ => {}144 }145 }146}The main part of my scooter firmware is what I call the ‘operation state’, which is the driving state and all other state which is influenced by the driver. That is: whether the scooter is locked or unlocked, the speed mode the scooter is in, whether the headlight is on, off, or in auto mode, and the speed limit the scooter is configured to.
For the operation state machine, I used a traditional actor model; the state machine receives command such as ‘unlock’ and ‘set speed mode’, and handles sending CAN messages to the controller depending on the current operation state and throttle position. The operation state itself is an enum with two states: Locked, and Unlocked. The main data of the operation state is only available within the unlocked state, which should prevent any chance of misbehaviour, such as being able to drive while the scooter is locked.
1#[derive(PartialEq, Eq, defmt::Format, Clone, Copy)]2pub enum OperationCommand {3 Unlock,4 Lock,5 UnlockSpeedLimit,6 LockSpeedLimit,7 SetSpeedLimit(u16),8 SetSpeedMode(SpeedMode),9 SetHeadlightMode(HeadlightMode),10}1112#[derive(PartialEq, Eq, defmt::Format, Clone)]13pub enum OperationState {14 Locked(Option<UnlockCode>),15 Active(ActiveState),16}1718#[derive(PartialEq, Eq, defmt::Format, Clone)]19pub struct ActiveState {20 pub throttle: Throttle,2122 /// Speed limit in km/h * 10, we'll later use this to select the 25/35/45 limit23 /// sent to the controller24 pub speed_limit: u16,2526 pub speed_limit_unlocked: bool,2728 pub speed_mode: SpeedMode,2930 pub walk_mode_counter: Option<NibbleCounter>,3132 pub headlight_mode: HeadlightMode,33 pub headlight_config: HeadlightConfig,34}3536#[embassy_executor::task]37async fn operation_task() {38 defmt::info!("Operation task startup");3940 let mut send_can_messages_ticker = embassy_time::Ticker::every(Duration::from_millis(100));4142 let mut throttle_readings = crate::adc::THROTTLE_READINGS.receiver().unwrap();43 let mut ambient_readings = crate::adc::AMBIENT_READINGS.receiver().unwrap();4445 let operation_commands = OPERATION_COMMANDS.receiver();4647 let state_updates = STATE_UPDATES.sender();4849 let unlock_code = UnlockCode::get_stored().await;50 defmt::info!("Loaded unlock code: {}", unlock_code);5152 update_state(|s| {53 if s.is_locked() {54 *s = OperationState::Locked(Some(unlock_code));55 }56 });5758 state_updates.send(());5960 loop {61 match select::select4(62 send_can_messages_ticker.next(),63 throttle_readings64 .changed()65 .with_timeout(Duration::from_secs(1)),66 ambient_readings.changed(),67 operation_commands.receive(),68 )69 .await70 {71 select::Either4::First(_) => {72 send_speed_and_throttle_can_messages().await;73 }74 select::Either4::Second(Ok(throttle)) => {75 update_state(|s| s.update_if_active(|a| a.throttle = throttle));7677 state_updates.send(());78 }79 select::Either4::Second(Err(_)) => {80 panic!("Operation task did not receive throttle update in time");81 }82 select::Either4::Third(ambient) => update_state(|s| {83 s.update_if_active(|a| {84 if a.headlight_mode == HeadlightMode::Auto {85 if !a.headlight_config.auto_on && ambient.mapped < a.headlight_config.low {86 a.headlight_config.auto_on = true;87 state_updates.send(());88 } else if a.headlight_config.auto_on89 && ambient.mapped > a.headlight_config.high90 {91 a.headlight_config.auto_on = false;92 state_updates.send(());93 }94 }95 })96 }),97 select::Either4::Fourth(op_cmd) => {98 defmt::info!("Handling op command: {}", op_cmd);99 match op_cmd {100 OperationCommand::Unlock => {101 let speed_limit = SpeedLimit::get_stored().await.get_validated();102 let speed_mode = SpeedMode::get_stored().await;103 let headlight_mode = HeadlightMode::get_stored().await;104105 update_state(|s: &mut OperationState| {106 *s = OperationState::Active(ActiveState {107 throttle: Throttle(0),108 speed_limit,109 speed_limit_unlocked: false,110 walk_mode_counter: None,111 speed_mode,112 headlight_mode,113 headlight_config: HeadlightConfig {114 low: 5,115 high: 13,116 auto_on: false,117 },118 })119 })120 }121 OperationCommand::Lock => {122 let unlock_code = UnlockCode::get_stored().await;123 update_state(|s| *s = OperationState::Locked(Some(unlock_code)))124 }125 OperationCommand::SetSpeedLimit(new_limit) => {126 let validated = SpeedLimit::new_validated(new_limit);127 SpeedLimit::update_stored(validated);128 update_state(|s| {129 s.update_if_active(|a| a.speed_limit = validated.get_validated())130 })131 }132 OperationCommand::SetSpeedMode(speed_mode) => {133 SpeedMode::update_stored(speed_mode);134 update_state(|s| s.update_if_active(|a| a.speed_mode = speed_mode))135 }136 OperationCommand::SetHeadlightMode(headlight_mode) => {137 HeadlightMode::update_stored(headlight_mode);138 update_state(|s| {139 s.update_if_active(|a| {140 a.headlight_mode = headlight_mode;141 })142 })143 }144 OperationCommand::UnlockSpeedLimit => update_state(|s| {145 s.update_if_active(|a| {146 a.speed_limit_unlocked = true;147 })148 }),149 OperationCommand::LockSpeedLimit => update_state(|s| {150 s.update_if_active(|a| {151 a.speed_limit_unlocked = false;152 })153 }),154 }155156 state_updates.send(());157 }158 }159 }160}1611#[derive(PartialEq, Eq, defmt::Format, Clone, Copy)]2pub enum OperationCommand {3 Unlock,4 Lock,5 UnlockSpeedLimit,6 LockSpeedLimit,7 SetSpeedLimit(u16),8 SetSpeedMode(SpeedMode),9 SetHeadlightMode(HeadlightMode),10}1112#[derive(PartialEq, Eq, defmt::Format, Clone)]13pub enum OperationState {14 Locked(Option<UnlockCode>),15 Active(ActiveState),16}1718#[derive(PartialEq, Eq, defmt::Format, Clone)]19pub struct ActiveState {20 pub throttle: Throttle,2122 /// Speed limit in km/h * 10, we'll later use this to select the 25/35/45 limit23 /// sent to the controller24 pub speed_limit: u16,2526 pub speed_limit_unlocked: bool,2728 pub speed_mode: SpeedMode,2930 pub walk_mode_counter: Option<NibbleCounter>,3132 pub headlight_mode: HeadlightMode,33 pub headlight_config: HeadlightConfig,34}3536#[embassy_executor::task]37async fn operation_task() {38 defmt::info!("Operation task startup");3940 let mut send_can_messages_ticker = embassy_time::Ticker::every(Duration::from_millis(100));4142 let mut throttle_readings = crate::adc::THROTTLE_READINGS.receiver().unwrap();43 let mut ambient_readings = crate::adc::AMBIENT_READINGS.receiver().unwrap();4445 let operation_commands = OPERATION_COMMANDS.receiver();4647 let state_updates = STATE_UPDATES.sender();4849 let unlock_code = UnlockCode::get_stored().await;50 defmt::info!("Loaded unlock code: {}", unlock_code);5152 update_state(|s| {53 if s.is_locked() {54 *s = OperationState::Locked(Some(unlock_code));55 }56 });5758 state_updates.send(());5960 loop {61 match select::select4(62 send_can_messages_ticker.next(),63 throttle_readings64 .changed()65 .with_timeout(Duration::from_secs(1)),66 ambient_readings.changed(),67 operation_commands.receive(),68 )69 .await70 {71 select::Either4::First(_) => {72 send_speed_and_throttle_can_messages().await;73 }74 select::Either4::Second(Ok(throttle)) => {75 update_state(|s| s.update_if_active(|a| a.throttle = throttle));7677 state_updates.send(());78 }79 select::Either4::Second(Err(_)) => {80 panic!("Operation task did not receive throttle update in time");81 }82 select::Either4::Third(ambient) => update_state(|s| {83 s.update_if_active(|a| {84 if a.headlight_mode == HeadlightMode::Auto {85 if !a.headlight_config.auto_on && ambient.mapped < a.headlight_config.low {86 a.headlight_config.auto_on = true;87 state_updates.send(());88 } else if a.headlight_config.auto_on89 && ambient.mapped > a.headlight_config.high90 {91 a.headlight_config.auto_on = false;92 state_updates.send(());93 }94 }95 })96 }),97 select::Either4::Fourth(op_cmd) => {98 defmt::info!("Handling op command: {}", op_cmd);99 match op_cmd {100 OperationCommand::Unlock => {101 let speed_limit = SpeedLimit::get_stored().await.get_validated();102 let speed_mode = SpeedMode::get_stored().await;103 let headlight_mode = HeadlightMode::get_stored().await;104105 update_state(|s: &mut OperationState| {106 *s = OperationState::Active(ActiveState {107 throttle: Throttle(0),108 speed_limit,109 speed_limit_unlocked: false,110 walk_mode_counter: None,111 speed_mode,112 headlight_mode,113 headlight_config: HeadlightConfig {114 low: 5,115 high: 13,116 auto_on: false,117 },118 })119 })120 }121 OperationCommand::Lock => {122 let unlock_code = UnlockCode::get_stored().await;123 update_state(|s| *s = OperationState::Locked(Some(unlock_code)))124 }125 OperationCommand::SetSpeedLimit(new_limit) => {126 let validated = SpeedLimit::new_validated(new_limit);127 SpeedLimit::update_stored(validated);128 update_state(|s| {129 s.update_if_active(|a| a.speed_limit = validated.get_validated())130 })131 }132 OperationCommand::SetSpeedMode(speed_mode) => {133 SpeedMode::update_stored(speed_mode);134 update_state(|s| s.update_if_active(|a| a.speed_mode = speed_mode))135 }136 OperationCommand::SetHeadlightMode(headlight_mode) => {137 HeadlightMode::update_stored(headlight_mode);138 update_state(|s| {139 s.update_if_active(|a| {140 a.headlight_mode = headlight_mode;141 })142 })143 }144 OperationCommand::UnlockSpeedLimit => update_state(|s| {145 s.update_if_active(|a| {146 a.speed_limit_unlocked = true;147 })148 }),149 OperationCommand::LockSpeedLimit => update_state(|s| {150 s.update_if_active(|a| {151 a.speed_limit_unlocked = false;152 })153 }),154 }155156 state_updates.send(());157 }158 }159 }160}161Additionally I also implemented embassy tasks for reading the throttle and ambient light sensor levels with the ADC, and reading the button panel messages. I also set up the RTC peripheral and added a bluetooth message handler to update it. The original firmware doesn’t use the RTC to display the time, but as I have control over the bluetooth messages I wrote a small android app to repurpose one of the bluetooth commands to transfer the current time.
Now we get to where I spent most of my effort: The GUI.
I came across Buoyant, which is a rust library providing a SwiftUI-like interface for constructing GUIs, while also requiring no memory allocations or the standard library. It also comes with focus/keyboard navigation support, which is exactly what I need as the scooter has no touchscreen.
I really like the API offered by buoyant, I really didn’t have to fight much to put together a UI that looks quite pretty. For example, here’s the entire code for the pin entry screen:
1#[derive(PartialEq, Eq, Clone, Copy, defmt::Format, Default)]2pub struct State {3 pin: [pin_digit::PinDigit; 4],4}56#[must_use]7pub fn view(state: &state::State) -> impl View<ColorFormat, state::State> + use<> {8 VStack::new((9 Text::new("Enter PIN", &font::B612_REGULAR).foreground_color(colour::on_background()),10 Lens::new(pin_entry(&state.locked_state), |s: &mut state::State| {11 &mut s.locked_state12 }),13 Button::new(14 |state: &mut state::State| {15 if state.locked_state.pin16 == state17 .operation_state18 .as_locked()19 .and_then(|x| *x)20 .unwrap_or_default()21 .digits22 {23 state.locked_state.pin = Default::default();24 let _ = state.next_operation_commands.push(OperationCommand::Unlock);25 }26 },27 |bs| {28 Text::new("Confirm", &font::B612_REGULAR)29 .padding(Edges::All, 4)30 .foreground_color(if bs.is_focused() {31 colour::on_primary()32 } else {33 colour::on_primary_fixed()34 })35 .background_color(36 if bs.is_focused() {37 colour::primary()38 } else {39 colour::primary_fixed()40 },41 RoundedRectangle::new(4),42 )43 },44 ),45 ))46 .with_spacing(2)47 .with_alignment(HorizontalAlignment::Center)48 .flex_infinite_width(HorizontalAlignment::Center)49 .with_infinite_max_height()50 .map_event(|event, _: &mut ()| match event {51 Event::KeyDown(key) => match *key {52 keys::UP_CLICK => Some(FocusAction::Previous.into_event(focus::GROUP_0)),53 keys::DOWN_CLICK => Some(FocusAction::Next.into_event(focus::GROUP_0)),54 keys::CONFIRM_CLICK => Some(FocusAction::Select.into_event(focus::GROUP_0)),55 _ => None,56 },57 Event::KeyUp(_) => None,58 _ => Some(event.clone()),59 })60}6162fn pin_entry(state: &State) -> impl View<ColorFormat, State> + use<> {63 HStack::new((64 Lens::new(pin_piece(state.pin[0]), |s: &mut State| &mut s.pin[0]),65 Lens::new(pin_piece(state.pin[1]), |s: &mut State| &mut s.pin[1]),66 Lens::new(pin_piece(state.pin[2]), |s: &mut State| &mut s.pin[2]),67 Lens::new(pin_piece(state.pin[3]), |s: &mut State| &mut s.pin[3]),68 ))69}7071fn pin_piece(pin: pin_digit::PinDigit) -> impl View<ColorFormat, pin_digit::PinDigit> {72 Rotary::new(73 |pin: &mut pin_digit::PinDigit, event: RotaryEvent| match event {74 RotaryEvent::Next => *pin = pin.prev(),75 RotaryEvent::Previous => *pin = pin.next(),76 RotaryEvent::Select | RotaryEvent::Exit => {}77 },78 move |rotary_state| {79 Text::new(pin.as_str(), &font::B612_REGULAR_LARGE_NUMBERS)80 .padding(Edges::All, 4)81 .foreground_color(82 match rotary_state {83 RotaryState::UnFocused => colour::on_background(),84 RotaryState::Focused => colour::on_background(),85 RotaryState::Captive => colour::on_primary_fixed(),86 }87 )88 .background(Alignment::Center,89 match_view!(rotary_state, {90 RotaryState::UnFocused => EmptyView,91 RotaryState::Focused => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary()),92 RotaryState::Captive => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary_fixed())93 })94 )95 .content_shape(Rectangle.corner_radius(4))96 },97 )98}1#[derive(PartialEq, Eq, Clone, Copy, defmt::Format, Default)]2pub struct State {3 pin: [pin_digit::PinDigit; 4],4}56#[must_use]7pub fn view(state: &state::State) -> impl View<ColorFormat, state::State> + use<> {8 VStack::new((9 Text::new("Enter PIN", &font::B612_REGULAR).foreground_color(colour::on_background()),10 Lens::new(pin_entry(&state.locked_state), |s: &mut state::State| {11 &mut s.locked_state12 }),13 Button::new(14 |state: &mut state::State| {15 if state.locked_state.pin16 == state17 .operation_state18 .as_locked()19 .and_then(|x| *x)20 .unwrap_or_default()21 .digits22 {23 state.locked_state.pin = Default::default();24 let _ = state.next_operation_commands.push(OperationCommand::Unlock);25 }26 },27 |bs| {28 Text::new("Confirm", &font::B612_REGULAR)29 .padding(Edges::All, 4)30 .foreground_color(if bs.is_focused() {31 colour::on_primary()32 } else {33 colour::on_primary_fixed()34 })35 .background_color(36 if bs.is_focused() {37 colour::primary()38 } else {39 colour::primary_fixed()40 },41 RoundedRectangle::new(4),42 )43 },44 ),45 ))46 .with_spacing(2)47 .with_alignment(HorizontalAlignment::Center)48 .flex_infinite_width(HorizontalAlignment::Center)49 .with_infinite_max_height()50 .map_event(|event, _: &mut ()| match event {51 Event::KeyDown(key) => match *key {52 keys::UP_CLICK => Some(FocusAction::Previous.into_event(focus::GROUP_0)),53 keys::DOWN_CLICK => Some(FocusAction::Next.into_event(focus::GROUP_0)),54 keys::CONFIRM_CLICK => Some(FocusAction::Select.into_event(focus::GROUP_0)),55 _ => None,56 },57 Event::KeyUp(_) => None,58 _ => Some(event.clone()),59 })60}6162fn pin_entry(state: &State) -> impl View<ColorFormat, State> + use<> {63 HStack::new((64 Lens::new(pin_piece(state.pin[0]), |s: &mut State| &mut s.pin[0]),65 Lens::new(pin_piece(state.pin[1]), |s: &mut State| &mut s.pin[1]),66 Lens::new(pin_piece(state.pin[2]), |s: &mut State| &mut s.pin[2]),67 Lens::new(pin_piece(state.pin[3]), |s: &mut State| &mut s.pin[3]),68 ))69}7071fn pin_piece(pin: pin_digit::PinDigit) -> impl View<ColorFormat, pin_digit::PinDigit> {72 Rotary::new(73 |pin: &mut pin_digit::PinDigit, event: RotaryEvent| match event {74 RotaryEvent::Next => *pin = pin.prev(),75 RotaryEvent::Previous => *pin = pin.next(),76 RotaryEvent::Select | RotaryEvent::Exit => {}77 },78 move |rotary_state| {79 Text::new(pin.as_str(), &font::B612_REGULAR_LARGE_NUMBERS)80 .padding(Edges::All, 4)81 .foreground_color(82 match rotary_state {83 RotaryState::UnFocused => colour::on_background(),84 RotaryState::Focused => colour::on_background(),85 RotaryState::Captive => colour::on_primary_fixed(),86 }87 )88 .background(Alignment::Center,89 match_view!(rotary_state, {90 RotaryState::UnFocused => EmptyView,91 RotaryState::Focused => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary()),92 RotaryState::Captive => RoundedRectangle::new(4).stroked(2).foreground_color(colour::primary_fixed())93 })94 )95 .content_shape(Rectangle.corner_radius(4))96 },97 )98}

The flexbox layout made building the homescreen also very easy, I know it’s quite overkill for static content on a fixed size screen, but it saves me having to position elements manually.
There was only one problem with Buoyant: the MCU has only 32k of RAM, which is nowhere near enough for a framebuffer. This means when Buoyant draws a frame it has to draw every single component to the display; the pixels with text on in the above homescreen view would be drawn three times: First the background, then the box, and finally the text. Since we don’t have a framebuffer we also need to send many more repositioning commands to the display. The end result is that the display flickers so much that it is unusable. The solution is to only redraw the components that change, and thankfully rust made updating Buoyant to support this relatively pain free.
A naïve solution to tracking what needs to redraw is to keep track of a bounding rectangle, which starts empty and, when a component is marked dirty, is expanded to surround its previous self and the rectangle containing the dirty component. But this isn’t good if you have two components at opposite ends of the screen that both update on the same frame. My solution to this is to instead insert the bounding boxes of dirtied components into a quadtree^7, which allows the areas that need to be redrawn (and therefore the components that need to redraw) to be tracked more precisely. My solution goes a step further and tracks two quadtrees: one tracks dirty regions and one tracks ‘overdrawn’ regions. A component is marked as changed if a property changes, or its bounding box overlaps with either tree before checking its children, or if its bounding box overlaps with the dirty tree after checking its children. When a component changes, its prior bounding box is added to the dirty tree, and its new bounding box is added to the ‘overdrawn’ tree. When a rectangle is added to the overdrawn tree, any rectangles contained within are removed from the dirty tree. If a node redraws but doesn’t change its bounding box, then any elements behind it don’t need to also redraw, but we do want its children to redraw.
TODO: video of display contents changing
So far, all this development has been happening on the cracked open display unit using a debugger, with the application starting from 0x800000 so that no code was running before mine. But when running on a real scooter, the application base is at 0x8008000. This is normally fine; the initialisation code of the application firmware just needs to be configured to configure the interrupt vector base address, so that the bootloader’s interrupts aren’t used instead of yours. This was almost the case with my firmware too, but for some reason, when the bootloader was allowed to run the CAN bus would no longer receive messages; instead it would repeatedly throw framing errors. After a lot of head bashing and printing of register contents, it dawned on me that the bootloader was enabling some clocks and initialising some peripherals; when a clock is running it becomes impossible to change things like the divisor, this leads to my clock initialisation code not being able to set the correct divisor or clock source for the CAN peripheral, leading to it calculating its timing parameters with the wrong clock frequency…
The solution ends up being this horrible dance that needs to be done:
1 let dp = unsafe { hal::pac::Peripherals::steal() };2 let mut cp = cortex_m::peripheral::Peripherals::take().unwrap();34 // Bootloader jumps to us with some clocks enabled, so the first thing5 // we do is tear everything down.67 // There might be a better way, and some of these are probably not necessary.8 dp.CRM.ctrl().reset();9 dp.CRM.cfg().reset();10 dp.CRM.clkint().reset();11 dp.CRM.pll().reset();12 dp.CRM.misc1().modify(|_, w| unsafe {13 w.clkoutdiv()14 .bits(0)15 .hickdiv()16 .bit(false)17 .clkout_sel3()18 .bit(false)19 });2021 dp.CRM.apb2en().modify(|_, w| {22 w.iomux()23 .bit(false)24 .gpioa()25 .bit(false)26 .gpiof()27 .bit(false)28 .spi1()29 .bit(false)30 });31 dp.CRM.apb1en().modify(|_, w| w.can1().bit(false));32 dp.CRM.ahben().modify(|_, w| w.dma1().bit(false));3334 dp.CRM35 .ctrl()36 .modify(|_, w| w.pllen().clear_bit().hexten().clear_bit());3738 dp.CRM.cfg().modify(|_, w| unsafe {39 w.pllrcs()40 .clear_bit()41 .pllmult3_0()42 .bits(0)43 .pllmult5_4()44 .bits(0)45 });4647 // finally we can now configure our clocks48 let crm = dp.CRM.constrain();4950 let clocks = crm51 .cfgr52 .use_hext(8.MHz())53 .sclk(96.MHz()) // can seems to fall over if this is clocked any higher54 .pclk1(48.MHz())55 .pclk2(48.MHz())56 .freeze();1 let dp = unsafe { hal::pac::Peripherals::steal() };2 let mut cp = cortex_m::peripheral::Peripherals::take().unwrap();34 // Bootloader jumps to us with some clocks enabled, so the first thing5 // we do is tear everything down.67 // There might be a better way, and some of these are probably not necessary.8 dp.CRM.ctrl().reset();9 dp.CRM.cfg().reset();10 dp.CRM.clkint().reset();11 dp.CRM.pll().reset();12 dp.CRM.misc1().modify(|_, w| unsafe {13 w.clkoutdiv()14 .bits(0)15 .hickdiv()16 .bit(false)17 .clkout_sel3()18 .bit(false)19 });2021 dp.CRM.apb2en().modify(|_, w| {22 w.iomux()23 .bit(false)24 .gpioa()25 .bit(false)26 .gpiof()27 .bit(false)28 .spi1()29 .bit(false)30 });31 dp.CRM.apb1en().modify(|_, w| w.can1().bit(false));32 dp.CRM.ahben().modify(|_, w| w.dma1().bit(false));3334 dp.CRM35 .ctrl()36 .modify(|_, w| w.pllen().clear_bit().hexten().clear_bit());3738 dp.CRM.cfg().modify(|_, w| unsafe {39 w.pllrcs()40 .clear_bit()41 .pllmult3_0()42 .bits(0)43 .pllmult5_4()44 .bits(0)45 });4647 // finally we can now configure our clocks48 let crm = dp.CRM.constrain();4950 let clocks = crm51 .cfgr52 .use_hext(8.MHz())53 .sclk(96.MHz()) // can seems to fall over if this is clocked any higher54 .pclk1(48.MHz())55 .pclk2(48.MHz())56 .freeze();The firmware at this point was working perfectly on the bench, now I just needed to get it into the installed display unit using the firmware update process. So it was time to finally reverse engineer that.
A firmware update starts in a CAN message handler for ID 0x384. If the message is then the firmware resets, and if the message is then the scooter erases the flash regions used to store the VIN and scooter configuration: A factory reset.

The device performing the firmware update then continues to send messages until the bootloader starts up, sees an update initiation message, and replies with . The updater device then sends 64 byte chunks spread over 9 CAN 0x384 frames, where each frame has the following structure:
- Frame 0
- Frame 1..9
- Frame 9
The CRC is CRC-16-CCITT over the data. The data of each chunk is padded with zeros to make 64 bytes before calculating the CRC. sequence is an unsigned byte, starting at 0 and incrementing for each chunk transmitted, after 0xFF it wraps to 0.
The first chunk is not the first 64 bytes of the firmware, but instead the update file name (for example: AT_R2_JHZY_GT1_GE_FM_HW02_4.0.2) as a null terminated string, followed by the firmware length as a base-10 encoded, null terminated string. The bootloader replies to the first chunk four times with , and all subsequent chunks with one .
After the first chunk is sent, the updater device then sends the firmware image a chunk at a time. The scooter replies with one message after the last CAN message of a frame is sent and the CRC is validated. After the firmware has been transmitted, the updater sends , which triggers a reboot of the display unit.
To actually do the firmware update, I extended the CAN dumping firmware that I wrote earlier into this, which simply flashes a firmware image embedded inside.
