Deep dive into the CockpitOS panel system. This document covers the panel architecture, registration, lifecycle, DCS-BIOS subscriptions, hardware abstraction APIs, and reference implementations.
Who needs this? Most users never need custom panels. The Generic panel handles all
InputMapping.handLEDMapping.hentries automatically. Custom panels are for specialized hardware that requires state-machine logic, custom display rendering, or non-standard DCS-BIOS interaction patterns.
Every panel in CockpitOS is a set of function pointers registered at compile time. The registry calls them at the right time during the firmware lifecycle.
// PanelRegistry.h
struct PanelHooks {
const char* label; // debug string (e.g. "hasWingFold")
PanelKind kind; // enum identity
uint8_t prio; // lower runs earlier; 100 is default
PanelFn init; // called when DCS mission starts
PanelFn loop; // called every poll cycle
PanelFn disp_init; // display hardware init (mission start)
PanelFn disp_loop; // display refresh (every cycle)
PanelFn tick; // optional per-frame work
};REGISTER_PANEL(KIND, INIT, LOOP, DINIT, DLOOP, TICK, PRIO)| Parameter | Description |
|---|---|
KIND |
Must match a value in the PanelKind enum. The macro includes a static_assert that fails at compile time if the kind does not exist. |
INIT |
Function called when a DCS mission starts. Use for hardware setup, state reset, DCS-BIOS subscriptions, and initial state sync. |
LOOP |
Function called every poll cycle (~250 Hz by default). Use for input scanning and state-machine updates. |
DINIT |
Display initialization function. Called after INIT. Use for HT1622 chip init, TFT setup, lamp tests. |
DLOOP |
Display refresh function. Called every cycle. Use for shadow RAM commit, TFT redraw. |
TICK |
Optional per-frame callback. |
PRIO |
Priority value. Lower numbers execute earlier. Default is 100. |
Pass nullptr for any hook you do not need. For example, the WingFold panel has no display:
REGISTER_PANEL(WingFold, WingFold_init, WingFold_loop, nullptr, nullptr, nullptr, 100);The IFEI panel separates input logic from display logic:
REGISTER_PANEL(IFEI, IFEI_init, nullptr, IFEIDisplay_init, IFEIDisplay_loop, nullptr, 100);The REGISTER_PANEL macro expands to a static _AutoPanelRegister object. Its constructor calls PanelRegistry_register() before main() begins. The registry stores up to PANELREGISTRY_MAX_PANELS (32) entries sorted by priority. Duplicate kinds are merged (missing hooks are filled in from later registrations).
// How it works internally
#define REGISTER_PANEL(KIND, INIT, LOOP, DINIT, DLOOP, TICK, PRIO) \
ASSERT_PANEL_KIND_EXISTS(KIND); \
static const PanelHooks _hooks_##KIND = { "has" #KIND, PanelKind::KIND, \
(uint8_t)(PRIO), (INIT), (LOOP), (DINIT), (DLOOP), (TICK) }; \
static _AutoPanelRegister _apr_##KIND(_hooks_##KIND)PanelKind.h is auto-generated by generate_panelkind.py from the .cpp files in src/Panels/. Each panel file gets a unique PanelKind enum value.
// src/Generated/PanelKind.h (DO NOT EDIT MANUALLY)
enum class PanelKind : uint8_t {
// Auto-detected hardware (permanent, no REGISTER_PANEL)
AnalogGauge, // Auto-detected when DEVICE_GAUGE in LEDMapping
// Compiled panels (auto-generated from src/Panels/*.cpp)
Generic,
IFEI,
TFTBatt,
TFTBrake,
TFTCabPress,
TFTCmws,
TFTHyd,
TFTRadarAlt,
WingFold,
// ... more entries ...
COUNT // Must always be last
};By default, the enum name is derived from the filename. To override it, add this comment anywhere in your .cpp file:
// PANEL_KIND: MyCustomNameThe build script generate_panelkind.py scans for this pattern. If absent, it uses the filename minus .cpp.
The registry tracks two 32-bit bitmasks:
g_presentMask-- set when a panel is registered (compiled in).g_activeMask-- set when a panel is runtime-enabled (default: active).
bool PanelRegistry_has(PanelKind k); // is compiled in?
bool PanelRegistry_isActive(PanelKind k); // is runtime-enabled?
void PanelRegistry_setActive(PanelKind k, bool active); // enable/disable at runtimeThe FOR_ACTIVE macro in PanelRegistry.cpp ensures only active panels get their hooks called.
Generic.cpp is the workhorse panel that handles all entries in InputMapping.h and LEDMapping.h automatically. It supports:
Inputs:
- GPIO direct (buttons, selectors, encoders)
- PCA9555 I2C expanders (when
ENABLE_PCA9555=1) - HC165 shift registers (when
HC165_BITS > 0) - MATRIX rotary encoders (strobe/data pattern)
- TM1637 key scanning
- Analog axes
Outputs (via LEDMapping.h):
- GPIO LEDs
- PCA9555 outputs
- WS2812 addressable LEDs
- TM1637 segment displays
- GN1640T LED matrices
- Servo gauges (DEVICE_GAUGE)
The Generic panel's init performs a full state sync -- it reads every input and fires the corresponding DCS-BIOS command or HID button press. This ensures the cockpit matches the physical switch positions when a mission starts.
void Generic_init() {
// ... hardware setup (run-once) ...
CoverGate_init();
pollGPIOEncoders();
pollGPIOSelectors(true); // true = forceSend
pollGPIOMomentaries(true);
// HC165, PCA9555, Matrix, TM1637, Analog ...
}
void Generic_loop() {
static unsigned long lastPoll = 0;
if (!shouldPollMs(lastPoll)) return; // 250 Hz throttle
// Poll all input sources...
CoverGate_loop();
}When to use the Generic panel vs. a custom panel:
| Scenario | Use Generic | Use Custom |
|---|---|---|
| Standard toggle/momentary switches | Yes | |
| Rotary selectors | Yes | |
| Analog axes | Yes | |
| LED indicators | Yes | |
| Multi-step state machines (wing fold) | Yes | |
| Custom display rendering (IFEI, CMWS) | Yes | |
| TFT gauge with sprite composition | Yes | |
| Hardware requiring special init sequence | Yes |
Power On
|
v
[REGISTER_PANEL constructors run -- panels registered in priority order]
|
v
[setup() -- HIDManager, DCSBIOSBridge, LEDs, etc.]
|-- PanelRegistry_forEachDisplayInit() --> all panel disp_init() hooks (runs ONCE at boot)
|
v
[Main loop starts -- waiting for DCS mission]
|
v
[Aircraft name received from DCS-BIOS stream]
|
+-- MISSION_START_DEBOUNCE timer (Config.h, default 500ms)
|
v
[initializePanels() called]
|-- PanelRegistry_forEachInit() --> all panel init() hooks
|
v
[panelLoop() called every main loop iteration]
|-- PanelRegistry_forEachLoop() --> all panel loop() hooks
|-- PanelRegistry_forEachDisplayLoop() --> all panel disp_loop() hooks
|-- PanelRegistry_forEachTick() --> all panel tick() hooks
|
v
[Aircraft name becomes blank -- mission ends]
|-- Calibration saved to NVS
|-- Mission state reset
|-- Panels NOT destroyed (static allocations persist)
Key points:
init()is called when a DCS mission starts, not at power-on.init()runs every time you enter a new mission (or re-enter the same one).loop()runs atPOLLING_RATE_HZ(250 Hz default) -- theshouldPollMs()helper enforces this.- When the mission ends, panels stop being called but their static state remains in memory.
Custom panels can subscribe to specific DCS-BIOS data changes. There are four subscription types:
// Subscribe to a numeric output (LED brightness, gauge position, etc.)
subscribeToLedChange("VOLT_U", onBatVoltUChange);
// Callback signature:
void onBatVoltUChange(const char* label, uint16_t value, uint16_t max_value) {
// value is the raw DCS-BIOS output value
// max_value is the declared maximum (e.g. 65535 for 16-bit gauges)
}subscribeToSelectorChange("COCKKPIT_LIGHT_MODE_SW", onBackLightChange);
void onBackLightChange(const char* label, uint16_t value) {
// value is the selector position (0, 1, 2, ...)
}subscribeToDisplayChange("IFEI_FUEL_UP", onFuelUpChange);
void onFuelUpChange(const char* label, const char* value) {
// value is the string content from DCS-BIOS
}subscribeToMetadataChange("EXT_NOZZLE_POS_L", updateLEFTNozzle);
void updateLEFTNozzle(const char* label, uint16_t value) {
// Raw 16-bit value from DCS-BIOS export stream
}| Type | Max Count | Defined In |
|---|---|---|
| LED/Gauge | MAX_LED_SUBSCRIPTIONS |
DCSBIOSBridge.h |
| Selector | MAX_SELECTOR_SUBSCRIPTIONS |
DCSBIOSBridge.h |
| Display | MAX_DISPLAY_SUBSCRIPTIONS |
DCSBIOSBridge.h |
| Metadata | MAX_METADATA_SUBSCRIPTIONS |
DCSBIOSBridge.h |
Subscribe in your init() or disp_init() function -- never in loop().
CockpitOS wraps all hardware access through the CUtils library (lib/CUtils/). Always use these APIs instead of raw Arduino calls.
// Reading and writing GPIO pins
pinMode(pin, INPUT_PULLUP); // Standard Arduino -- used in Generic_init
digitalRead(pin);
digitalWrite(pin, HIGH);
analogRead(pin); // 12-bit ADC (0-4095)bool readPCA9555(uint8_t addr, uint8_t& port0, uint8_t& port1);
// Returns true on success, reads both 8-bit portsHC165_init(PL_pin, CP_pin, QH_pin, bitCount);
uint64_t bits = HC165_read(); // Returns all bits as a 64-bit valueControlled through LEDMapping.h entries via the setLED() function. Brightness and color are managed automatically by LEDControl.cpp.
Handled by the Generic panel through InputMapping.h and LEDMapping.h. For custom rendering, access via the CUtils TM1637 driver.
Used by the IFEI panel and other segment displays:
HT1622 chip(CS_PIN, WR_PIN, DATA_PIN);
chip.init();
chip.commit(ramShadow, lastShadow, 64); // Write changed bytes only
chip.commitPartial(ramShadow, lastShadow, addrStart, addrEnd);The HIDManager handles all output to the host PC, whether in DCS-BIOS mode or HID gamepad mode.
// Set a named button ON or OFF
HIDManager_setNamedButton("MASTER_ARM_SW", deferSend, pressed);
// Toggle a latched button (press-on / press-off)
HIDManager_setToggleNamedButton("FIRE_BTN", deferSend);
// Toggle only on rising edge
HIDManager_toggleIfPressed(isPressed, "FIRE_BTN", deferSend);// Move an analog axis -- includes EMA filtering, self-learning calibration,
// deadzone hysteresis, and threshold gating
HIDManager_moveAxis("THROTTLE_LEFT", gpioPin, AXIS_X, forceSend, deferSend);// Send a DCS-BIOS command directly (label + string value)
sendCommand("MASTER_ARM_SW", "1", false);
// Send with automatic throttle and history tracking
sendDCSBIOSCommand("MASTER_ARM_SW", 1, force);HIDManager_dispatchReport(false); // Send current HID report to host
HIDManager_commitDeferredReport("MyPanel"); // Flush deferred reports
bool shouldPollMs(lastPoll); // Returns true at POLLING_RATE_HZInputControl.cpp is the engine behind the Generic panel's input scanning. It automatically handles all InputMapping.h entries without requiring custom panel code. Understanding what it does automatically helps you know what you do NOT need to implement in a custom panel.
- GPIO buttons and selectors -- polls all GPIO-sourced inputs, detects state changes, sends commands
- PCA9555 inputs -- reads I2C expander ports, debounces, detects changes per-bit
- HC165 shift registers -- clocks data in, maps bits to input labels
- TM1637 key scanning -- reads keys from TM1637 modules
- Matrix rotary encoders -- strobes columns and reads rows
- Analog axes -- reads ADC values with EMA filtering, calibration, deadzone, and hysteresis
- Selector group logic -- ensures only one position per group is active, sends the correct value
- Encoder step counting -- tracks A/B quadrature state, sends
variable_steporfixed_stepcommands
You bypass InputControl when your custom panel reads hardware directly. For example, WingFold reads PCA9555 ports raw because the two-axis interaction cannot be expressed in InputMapping.h. The key rule: if a control appears in InputMapping.h, InputControl handles it. If you need custom logic, either:
- Set the source to
"NONE"in InputMapping.h so InputControl ignores it, then handle it in your custom panel - Remove it from InputMapping.h entirely and handle all aspects in your panel code
- Leave it in InputMapping.h for the command/HID mapping but subscribe to its DCS-BIOS output for custom reactions
PCA9555 I2C expanders are fully data-driven. At startup, CockpitOS:
- Collects addresses from
InputMapping.h(source strings like"PCA_0x22") andLEDMapping.h(DEVICE_PCA9555entries with.pcaInfo.address) - Probes each address on the I2C bus with 3 retries
- Sweeps the full I2C range (0x08-0x77) to detect any unmapped devices
- Reports results in debug output: which devices responded, which were expected but missing, and which were found but not in any mapping
No hardcoded panel tables or manual registration is needed. Just add PCA_0xNN entries to your label set's InputMapping.h or LEDMapping.h and the system handles everything.
Location: src/Panels/WingFold.cpp
The WingFold panel decodes a physical 2-axis (push/pull + fold/hold/spread) mechanism from PCA9555 bits. Key patterns:
- Compile guard:
#if defined(HAS_CUSTOM_RIGHT)-- only compiles when the panel's label set is active. - Wiring resolution: Reads InputMappings at init to find PCA addresses and bit positions.
- Debouncing: Per-byte debounce with configurable
DEB_MS(8ms). - Command queue: Fixed-size ring buffer with
MIN_CMD_SPACING_MS(500ms) pacing to avoid overwhelming DCS. - Mechanical invariants: FOLD + PUSH is illegal and gets corrected automatically.
REGISTER_PANEL(WingFold, WingFold_init, WingFold_loop, nullptr, nullptr, nullptr, 100);Location: src/Panels/IFEIPanel.cpp
The IFEI panel drives dual HT1622 segment LCD controllers with shadow RAM. Key patterns:
- Separate init/loop for inputs and display: Uses both
initanddisp_inithooks. - Shadow RAM architecture: Software buffer mirrors HT1622 display memory. Only changed bytes are written to hardware.
- Commit regions:
buildCommitRegions()pre-computes which RAM ranges correspond to which display fields for efficient partial updates. - Overlay system: SP/CODES fields share physical segments with TEMP fields. Complex priority logic handles overlays.
- FreeRTOS task option:
RUN_IFEI_DISPLAY_AS_TASKcan offload display refresh to a separate task.
Location: src/Panels/TFT_Gauges_Battery.cpp
A round GC9A01 TFT gauge using LovyanGFX. Key patterns:
- Dirty-rect rendering: Only redraws the area where the needle moved.
- DMA double-buffer: Two bounce buffers in internal RAM for non-blocking SPI DMA.
- PSRAM background cache: Full-frame background images stored in PSRAM for fast compositing.
- Day/NVG modes: Automatically switches needle and background assets based on
CONSOLES_DIMMER. - FreeRTOS task: Runs rendering at ~200 Hz on a dedicated core.
For a complete TFT gauge development walkthrough, see How-To/Wire-TFT-Gauges.md and Hardware/TFT-Gauges.md.
Location: src/Panels/TFT_Gauges_CabinPressure.cpp
A 360x360 round gauge using the ST77961 controller. Key differences from the Battery gauge:
- Higher resolution -- 360x360 vs 240x240 requires more PSRAM for backgrounds.
- Multiple subscriptions -- subscribes to both cabin altitude and rate-of-climb.
- Two needles -- renders two independent needle sprites on the same gauge face.
- Larger dirty rects -- higher resolution means more pixels per update; DMA double-buffering is critical.
Location: src/Panels/TFT_Display_CMWS.cpp
The CMWS (Common Missile Warning System) display subscribes to multiple DCS-BIOS fields and renders a text-based tactical display. Key patterns:
- Many subscriptions -- subscribes to numerous metadata and LED fields simultaneously.
- Complex state management -- tracks multiple independent data streams and composes a unified display.
- Non-gauge TFT -- demonstrates that TFT panels are not limited to analog gauge faces.
Location: src/Panels/TestPanel.cpp
The simplest possible custom panel. Use this as a starting template. It demonstrates the bare minimum: a compile guard, REGISTER_PANEL call, an init function with a DCS-BIOS subscription, and a loop function.
The panelLoop() function in Mappings.cpp calls tickOutputDrivers() after all panel loops finish. This automatically flushes all output driver buffers (PCA9555, TM1637, GN1640T, WS2812) regardless of which panel set the LED state. This means:
- You do NOT need a
tickfunction just to flush LED output buffers. - If your panel calls
setLED()or writes to PCA9555 outputs, the data is flushed automatically. - The
tickhook is only needed for panel-specific per-frame work that is not output flushing.
The ESP32 has a watchdog timer. If your loop() function blocks for too long, the watchdog will reset the device. Never use delay() in a loop function. Use millis() comparisons or vTaskDelay() in FreeRTOS tasks instead.
Allocate all buffers, build lookup tables, and subscribe to DCS-BIOS events in init(). The loop() function should be allocation-free to avoid heap fragmentation.
CUtils provides consistent behavior across ESP32 variants (S2, S3, C3, C6, etc.). Raw Arduino calls may behave differently across variants.
Wrap your panel code in #if defined(HAS_YOUR_LABEL_SET) to prevent compilation errors when the panel's label set is not active:
#if defined(HAS_CUSTOM_RIGHT)
// ... panel code ...
#endifSet IS_REPLAY=1 in Config.h to test your panel with recorded DCS-BIOS data without needing DCS World running. The replay system feeds pre-recorded data through the same parser pipeline.
Use the shouldPollMs() helper to throttle your loop to POLLING_RATE_HZ:
void MyPanel_loop() {
static unsigned long lastPoll = 0;
if (!shouldPollMs(lastPoll)) return;
// ... your logic at 250 Hz ...
}Always check isMissionRunning() before accessing DCS-BIOS state in display render functions. The data is undefined between missions.
- How-To/README.md -- Step-by-step guides for common tasks
- Reference/Config.md -- All configuration constants
- Reference/Control-Types.md -- Input control type reference
- Advanced/Display-Pipeline.md -- Display rendering internals
- Advanced/FreeRTOS-Tasks.md -- Task management for TFT gauges