Building a Custom Drone Controller from Scratch — Part 4: Accelerometer Tilt Control

If you’ve seen Pacific Rim, you know the core idea behind a Jaeger: a pilot doesn’t push buttons to make the giant robot punch — they punch, and the robot punches with them. The machine reads the pilot’s body and mirrors it. That’s the exact idea behind the first control mode I built for the Maritaca Force 1 and Dr.One drones: you tilt the controller, the drone tilts with you.

No joystick, no buttons for movement — just an M5Stack AtomS3 in your hand, leaning the way you want the drone to fly. In this article I’ll walk through how that actually works under the hood: turning raw motion-sensor numbers into smooth flight commands, the gesture I use for throttle (which is not tilt-based, for a good reason), and a yaw-control redesign that turned out to accidentally fix a completely unrelated bug.

The big idea: tilt is data, not magic

Inside the AtomS3 sits a small chip that measures two different things: acceleration (how the board is oriented relative to gravity) and rotation rate (how fast it’s spinning). Tilt the board to the left, and gravity “pulls” more on one axis than another — from that pull, we can calculate an angle. That angle is the only ingredient we need for roll and pitch.

float pitchDeg = atan2f(imu.ax, sqrtf(imu.ay * imu.ay + imu.az * imu.az)) * (180.0f / M_PI);
float rollDeg  = atan2f(imu.ay, sqrtf(imu.ax * imu.ax + imu.az * imu.az)) * (180.0f / M_PI);

Don’t worry about the trigonometry — the short version is: atan2f converts the raw gravity readings on each axis into an angle in degrees. Tilt the board 15° to the right, and rollDeg comes out as roughly 15. That’s it. From here on, it’s all about turning that number into something a drone can actually use safely.

From a raw angle to a flight command

If we sent that raw angle straight to the drone, two things would go wrong immediately. First, your hand is never perfectly still — even resting flat, there’s a tiny natural tremor that the sensor happily reports as “tilt.” Second, a twitchy, instant response to every micro-movement would make the drone feel nervous and hard to fly precisely.

Two small ideas fix both problems:

  • Dead zone — ignore any tilt smaller than about 10°. Below that threshold, we treat the board as “flat,” even if the sensor reports a tiny non-zero number. This is what keeps the drone from drifting on its own just because your hand isn’t a tripod.
  • Expo curve — once you’re past the dead zone, small additional tilts produce small movements, and big tilts produce big movements, but the relationship isn’t a straight line — it curves, so fine control near the center is easier and the extremes are still reachable.
uint8_t AccelController::mapAxis(float value, float maxRange, float deadZone, float expo) {
    if (fabsf(value) < deadZone) return 0x80; // 0x80 = neutral, dead-center

    float sign   = value > 0.0f ? 1.0f : -1.0f;
    float scaled = (fabsf(value) - deadZone) / (maxRange - deadZone);
    if (scaled > 1.0f) scaled = 1.0f;

    scaled = scaled * ((1.0f - expo) + expo * scaled); // the "curve"
    return (uint8_t)((0.5f + sign * scaled * 0.5f) * 254.0f);
}

One more ingredient, and this is the one that actually makes it feel like piloting rather than flicking a switch: a slew rate limiter. Even if you snap the board from flat to a hard tilt instantly, the output value isn’t allowed to jump instantly — it ramps there over a fraction of a second. It’s the same principle as a car’s steering having weight to it instead of feeling like an on/off switch. Tiny detail, huge difference in how trustworthy the drone feels in your hand.

Throttle: the one gesture that isn’t tilt

Here’s a detail that surprised me during testing: both drones run what’s called altitude-hold firmware. Left alone, they actively fight to stay at whatever height they’re already at — like a Jaeger’s auto-balance systems quietly correcting your footing so you don’t topple over. That’s great for a beginner-friendly drone, but it means throttle can’t just be “tilt forward = climb” the way roll and pitch are tilt-based. Instead, throttle uses the only physical button on the AtomS3:

  • Press and hold the screen button → climb
  • Click once, then press and hold → descend
  • Let go → the drone snaps back to hovering at whatever height it just reached

That last part — snapping back to neutral the instant you release — took a real bug to discover. My first version kept whatever throttle value you’d built up even after you let go, which sounds harmless until you realize it means the drone keeps climbing (or sinking) forever after you’ve stopped touching anything. On altitude-hold firmware, “neutral” isn’t zero — it’s “stay right here,” so the fix was just snapping back to that neutral value the moment your thumb leaves the button.

The yaw redesign: when “more realistic” makes things worse

This is the part of the story I actually want to tell, because it’s a good example of a control scheme that looked elegant on paper and was miserable in practice.

My first idea for yaw (spinning the drone left/right in place) was to use the gyroscope’s twist-rate — physically rotate the flat board like a steering wheel lying on a table, and the drone spins to match. It’s the most “drift-compatible-pilot” idea of the whole project: your hand’s rotation becomes the robot’s rotation, no abstraction in between.

It was also nearly unflyable. Twisting a small board flat on your palm, with enough precision to stop exactly where you want, turns out to be a genuinely hard physical motion — much harder than tilting it, which your wrist already does naturally. Worse, any tiny ambient drift in the gyroscope reading (sensors are never perfectly zeroed) had a path all the way to the drone’s yaw command, which I suspect caused an unrelated mystery: the drone would slowly spin on the ground before even taking off, for no command I could find in the logs.

The fix ended up being almost embarrassingly simple. Instead of inventing a new gesture, I reused the existing one: a single click of the screen button toggles a “yaw mode,” and while it’s on, the same left/right tilt that normally controls roll gets rerouted to control yaw instead:

out.roll  = yawModeActive ? DroneAxis::NEUTRAL    : (uint8_t)_currentRoll;
out.yaw   = yawModeActive ? (uint8_t)_currentRoll : DroneAxis::NEUTRAL;

Click once, tilt left/right to spin in place; click again, and the same tilt goes back to strafing. No new motion to learn, and the gyroscope is no longer involved in flight at all — which meant that mystery ground-spin bug disappeared the moment this shipped, without me touching it directly. Sometimes the best fix for a bug is removing the entire mechanism it was hiding in.

What’s next

Tilt control gets you flying with nothing but your hand and a tiny screen, but it has a ceiling — there’s only so much precision your wrist can offer, and only one physical button to work with. In the next article, I’ll cover the second control mode I built: a full Bluetooth gamepad host running directly on the ESP32, including a very unexpected discovery about how a $15 controller pretends to be a touchscreen.

Enjoy

[]’s
PopolonY2k

Building a Custom Drone Controller from Scratch — Part 3: Firmware Architecture (HAL Pattern, Non-Blocking Loop, and the Flight State Machine)

In Part 1 we talked about why this project exists and what hardware it runs on, and in Part 2 we reverse-engineered the UDP protocol that lets an M5Stack AtomS3 talk to a cheap WiFi toy drone. Before going further into flight control and gamepad support, it’s worth stopping to look at how the firmware itself is put together — because the same skeleton ends up driving two completely different drones (Maritaca Force 1, the black E58-protocol drone, and Dr.One, the grey FLOW-WIFI drone) and two completely different input methods (accelerometer tilt and a Bluetooth gamepad) without duplicating the flight logic.

The full source is on GitHub: github.com/popolony2k/maritaca-e88-controller.

A quick tour of the project structure

The firmware is built with PlatformIO, using plain .cpp/.h files (no .ino) so everything can be unit-structured and reasoned about like normal C++. The layout looks like this:

src/
├── main.cpp                  # wiring + non-blocking main loop
├── hal/
│   ├── hal.h                  # BoardHal, DisplayHal, ImuHal, ButtonHal
│   ├── m5atoms3.h
│   └── m5atoms3.cpp           # only file that includes M5Unified.h
├── imu/
│   └── accelerometer.h/.cpp   # ImuData struct, filtering
├── comm/
│   ├── drone_protocol_base.h  # abstract DroneProtocolBase interface
│   ├── drone_protocol.h/.cpp  # WIFI_8K_ black drone (E58 8-byte)
│   ├── flow_wifi_protocol.h/.cpp # FLOW-WIFI grey drone (88-byte)
│   └── wifi_manager.h/.cpp    # WiFi station + auto-detect scan
├── bt/
│   ├── gamepad_axes.h         # normalized axes, no BLE deps
│   └── ble_gamepad.h/.cpp     # BLE HID host (iPega, 8BitDo)
├── control/
│   ├── accel_controller.h/.cpp   # tilt → roll/pitch/yaw/throttle
│   ├── gamepad_controller.h/.cpp # gamepad axes → DroneState
│   ├── flight_controller.h/.cpp  # the state machine
│   └── operation_mode.h
└── ui/
    └── display.h/.cpp         # pure renderer

Three ideas repeat throughout this tree, and they’re the subject of the rest of this article:

  1. A hardware abstraction layer (HAL) that keeps the M5Stack SDK out of almost every file.
  2. A non-blocking main loop built entirely on millis() — no delay(), anywhere.
  3. A flight state machine that’s shared by both drones and both input modes.

The HAL: one file gets to know about M5Unified

M5Unified is a great library — it gives you the display, the IMU, the button and the power management of the AtomS3 behind one consistent API. The problem is that once #include <M5Unified.h> shows up in a file, that file is now tied to this specific board. For a project that already juggles two drone protocols and two control schemes, we didn’t want a third axis of “which file is allowed to touch the hardware.”

So the rule is simple: only src/hal/m5atoms3.cpp includes M5Unified.h. Everything else — the flight controller, the display renderer, the accelerometer filter — talks to a set of plain interfaces defined in hal.h:

struct BoardHal {
    void (*begin)           ();
    void (*update)          ();
    int  (*getBatteryLevel) ();   // 0/25/50/75/100 in %
    bool (*isCharging)      ();   // always false on this hardware
};

struct ButtonHal {
    bool (*wasPressed)  ();
    bool (*wasReleased) ();
    bool (*pressedFor)  (uint32_t ms);
};

These are structs of function pointers, not abstract classes with virtual methods. The implementation, in m5atoms3.cpp, fills them in with non-capturing lambdas:

const BoardHal kBoard {
    .begin           = [] { auto cfg = M5.config(); M5.begin(cfg); },
    .update          = [] { M5.update(); },
    .getBatteryLevel = [] { return batteryLevel(); },
    .isCharging      = [] { return false; },
};

const ButtonHal kButton {
    .wasPressed  = []()            -> bool { return M5.BtnA.wasPressed(); },
    .wasReleased = []()            -> bool { return M5.BtnA.wasReleased(); },
    .pressedFor  = [](uint32_t ms) -> bool { return (bool)M5.BtnA.pressedFor(ms); },
};

A non-capturing lambda — one that doesn’t reference any outside variables — has no state of its own, so it decays to a plain function pointer at compile time. There’s no closure object, no heap allocation, no vtable lookup. kBoard.update() compiles down to exactly the same code as calling M5.update() directly, but every other file in the project now depends on hal.h (a tiny, dependency-free header) instead of the entire M5Unified SDK.

This pays off in a very concrete way: the battery-level logic — reading GPIO8/ADC2 through a voltage divider, averaging samples, applying hysteresis around the 25/50/75% boundaries — lives entirely inside m5atoms3.cpp as a couple of small static functions. The flight controller and the display just call kBoard.getBatteryLevel() and get back a number from 0–100. When the battery calibration changed (we covered that story in an earlier debugging session), not a single line outside m5atoms3.cpp needed to change.

A loop with no delay()

The AtomS3 in this project is doing a lot at once: polling a button, running an IMU filter, talking to a BLE gamepad, maintaining a WiFi connection, sending UDP control packets at a fixed rate, and redrawing a small LCD — all from a single loop(). If any one of those used delay(), everything else would stall for that duration. A drone waiting for its next 40 ms control packet doesn’t care that the display “only” wanted to sleep for 10 ms.

So the project has a hard rule: no delay() in loop(), ever. Every periodic task instead remembers the last time it ran and checks millis() on every pass:

static uint32_t _lastDisplayMs = 0;
static constexpr uint32_t DISPLAY_INTERVAL_MS = 100; // 10 Hz

void loop() {
    kBoard.update();
    wifi.update();
    imu.update(kImu);

    // ... gamepad + flight controller update, every iteration ...

    uint32_t now = millis();
    if (now - _lastDisplayMs >= DISPLAY_INTERVAL_MS) {
        _lastDisplayMs = now;
        display.update(/* ... */);
    }
}

The same pattern shows up at every layer, each with its own cadence chosen for a reason:

  • Drone control packets go out at ~25 Hz (every ~40 ms) — fast enough for responsive flight, matching what the original Android app does.
  • The keepalive packet (AA 80 80 00 80 00 80 55) fires every ~790 ms when the sticks are idle, so the drone doesn’t think the link has died.
  • The display redraws at 10 Hz — plenty for human eyes, and it keeps SPI traffic from competing with the more time-critical UDP and BLE work.
  • BLE scanning uses 5-second timed windows, restarted every 6 seconds while no gamepad is connected.

Because every one of these is just “a timestamp and a constant,” loop() can run thousands of times a second, doing almost nothing on most iterations and exactly the right thing the moment any of those windows elapses. Nothing ever blocks anything else.

One subtlety we ran into directly because of this design: WiFiUDP::begin() needs WiFi.mode() to have been called at least once to initialize the ESP32’s network stack — even on builds where WiFi is never actually connected. Skip it, and the drone protocol driver crashes during setup(), producing a silent boot loop. It’s a good reminder that “non-blocking” and “stateless” aren’t the same thing — some subsystems still have a required initialization order, even if nothing about them looks synchronous.

The flight state machine

All of the above exists to support the actual brain of the firmware: FlightController. It’s a small state machine with six states:

enum class FlightState {
    Idle,        // disarmed, sending keepalive packets
    Calibrating, // sending CaliGyro for 1.5 s before arming
    Arming,      // Unlock, then TakeOff
    Flying,      // active flight, 25 Hz control packets
    Landing,     // sending Land for 2 s, then back to Idle
    Emergency,   // EmergStop, then immediately back to Idle
};

The normal lifecycle is exactly what you’d expect:

Idle ──(double-click + WiFi ok)──▶ Calibrating ──▶ Arming ──▶ Flying
Flying ──(double-click)──▶ Landing ──▶ Idle
any state ──(triple-click)──▶ Emergency ──▶ Idle

Every transition goes through one function, enterState(), which resets all the per-state bookkeeping (button click counters, one-shot command timers, the “is this the first frame in this state” flag) and logs the transition to Serial. runState() then does whatever that state needs to do every frame: send the right command bytes, check elapsed time, and decide whether it’s time to move on.

Gestures, not menus

Remember — the only physical input in ACCEL mode is a single screen button (BtnA). Every gesture has to be encoded in click count and timing:

  • Single click in Flying toggles yaw on/off.
  • Double click in Idle arms and takes off (if WiFi is connected); double click in Flying starts landing.
  • Triple click, from any state, is an immediate emergency stop.
  • Press-and-hold (ACCEL mode, while Flying) drives the throttle — first hold = climb, click-then-hold = descend.

All of this is decided by a small amount of state — _clickCount, _lastReleaseMs, _buttonDown — updated once per frame in handleButton(). A click only “counts” once the double-click window (DOUBLE_CLICK_MS = 1000) has elapsed without a follow-up press, which is what lets a double-click and a hold-after-click (the throttle-down gesture) coexist on the same physical button.

In BLUETOOTH mode, the same state machine is driven by handleGamepadButtons() instead — rising edges on the gamepad’s button bitmask map directly to the same transitions (A = arm, B = land, X = emergency, etc.), so runState() itself doesn’t need to know or care which input method is active.

One state machine, two drones

This is the part that ties back to Part 2. Maritaca Force 1 (the WIFI_8K_ / E58-protocol drone) needs the full Calibrating → Arming(Unlock → TakeOff) → Flying sequence. Dr.One (the FLOW-WIFI drone) auto-arms the moment it receives a single TakeOff toggle — sending it through a multi-second calibration dance would just be wrong for that hardware.

Rather than branch the state machine on “which drone is this,” the abstract DroneProtocolBase interface exposes a single capability flag:

// FlightController::handleDoubleClick()
enterState(_deps.drone.supportsArmSequence()
           ? FlightState::Calibrating   // Maritaca Force 1
           : FlightState::Flying);      // Dr.One — auto-arms on TakeOff

and enterState() fires the one-shot TakeOff command itself when that flag is false:

if (s == FlightState::Flying) {
    _accel.begin();
    _gamepad.begin();
    if (!_deps.drone.supportsArmSequence()) {
        _oneShotCmd   = DroneCmd::TakeOff;
        _oneShotUntil = millis() + 1000;
    }
}

Everything else — gesture handling, throttle hold, the altitude-hold throttle logic, the landing and emergency paths — is shared. Swapping drones at boot is just a matter of which concrete DroneProtocolBase implementation gets injected into FlightController‘s constructor; WifiManager::scanForFirst() figures out which SSID is visible and the rest follows automatically.

Safety net: WiFi loss means emergency stop

Every call to runState() starts with one check, before the big switch statement:

if (!wifiOk && _state != FlightState::Idle && _state != FlightState::Emergency) {
    Serial.println("[Flight] WiFi lost — emergency stop");
    enterState(FlightState::Emergency);
    return;
}

If the AtomS3 ever drops its connection to the drone’s access point while armed, calibrating, flying, or landing, the very next frame forces an emergency stop — regardless of what gesture or gamepad input is happening. The same idea applies to losing the Bluetooth gamepad mid-flight in BLUETOOTH mode. Given that this whole project is one step away from “small spinning blades a meter from your face,” this single guard clause is arguably the most important seven lines in the firmware.

What’s next

With the skeleton in place — HAL, non-blocking loop, and a drone-agnostic state machine — the next two articles get to the fun part: turning physical input into flight commands. Part 4 covers the accelerometer/tilt control path (and the altitude-hold throttle gesture that took some real debugging to get right), and Part 5 covers building a BLE HID host from scratch to support a Bluetooth gamepad.

As always, the full source is on GitHub: github.com/popolony2k/maritaca-e88-controller. You can also see Maritaca Force 1 in action on the project’s YouTube playlist.

Enjoy
[]’s
PopolonY2k

C vs Rust

Após algum tempo sem posts aqui no blog, deixo esse post sobre um novo projeto que deixei público no GitHub, onde demonstro como integrar projetos escritos em C com bibliotecas escritas em Rust (o inverso também é possível), utilizando CMake e todo ferramental disponível no ecossistema do Rust.

O projeto em questão é o weather-bot que já está disponível no meu perfil no GitHub, prontinho para uso nas plataformas Linux e MacOSX, até a data desse artigo.

Conforme explico no vídeo sobre o weather-bot em meu canal no Youtube, o projeto ainda não foi testado no Windows, portanto possíveis ajustes talvez sejam necessários para compatibilizar o mesmo nessa plataforma e caso seja necessário alguma compatibilização, a mesma deva afetar apenas na camada do CMake, uma vez que o projeto foi desenvolvido com o máximo de código multi-plataforma em mente, tanto na parte C quanto na parte Rust.

Abaixo segue o vídeo sobre a “saga” do desenvolvimento desse projeto.

[]’s
PopolonY2k

Do It Yourself (ou não)

Há algum tempo tenho feito alguns “experimentos” no meu canal do YouTube, afim de aprimorar também essa plataforma para que se torne um complemento desse blog.

Algumas lives sobre programação e principalmente, alguns mini projetos relacionados ao MSX e computação em geral, tem sido feitos nesse interim, como a live onde demonstrei o código da engine 3D portado a partir do vídeo e código apresentados em “Code it yourself – First person shooter” do excelente canal do Javidx9.

Pop!Live Stream Code It yourself-First person shooter on MSX-TR (Quick and simple Turbo Pascal 3.3F)
Pop! Live Stream – Code It yourself – First person shooter (Quick and simple Free Pascal)

Hoje finalmente liberei mais um formato, que acredito ainda precisar de aprimoramentos de maneira geral.

É o quadro “Faça você mesmo (ou não)”, para isso já até criei a playlist só para facilitar a busca nos vídeos dessa série em meu canal.

Entretanto podemos dizer que esse formato ainda é WIP (Work In Progress) e que estaremos ajustando nos próximos vídeos.

Toda sugestão é bem vinda.

MSX 1.1 Expert XP800 Capacitor Recap e CPU upgrade (Z80 6Mhz)

[]’s
PopolonY2k

Hungarian rapsody

A computação é uma ciência “recente”, que se move quase que na velocidade da luz em comparação com as demais ciências.

Em pouco tempo diversas mudanças ocorrem e tecnologias dominantes são praticamente varridas quase que para baixo do tapete em “segundos”.

É uma área em constante mudança que acontecem desde a concepção da tecnologia em seus mínimos detalhes, mesmo os invisíveis a olho nú, até aqueles estruturais e que abalam o ecossistema todo, ecoando através dos tempos deixando sua marca nos mais diversos meios de comunicação.

Nesse cenário a indústria de desenvolvimento de software moderna tem aprendido muito através do desenvolvimento de novas ferramentas que auxiliam a construção de tecnologias e produtos, entretanto nem sempre existiram IDE’s poderosas capazes de integrar com debuggers e analisadores estáticos, dentre outros ferramentais disponíveis na atualidade.

Borland Turbo C 2.0

No inicio dos anos 90, IDE’s como Turbo Pascal 5.0 e Turbo C 2.0, da Borland, começavam a revolucionar a forma como softwares eram desenvolvidos, com seus debuggers integrados, excelentes editores de código com seus syntax highlighting.

Entretanto nessa época os desenvolvedores, em sua grande maioria, não terceirizavam sua confiança e competência nas IDE’s e na maioria das vezes utilizavam ou desenvolviam técnicas para auxiliar a organização, compreensão e depuração do código.

Charles Simonyi

Nessa mesma época, Charles Simonyi, um jovem engenheiro de software nascido na Hungria e que havia sido recrutado por Bill Gates para trabalhar no desenvolvimento da suite Microsoft Office na Microsoft, começou a popularizar uma técnica que já havia sido criada por ele em seu antigo emprego na Xerox PARC.

Essa técnica consiste em uma notação de código, independente de plataforma e linguagem, que tenta padronizar prefixo de nomes de variáveis de acordo com seus tipos e comportamento, bem como estilo de nomes de funções (se estendendo a classes) de forma a deixar o desenvolvimento uniforme e com um padrão conhecido e documentado, auxiliando a compreensão principalmente em grandes projetos e por pessoas diferentes.

Esse padrão é conhecido como notação húngara (hungarian notation) e foi popularizada pela Microsoft em suas literaturas oficiais, principalmente nos anos 90, sendo ainda um padrão forte dentro da empresa até os dias atuais.

Programming Windows 3.1

Eu mesmo conheci essa notação em 1993, quando adquiri a terceira edição de Programming Windows 3.1, de Charles Petzold – Microsoft Press, esse também um dos melhores escritores de livros técnicos que eu já li (menção honrosa também a Peter Norton), me apresentando assim essa notação.
Me rendendo ao entusiasmo de Petzold, decidi tentar escrever todo software que eu pudesse, utilizando notação hungara para saber se traria algum benefício, não só por isso mas também porque meus softwares começavam a se tornar cada vez maiores e complexos, portanto eu deveria tornar as coisas mais compreensíveis para o EU do futuro.

Apesar de ser alguém, corportivamente falando, com raízes fortíssimas no UNIX, tendo iniciado no XENIX, passado por SCO UNIX, IBM-AIX e Sun Solaris, confesso que era bastante influenciado pelo estilo Microsoft, antes dela se tornar esse elefante branco que é hoje, pois ela era a Google da época e portanto uma forte influenciadora dentre os jovens programadores, algo em proporções muito maiores do que ela mesmo representa nos dias atuais junto aos programadores mais jovens.
Pessoalmente, a Microsoft foi um dos pais do padrão MSX, portanto era “alguém” por quem eu tinha um certo carinho até então e por isso usar uma notação difundida pela Microsoft e não pela galera do UNIX, a qual eu estava mais corporativamente inserido, não foi nada estranho.

Sobre a notação húngara, abaixo vou deixar alguns exemplos retirados desse documento, que é bem simples e direto e que faço questão de deixar aqui em meu site, mas também fazer referência ao link original onde ele originalmente está hospedado aqui e que me pareceu ser uma plataforma de ensino inglesa bem interessante.

[]’s
PopolonY2k