Skip to content

Latest commit

 

History

History
555 lines (394 loc) · 20.1 KB

File metadata and controls

555 lines (394 loc) · 20.1 KB

Custom Panels

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.h and LEDMapping.h entries automatically. Custom panels are for specialized hardware that requires state-machine logic, custom display rendering, or non-standard DCS-BIOS interaction patterns.


1. Panel Architecture

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.

The PanelHooks Structure

// 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 Macro

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);

Registration Internals

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)

2. PanelKind System

Auto-Generated Enum

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
};

Custom PANEL_KIND Override

By default, the enum name is derived from the filename. To override it, add this comment anywhere in your .cpp file:

// PANEL_KIND: MyCustomName

The build script generate_panelkind.py scans for this pattern. If absent, it uses the filename minus .cpp.

Active/Present Bitmasks

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 runtime

The FOR_ACTIVE macro in PanelRegistry.cpp ensures only active panels get their hooks called.


3. The Generic Panel

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

4. Panel Lifecycle

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 at POLLING_RATE_HZ (250 Hz default) -- the shouldPollMs() helper enforces this.
  • When the mission ends, panels stop being called but their static state remains in memory.

5. DCS-BIOS Subscription System

Custom panels can subscribe to specific DCS-BIOS data changes. There are four subscription types:

LED/Gauge Subscriptions (Numeric Values)

// 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)
}

Selector Subscriptions (Switch Positions)

subscribeToSelectorChange("COCKKPIT_LIGHT_MODE_SW", onBackLightChange);

void onBackLightChange(const char* label, uint16_t value) {
    // value is the selector position (0, 1, 2, ...)
}

Display Subscriptions (String Fields)

subscribeToDisplayChange("IFEI_FUEL_UP", onFuelUpChange);

void onFuelUpChange(const char* label, const char* value) {
    // value is the string content from DCS-BIOS
}

Metadata Subscriptions (Raw DCS-BIOS Values)

subscribeToMetadataChange("EXT_NOZZLE_POS_L", updateLEFTNozzle);

void updateLEFTNozzle(const char* label, uint16_t value) {
    // Raw 16-bit value from DCS-BIOS export stream
}

Subscription Limits

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().


6. CUtils Hardware Abstraction API

CockpitOS wraps all hardware access through the CUtils library (lib/CUtils/). Always use these APIs instead of raw Arduino calls.

GPIO

// 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)

PCA9555 I2C Expander

bool readPCA9555(uint8_t addr, uint8_t& port0, uint8_t& port1);
// Returns true on success, reads both 8-bit ports

HC165 Shift Register

HC165_init(PL_pin, CP_pin, QH_pin, bitCount);
uint64_t bits = HC165_read();  // Returns all bits as a 64-bit value

WS2812 LEDs

Controlled through LEDMapping.h entries via the setLED() function. Brightness and color are managed automatically by LEDControl.cpp.

TM1637 Displays

Handled by the Generic panel through InputMapping.h and LEDMapping.h. For custom rendering, access via the CUtils TM1637 driver.

HT1622 Segment LCD

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);

7. HIDManager API

The HIDManager handles all output to the host PC, whether in DCS-BIOS mode or HID gamepad mode.

Button Functions

// 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);

Axis Function

// Move an analog axis -- includes EMA filtering, self-learning calibration,
// deadzone hysteresis, and threshold gating
HIDManager_moveAxis("THROTTLE_LEFT", gpioPin, AXIS_X, forceSend, deferSend);

DCS-BIOS Commands

// 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);

Report Management

HIDManager_dispatchReport(false);    // Send current HID report to host
HIDManager_commitDeferredReport("MyPanel");  // Flush deferred reports

bool shouldPollMs(lastPoll);         // Returns true at POLLING_RATE_HZ

8. InputControl -- Automatic Input Handling

InputControl.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.

What InputControl Handles Automatically

  • 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_step or fixed_step commands

When to Bypass InputControl

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:

  1. Set the source to "NONE" in InputMapping.h so InputControl ignores it, then handle it in your custom panel
  2. Remove it from InputMapping.h entirely and handle all aspects in your panel code
  3. Leave it in InputMapping.h for the command/HID mapping but subscribe to its DCS-BIOS output for custom reactions

9. PCA9555 Auto-Detection

PCA9555 I2C expanders are fully data-driven. At startup, CockpitOS:

  1. Collects addresses from InputMapping.h (source strings like "PCA_0x22") and LEDMapping.h (DEVICE_PCA9555 entries with .pcaInfo.address)
  2. Probes each address on the I2C bus with 3 retries
  3. Sweeps the full I2C range (0x08-0x77) to detect any unmapped devices
  4. 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.


10. Reference Implementations

WingFold.cpp -- State Machine Panel

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);

IFEIPanel.cpp -- Complex Display Panel

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 init and disp_init hooks.
  • 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_TASK can offload display refresh to a separate task.

TFT_Gauges_Battery.cpp -- TFT Gauge with FreeRTOS

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.

TFT_Gauges_CabinPressure.cpp -- Higher Resolution TFT

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.

TFT_Display_CMWS.cpp -- Multi-Subscription Display

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.

TestPanel.cpp -- Minimal Example

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.


11. Auto-Tick and Output Drivers

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 tick function just to flush LED output buffers.
  • If your panel calls setLED() or writes to PCA9555 outputs, the data is flushed automatically.
  • The tick hook is only needed for panel-specific per-frame work that is not output flushing.

12. Best Practices

Never Block in loop()

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.

Keep Allocations in init(), Not loop()

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.

Use CUtils API, Not Raw Arduino GPIO

CUtils provides consistent behavior across ESP32 variants (S2, S3, C3, C6, etc.). Raw Arduino calls may behave differently across variants.

Check HAS_* Guards

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 ...
#endif

Use IS_REPLAY for Testing

Set 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.

Polling Rate

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 ...
}

Mission-Aware Logic

Always check isMissionRunning() before accessing DCS-BIOS state in display render functions. The data is undefined between missions.


See Also