Cr1140.Avalonia 0.12.0

dotnet add package Cr1140.Avalonia --version 0.12.0
                    
NuGet\Install-Package Cr1140.Avalonia -Version 0.12.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Cr1140.Avalonia" Version="0.12.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Cr1140.Avalonia" Version="0.12.0" />
                    
Directory.Packages.props
<PackageReference Include="Cr1140.Avalonia" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Cr1140.Avalonia --version 0.12.0
                    
#r "nuget: Cr1140.Avalonia, 0.12.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Cr1140.Avalonia@0.12.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Cr1140.Avalonia&version=0.12.0
                    
Install as a Cake Addin
#tool nuget:?package=Cr1140.Avalonia&version=0.12.0
                    
Install as a Cake Tool

Cr1140.Avalonia

Custom Avalonia components for keypad-only embedded panels: evdev keypad input backend (with gestures), soft-key footer control, fbdev and tear-free DRM/KMS output backends with display rotation, status-LED and keypad-backlight control, display-brightness (backlight) control, a readable system-telemetry API, and a performance/diagnostics overlay.

What & Why

Avalonia's built-in LinuxFramebuffer input (LibInput / EvDev) provides touch and pointer input only — no keyboard or keypad support. The ifm CR1140/CR1141 ecomatDisplay (4.3", i.MX 8M Nano, 800×480 fbdev) is available as a keypad-only SKU (no touchscreen), which means a headless-framebuffer Avalonia UI cannot receive input from the device's gpio-keys keypad using the stock input backend.

Cr1140.Avalonia provides a custom IInputBackend implementation that directly reads the keypad from /dev/input/event1 via Linux evdev, maps the raw keycodes to a typed KeypadKey enum (F1–F6, arrow keys, Enter), and raises managed events for application-driven navigation: KeyPressed and KeyReleased for raw down/up, plus the derived gestures KeyTapped, KeyDoubleTapped, KeyHeld (long-press), and KeyHolding (press-and-hold auto-repeat). It also includes a SoftKeyFooter control — a 6-key soft-key footer with two layout modes (Physical and Natural) for operator-panel UIs — two software (Skia) output backends with display rotation (RotatingFbdevOutput on /dev/fb0 and the tear-free RotatingDrmOutput on /dev/dri/card0, via StartLinuxFbDevRotated / StartLinuxDrmRotated) so the panel can be mounted in any of the four orientations, onboard-LED control (LedSysfs/LedDriver over /sys/class/leds: RGB status light + RGB keypad backlight with animation modes), display-brightness control (Backlight over /sys/class/backlight, with raw and 0–100 % helpers), a framework-agnostic SystemTelemetry collector for CPU / memory / temperature / uptime / load and DeviceInfo for OS identity and network state, and a non-interactive PerfOverlay that shows real FPS / frame timing sourced from the output backends. Components are verified on real CR1140 hardware rendering to the panel and receiving physical keypad input.

Install

dotnet add package Cr1140.Avalonia

Dependencies (automatically resolved):

  • Avalonia 11.3.20
  • Avalonia.LinuxFramebuffer 11.3.20

Requirements

  • Framebuffer device: /dev/fb0 or another fbdev node (800×480 on the CR1140/CR1141).
  • DRM device (for the tear-free DRM path): /dev/dri/card0. The process must be able to become DRM master (own the display exclusively).
  • Evdev keypad node: /dev/input/event1 (or another evdev node; path is configurable).
  • Permissions: The process must have read access to the evdev node. Run as root or add the user to the input group.
  • Platform: The Avalonia app must start via StartLinuxFbDev(...) or StartLinuxDrm(...) — a display server (X11/Wayland) is not used.

Usage

using Avalonia;
using Avalonia.Controls;
using Avalonia.LinuxFramebuffer;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using Cr1140.Avalonia.Input;

namespace MyKeypadApp;

internal static class Program
{
    public static void Main(string[] args)
    {
        // Construct the keypad input backend
        var keypad = new EvdevKeypadInput("/dev/input/event1");

        // Subscribe to key presses and marshal to the UI thread
        keypad.KeyPressed += key =>
        {
            Dispatcher.UIThread.Post(() => HandleKey(key));
        };

        // Start Avalonia on the Linux framebuffer with the custom input backend
        BuildAvaloniaApp()
            .StartLinuxFbDev(args, "/dev/fb0", scaling: 1.0, inputBackend: keypad);
    }

    private static void HandleKey(KeypadKey key)
    {
        // Drive your navigation FSM or view-model from here
        switch (key)
        {
            case KeypadKey.Up:
                // Navigate up
                break;
            case KeypadKey.Down:
                // Navigate down
                break;
            case KeypadKey.Enter:
                // Confirm selection
                break;
            case KeypadKey.F6:
                // Back to menu
                break;
            // ... handle other keys
        }
    }

    private static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>()
            .UseReactiveUI();
}

See the cr1140-avalonia-demo reference application for a complete working example with MVVM navigation, screens, and soft-key footer.

Keycode Mapping

The evdev keycodes from the CR1140/CR1141 gpio-keys device are mapped as follows:

Evdev Code KeypadKey
59 F1
60 F2
61 F3
62 F4
63 F5
64 F6
103 Up
108 Down
105 Left
106 Right
28 Enter

KeyPressed fires on key-down (EV_KEY, value 1) and KeyReleased on key-up (value 0); kernel auto-repeat (value 2) is ignored. See Key events & gestures below for the higher-level events built on top of these.

Key events & gestures

EvdevKeypadInput exposes six event Action<KeypadKey>? events. All fire on background threads — marshal to the UI thread with Dispatcher.UIThread.Post.

Event Fires when
KeyPressed A key goes down (evdev value 1).
KeyReleased A key comes up (evdev value 0).
KeyTapped A short press-and-release completes with no second tap inside the double-tap window.
KeyDoubleTapped Two taps of the same key complete within DoubleTapWindow.
KeyHeld A key stays down past HoldThreshold (fires once — long-press).
KeyHolding Repeats every HoldRepeatInterval while the key stays down after KeyHeld (press-and-hold auto-repeat).

Gesture timing is configurable via KeyGestureOptions (defaults: HoldThreshold 500 ms, HoldRepeatInterval 150 ms, DoubleTapWindow 300 ms):

var keypad = new EvdevKeypadInput("/dev/input/event1", new KeyGestureOptions
{
    HoldThreshold = TimeSpan.FromMilliseconds(400),
    HoldRepeatInterval = TimeSpan.FromMilliseconds(120),
    DoubleTapWindow = TimeSpan.FromMilliseconds(250),
});

keypad.KeyTapped       += k => Dispatcher.UIThread.Post(() => OnTap(k));
keypad.KeyDoubleTapped += k => Dispatcher.UIThread.Post(() => OnDoubleTap(k));
keypad.KeyHeld         += k => Dispatcher.UIThread.Post(() => OnHoldStart(k));
keypad.KeyHolding      += k => Dispatcher.UIThread.Post(() => OnHoldRepeat(k)); // e.g. increment a value
keypad.KeyReleased     += k => Dispatcher.UIThread.Post(() => OnRelease(k));

The gesture logic lives in KeyGestureDetector — a pure, allocation-free, host-testable state machine driven by monotonic timestamps (Down/Up/Tick). Auto-repeat is derived by the library's own timer, so holding works whether or not the gpio-keys kernel autorepeat is enabled.

SoftKeyFooter control

SoftKeyFooter is a 6-key soft-key footer control that displays labels above the CR1140's physical F1–F6 keys. It supports two layout modes:

  • Physical (default): Cells are ordered F6 F4 F2 · d-pad · F1 F3 F5 to match the CR1140 keypad — each label sits directly over the button that triggers it. The d-pad cluster (Enter + arrows) sits in the middle between F2 and F1.
  • Natural: Cells run left-to-right F1 F2 F3 · d-pad · F4 F5 F6 — a conventional reading order, with the d-pad kept centred.

XAML Usage

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:cr="using:Cr1140.Avalonia.Controls"
             x:Class="MyApp.Views.MyView">
  <Grid RowDefinitions="*, Auto">
    
    <TextBlock Grid.Row="0" Text="Dashboard" />

    
    <cr:SoftKeyFooter Grid.Row="1"
                      Layout="Physical"
                      F1="Start"
                      F2="Stop"
                      F3="Info"
                      F6="Back" />
  </Grid>
</UserControl>

Or bind labels from a view model:

<cr:SoftKeyFooter Layout="{Binding FooterLayout}"
                  F1="{Binding SoftKeys[0].Label}"
                  F2="{Binding SoftKeys[1].Label}"
                  F3="{Binding SoftKeys[2].Label}"
                  F4="{Binding SoftKeys[3].Label}"
                  F5="{Binding SoftKeys[4].Label}"
                  F6="{Binding SoftKeys[5].Label}" />

Natural layout & key remapping

In Physical mode each label sits over its real button, so no key remapping is needed. In Natural mode the on-screen order no longer matches the physical buttons, so remap each incoming key-press with SoftKeyLayoutMap.ToLogical before acting on it — the button in physical position i then triggers the logical key shown there (e.g. hardware F6 acts as F1). Arrow/Enter keys are never remapped.

using Cr1140.Avalonia.Controls;

keypad.KeyPressed += hw =>
{
    // identity in Physical; physical-position remap in Natural
    var key = SoftKeyLayoutMap.ToLogical(hw, footerLayout);
    Dispatcher.UIThread.Post(() => Handle(key));
};

SoftKeyLayoutMap.PhysicalOrder is the single source of truth for the keypad's left-to-right F-key order; the SoftKeyFooter control uses it too.

Properties

Namespace: Cr1140.Avalonia.Controls

Property Type Default Description
Layout SoftKeyFooterLayout Physical Layout mode: Physical (keypad-ordered) or Natural (F1–F6 left-to-right)
ShowDPad bool true Show the centre d-pad cell (applies to both layouts; set false for a plain 6-cell footer)
F1F6 string? null Per-key label text; empty or null renders a blank cell
DividerBrush IBrush #333333 Brush for cell dividers
KeyForeground IBrush #888888 Brush for key names (F1, F2, etc.)
LabelForeground IBrush #00AAFF Brush for label text
DPadForeground IBrush #6A7B8A Brush for d-pad glyph text
DPadBackground IBrush #141414 Brush for d-pad cell background
DPadLine1 string "▲ ▼" First line of d-pad cell text
DPadLine2 string "◀ OK ▶" Second line of d-pad cell text

Because SoftKeyFooter derives from Avalonia's Border, you can also set:

  • Background (default #1A1A1A) — footer strip background
  • BorderBrush (default #333333) — footer strip border
  • BorderThickness (default 0,2,0,0) — top border line
  • Height (default 64) — footer strip height

All styling properties are overridable to match your application's theme. The defaults are dark colors suited for an operator panel UI.

Verified on real CR1140 hardware with both Physical and Natural layout modes.

Telemetry (system metrics)

SystemTelemetry is a plain, framework-agnostic collector — not a control. Hold one instance and call Sample() on whatever cadence you like (a 1 Hz DispatcherTimer is typical); each call returns a TelemetrySnapshot. Every field is independently optional (double? / MemInfo?), so a missing /proc file or thermal zone never throws — it just yields null for that field. Because it keeps CPU-sampler state between calls, reuse the same instance rather than constructing one per sample; the first call primes the CPU baseline and reports 0%.

using Cr1140.Avalonia.Telemetry;

var telemetry = new SystemTelemetry();      // SoC thermal zone 0 by default

// ...on a 1 Hz timer, on the thread of your choice:
TelemetrySnapshot s = telemetry.Sample();

string cpu = s.CpuPercent is double c ? $"{c:F0} %" : "—";
string mem = s.Memory is MemInfo m ? $"{m.UsedPercent:F0} % of {m.TotalKb / 1024} MB" : "—";
string soc = s.SocTempC is double t ? $"{t:F1} °C" : "—";
string up  = s.UptimeSeconds is double u ? ProcFs.FormatUptime(u) : "—";

Namespace: Cr1140.Avalonia.Telemetry

Type Role
SystemTelemetry Pull-based collector; Sample()TelemetrySnapshot. Ctor takes an optional SoC thermal-zone number (DefaultSocThermalZone = 0). Holds CPU-sampler state; no threads, no timers.
TelemetrySnapshot One point-in-time read: CpuPercent, Memory, SocTempC, BoardTempC, UptimeSeconds, Load1 — each independently optional.
MemInfo TotalKb, AvailableKb, and the computed UsedPercent (0..100).
CpuSampler Standalone CPU-usage sampler (busy % between two /proc/stat reads); reusable on its own.
ProcFs Pure parsers (ParseStat, ParseMeminfo, ParseUptime, ParseLoadavg, ParseMillidegrees) plus thin readers and FormatUptime — read a single metric yourself, or unit-test the parsers without a filesystem.
DeviceInfo Device/OS identity and network state: Hostname(), OsRelease(key), ReadBoardTempC(), OperState(iface), IPv4(iface).

All telemetry types are pure BCL (no Avalonia dependency) and read from Linux /proc and /sys; on a non-Linux host every reader degrades to null / "?" rather than throwing, so the same code is safe to reference from cross-platform tooling. This mirrors the Rust SDK's cr1140-sdk metrics + device modules.

Status LED & keypad backlight

The CR1140/CR1141 has an RGB status light and an RGB backlight behind the keypad buttons, both exposed by the kernel under /sys/class/leds/. The Cr1140.Avalonia.Leds namespace mirrors the Rust framework: LedSysfs is the thin sysfs primitive (cr1140-hal), and LedMode/LedAnimation/LedDriver are the animation layer (cr1140-sdk). Writes need write access to the brightness nodes (run as root or add a udev rule); off-device every call is a safe no-op — writes return false, reads return null.

using Cr1140.Avalonia.Leds;

// Status light: three binary channels (max 1). Green = ready, amber = warning.
LedSysfs.SetTyped(Led.StatusRed, 0);
LedSysfs.SetTyped(Led.StatusGreen, 1);
LedSysfs.SetTyped(Led.StatusBlue, 0);

// Keypad button backlight: one RGB color from three PWM channels (0–255).
LedSysfs.SetKbdBacklight(255, 90, 0);       // orange

// Animated keypad backlight — drive Tick() from a timer (e.g. a DispatcherTimer).
var led = new LedDriver();
led.SetColor((0, 128, 255));                 // base color
led.SetMode(LedMode.Pulse);                  // 2 s breathe

// ...on a ~30–60 Hz timer for a smooth pulse (1 Hz is enough for Blink/Solid):
led.Tick();   // samples the curve, writes sysfs only when the value changes

Namespace: Cr1140.Avalonia.Leds

Type Role
enum Led The six LED channels: StatusRed/Green/Blue (binary RGB status light, Max = 1) and KbdRed/Green/Blue (PWM RGB keypad backlight, Max = 255).
LedSysfs Typed sysfs read/write: Name(Led), Max(Led), Set/Read (raw name), SetTyped(Led, value) (clamps to Max), SetKbdBacklight(r, g, b), ListLeds(). Writes → bool, reads → uint?.
enum LedMode Animation curve: Solid, Dim (50%), Pulse (2 s breathe), Blink (1 Hz), Flash (~4 Hz strobe), Heartbeat (double-beat).
LedAnimation Pure math (host-testable, no hardware): Name(mode), Level(mode, t), Scale((r,g,b), level).
LedDriver Holds a base color + LedMode; SetColor/SetMode; Tick() writes the keypad backlight only when the computed color changes. New driver is off/Solid, no write until the first Tick.

LedAnimation is pure BCL (host-testable like CpuSampler); LedSysfs/LedDriver are the sysfs wirings. This mirrors the Rust cr1140-hal sys LED functions and the cr1140-sdk led module. The status light is set per-channel (SetTyped); the keypad backlight is one RGB color (SetKbdBacklight / LedDriver).

Display brightness

The panel's LCD backlight is exposed by the kernel under /sys/class/backlight/backlight/ (max_brightness = 400 on the CR1140/CR1141). The Cr1140.Avalonia.Display namespace's Backlight mirrors the Rust cr1140-hal sys backlight functions — a thin sysfs primitive — and adds 0–100 % helpers so you can offer an operator brightness control without hard-coding the panel's raw range. Writes need write access to the brightness node (run as root or add a udev rule); off-device every call is a safe no-op — writes return false, reads return null.

using Cr1140.Avalonia.Display;

// Raw counts (0..max_brightness):
uint? max = Backlight.Max(Backlight.Default);       // 400 on the CR1140
uint? cur = Backlight.Read(Backlight.Default);
Backlight.Set(Backlight.Default, 300);

// Or as a percentage, scaled against max_brightness for you:
Backlight.SetPercent(Backlight.Default, 80);        // 80 % → 320 counts
double? pct = Backlight.ReadPercent(Backlight.Default);

⚠ Writing 0 turns the backlight fully off. On a keypad-only panel there is no touch to recover, so keep a non-zero floor in your UI (the demo uses 10 %).

Namespace: Cr1140.Avalonia.Display

Type Role
Backlight Typed /sys/class/backlight/ read/write: Default (="backlight") and MaxHint (=400) constants, Set/Read (raw counts), Max (reads max_brightness), ListBacklights(), plus SetPercent(name, 0..100) / ReadPercent(name) scaled against max_brightness. Writes → bool, reads → uint?/double?; safe no-op off-device.

Backlight is pure BCL sysfs access (no Avalonia dependency), the screen-brightness counterpart of LedSysfs, mirroring the Rust cr1140-hal sys backlight primitives.

Performance overlay

A game-engine-style diagnostics HUD for on-panel performance monitoring. The overlay shows real FPS and frame timing sourced directly from the output backends (RotatingFbdevOutput / RotatingDrmOutput) — Render time (Skia CPU rasterize), Present time (rotate-blit + page-flip/vsync wait), Total cadence, and actual V-Sync state — along with sparkline graphs and an optional system-info block (CPU, memory, SoC temp).

Because the i.MX 8M Nano has no GPU (software Skia only), the overlay repurposes the typical "GPU" row to show the Present path (rotate-blit + vsync), which is the other half of the frame budget.

Usage

Create a FrameStatsRecorder and pass it to your output backend:

using Avalonia;
using Cr1140.Avalonia.Diagnostics;
using Cr1140.Avalonia.Input;
using Cr1140.Avalonia.Output;

var stats = new FrameStatsRecorder();
var keypad = new EvdevKeypadInput("/dev/input/event1");

// DRM path (tear-free)
BuildAvaloniaApp().StartLinuxDrmRotated(
    args,
    DisplayRotation.None,
    card: "/dev/dri/card0",
    scaling: 1.0,
    inputBackend: keypad,
    stats: stats);

// Or fbdev path (single-buffered)
// BuildAvaloniaApp().StartLinuxFbDevRotated(
//     args,
//     DisplayRotation.None,
//     fbdev: "/dev/fb0",
//     scaling: 1.0,
//     inputBackend: keypad,
//     stats: stats);

Then attach the overlay to your view's TopLevel (in OnAttachedToVisualTree):

using Avalonia.Controls;
using Cr1140.Avalonia.Diagnostics;

protected override void OnAttachedToVisualTree(TreeAttachmentEventArgs e)
{
    base.OnAttachedToVisualTree(e);

    TopLevel.GetTopLevel(this)!.AttachPerfOverlay(stats, new PerfOverlayOptions
    {
        ToggleKeypad = keypad,
        ToggleKey = KeypadKey.F5,
        ToggleGesture = PerfOverlayToggleGesture.DoubleTapped,
        ShowSystemInfo = true,
    });
}

The overlay is non-interactive and renders on the TopLevel's OverlayLayer.

Note (retained-mode FPS): Avalonia is retained-mode — the UI only redraws when something changes. FPS is meaningful only while the overlay is driving redraw (via RedrawInterval > 0 in PerfOverlayOptions); otherwise the panel idles and shows whatever FPS the app's own activity produces. The default RedrawInterval is 100 ms (10 Hz).

Note (self-perturbation): The HUD self-perturbs the frame time it measures — drawing the overlay itself consumes CPU and elongates the frame. This is inherent to any on-panel diagnostics HUD and is kept light (the overlay is custom-drawn and text-only; no heavy controls).

Display rotation

Mount the panel in any orientation. RotatingFbdevOutput (namespace Cr1140.Avalonia.Output) is a LinuxFramebuffer output backend that renders Avalonia at the logical (rotated) size and rotate-blits each frame onto /dev/fb0, so layout, DPI, and hit-testing stay correct for the chosen orientation. Rotating 90°/270° swaps the surface to portrait (the native 800×480 landscape framebuffer becomes 480×800).

Start with the StartLinuxFbDevRotated helper — the rotated counterpart of StartLinuxFbDev:

using Avalonia;
using Cr1140.Avalonia.Input;
using Cr1140.Avalonia.Output;

var keypad = new EvdevKeypadInput("/dev/input/event1");

BuildAvaloniaApp().StartLinuxFbDevRotated(
    args,
    DisplayRotation.Clockwise90,   // None / Clockwise90 / Clockwise180 / Clockwise270
    "/dev/fb0",
    scaling: 1.0,
    inputBackend: keypad);

DisplayRotation is the clockwise angle the rendered image is turned before it reaches the panel — pick the value that makes the UI upright for how the display is mounted. Or drive a RotatingFbdevOutput yourself and pass it to StartLinuxDirect:

var output = new RotatingFbdevOutput("/dev/fb0", DisplayRotation.Clockwise270, scaling: 1.0);
BuildAvaloniaApp().StartLinuxDirect(args, output, keypad);

The rotation math lives in the pure, dependency-free FramebufferRotator (Rotate(src, srcStride, dst, dstStride, dstWidth, dstHeight, bytesPerPixel, rotation)), which handles 32 bpp (Bgra/Rgba8888) and 16 bpp (Rgb565) and is unit-tested pixel-exact for all four angles. RotatingFbdevOutput uses the framebuffer's current mode (it does not change the display mode).

Note: rotation transforms the output only. Pointer/touch coordinates are not rotated, which is fine for the keypad-only SKU (keys carry no screen coordinates); a touch SKU using rotation would need a matching coordinate transform in the input path.

The cr1140-avalonia-demo reads rotation from --rotate=90|180|270 or the CR1140_ROTATE environment variable (default: no rotation).

DRM output (tear-free)

The fbdev backend (RotatingFbdevOutput) is single-buffered and can tear during large redraws. RotatingDrmOutput is the tear-free alternative: it presents through the Linux DRM/KMS stack (/dev/dri/card0) using double-buffered DUMB buffers and a page-flip, while still rendering with software Skia — no GL required, which matters because the i.MX 8M Nano has no usable GL driver. It supports the same DisplayRotation values as the fbdev backend and reuses the same pure FramebufferRotator.

Start with the StartLinuxDrmRotated helper — the DRM counterpart of StartLinuxFbDevRotated:

using Avalonia;
using Cr1140.Avalonia.Input;
using Cr1140.Avalonia.Output;

var keypad = new EvdevKeypadInput("/dev/input/event1");

BuildAvaloniaApp().StartLinuxDrmRotated(
    args,
    DisplayRotation.None,          // None / Clockwise90 / Clockwise180 / Clockwise270
    "/dev/dri/card0",              // or null for the default node
    scaling: 1.0,
    inputBackend: keypad,
    fps: 24);                      // cap the render/present rate (default 60)

Or drive a RotatingDrmOutput yourself and pass it to StartLinuxDirect:

var output = new RotatingDrmOutput("/dev/dri/card0", DisplayRotation.None, scaling: 1.0);
BuildAvaloniaApp().StartLinuxDirect(args, output, keypad);

Requirements & limitations:

  • The process must be DRM master — own the display exclusively (mask app-launcher / ifm-local-setup / CODESYS first, as for the fbdev path).
  • Presents at 32 bpp XRGB8888 (Bgra8888); there is no 16 bpp path.
  • Picks the first connected connector and its preferred mode.
  • If the driver rejects legacy page-flip, it falls back to a per-frame SETCRTC (tearing, but working).

The cr1140-avalonia-demo uses this DRM path by default; opt out with --fbdev (or CR1140_OUTPUT=fbdev), and override the node with --card=… / CR1140_CARD. If DRM init fails it logs and falls back to the fbdev backend.

Fixed-FPS render cap (CPU headroom)

Both StartLinuxDrmRotated and StartLinuxFbDevRotated accept an optional fps argument (default 60) that sets Avalonia's LinuxFramebufferPlatformOptions.Fps — the render-timer ceiling on how often the compositor renders and presents. Rendering is software Skia (no GPU) and every present does a full-frame rotate-blit + page-flip regardless of dirty region, so present CPU is proportional to the present rate. Lowering fps frees CPU while the UI is actively redrawing (animations, live gauges, scrolling); at idle it is a no-op, because Avalonia is retained-mode and an unchanging screen produces no frames to throttle. On-device (CR1140, 2×Cortex-A53, DRM): steady-idle ~5 % of one core regardless of fps; continuous redraw ~69 % @60, ~48 % @24, ~31 % @15. fps <= 0 leaves the Avalonia default (60). The cr1140-avalonia-demo wires it via --fps=<n> / CR1140_FPS.

systemd watchdog

SystemdWatchdog (namespace Cr1140.Avalonia.Systemd) reproduces the liveness supervision the stock CODESYS runtime uses — a systemd Type=notify + WatchdogSec= service watchdog — for your Avalonia app, with no libsystemd dependency. It reads NOTIFY_SOCKET / WATCHDOG_USEC from the environment, speaks the sd_notify(3) AF_UNIX datagram protocol directly, and no-ops off systemd (desktop/dev), so it is safe to construct and Start() unconditionally.

Start() sends READY=1, then pings WATCHDOG=1 on a DispatcherTimer at half of WATCHDOG_USEC. The ping runs on the Avalonia UI thread — so if the UI/render thread wedges the pings stop and systemd restarts the app. (A background-thread ping would keep firing through a frozen UI and hide the hang.) Call it once the surface is up:

using Cr1140.Avalonia.Systemd;

public override void OnFrameworkInitializationCompleted()
{
    // ... set up your MainView / MainWindow ...

    _watchdog = new SystemdWatchdog(); // no-op off systemd
    _watchdog.Start();                 // READY=1 + UI-thread WATCHDOG=1 heartbeat
    base.OnFrameworkInitializationCompleted();
}

Configure the unit to match (restart in place rather than CODESYS's reboot-force):

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
WatchdogSec=30s
Restart=on-failure

For defense-in-depth, let systemd arm the SoC hardware watchdog as a backstop if systemd itself hangs — a drop-in /etc/systemd/system.conf.d/ file with [Manager]\nRuntimeWatchdogSec=60, then systemctl daemon-reexec.

Desktop emulator

The Cr1140.Avalonia.Emulator namespace (new in 0.12.0) lets any CR1140/CR1141 Avalonia operator-panel app run in a desktop window on macOS, Windows, or Linux for a fast edit→run loop, instead of cross-publishing and deploying to the physical panel over ssh. The emulator renders your app at the panel's 800×480 resolution (rotation-aware) inside a device bezel, provides an on-screen keypad plus physical-keyboard mapping (F1–F6, arrow keys, Enter), and emulates the device's actuation surfaces — the status LED, keypad backlight, and display-backlight dimming — by redirecting the real LedSysfs and Backlight writes to a temporary sysfs directory tree. Your application code is byte-identical on device and desktop; the same LedSysfs.SetTyped(...) / Backlight.SetPercent(...) calls work in both environments.

IKeypadInput: the keypad seam

The IKeypadInput interface (namespace Cr1140.Avalonia.Input) is the shared managed keypad event surface that lets the same view-model run unchanged on the device and in the emulator. It exposes six events, each event Action<KeypadKey>?:

  • KeyPressed — a key goes down
  • KeyReleased — a key comes up
  • KeyTapped — short press-and-release (no second tap)
  • KeyDoubleTapped — two taps within the double-tap window
  • KeyHeld — key stays down past the hold threshold (long-press, fires once)
  • KeyHolding — repeats while the key stays down after KeyHeld (press-and-hold auto-repeat)

On device, EvdevKeypadInput implements IKeypadInput by reading /dev/input/event1 via Linux evdev. On desktop, WindowKeypadInput implements the same interface by listening to the window's keyboard (physical keys F1–F6, arrows, Enter/Return) and on-screen button presses, deriving the same tap/double-tap/hold/holding gestures via the shared pure KeyGestureDetector on a 25 ms UI-thread DispatcherTimer. Consuming an app off IKeypadInput (e.g. MainViewModel(IKeypadInput keypad)) is what lets the same view-model run on both.

Usage

Your app's classic-desktop startup builds an AppBuilder with UsePlatformDetect() and StartWithClassicDesktopLifetime(args), creates a WindowKeypadInput + EmulatedDevice, and in OnFrameworkInitializationCompleted sets desktop.MainWindow to the window returned by Cr1140Emulator.BuildWindow(...):

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Cr1140.Avalonia.Emulator;
using Cr1140.Avalonia.Input;

namespace MyKeypadApp;

internal static class Program
{
    public static void Main(string[] args)
    {
        BuildAvaloniaApp()
            .UsePlatformDetect()          // windowing platform (macOS/Windows/Linux)
            .StartWithClassicDesktopLifetime(args);
    }

    private static AppBuilder BuildAvaloniaApp()
        => AppBuilder.Configure<App>();
}

public class App : Application
{
    private WindowKeypadInput? _keypad;
    private EmulatedDevice? _device;

    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            // Desktop emulator: create the keypad and hardware shim
            _keypad = new WindowKeypadInput();
            _device = new EmulatedDevice();  // single-instance; redirects LedSysfs/Backlight roots

            // Build your app's root view (consuming IKeypadInput)
            var mainView = new MainView(new MainViewModel(_keypad));

            // Host it in the emulator bezel
            desktop.MainWindow = Cr1140Emulator.BuildWindow(
                mainView,
                _keypad,
                _device,
                new EmulatorOptions
                {
                    Title = "My Keypad App (Emulator)",
                    ShowKeypad = true,
                    ShowKeyboardHints = true,
                });

            // Attach the window's keyboard events to the keypad input
            _keypad.Attach(desktop.MainWindow);
        }

        base.OnFrameworkInitializationCompleted();
    }
}

The consuming app must reference Avalonia.Desktop (the emulator itself uses only core Avalonia and does not pull the desktop platform into the package):

dotnet add package Avalonia.Desktop

Emulated surfaces

The EmulatedDevice creates a seeded temporary sysfs directory tree and redirects the new internal LedSysfs.Root / Backlight.Root hooks at it, so an app's real LedSysfs / Backlight / LedDriver writes land in the temp tree and become observable off-device — the device code path is byte-identical (still file I/O). It exposes live properties that the EmulatorWindow polls at 33 ms to update the on-screen indicators:

  • StatusColor (RGB) — the status light, reflecting LedSysfs.SetTyped(Led.Status*, ...) writes
  • KbdColor (RGB) — the keypad backlight, reflecting LedSysfs.SetKbdBacklight(r, g, b) or LedDriver.Tick() writes
  • BacklightPercent (0..100) — the display brightness, reflecting Backlight.SetPercent(...) writes

The bezel renders:

  • Your app's root view at the rotation-aware logical panel size (800×480 landscape, or 480×800 portrait if rotated 90°/270°)
  • An on-screen keypad in the panel's physical single-row layout (F6 F4 F2 · d-pad · F1 F3 F5) wired to the WindowKeypadInput via pointer events; each F-key keeps its fixed hardware label and shows a live caption of the soft-key it currently triggers (supply it via EmulatorOptions.KeyCaptions), so captions follow the app's footer layout
  • A live status-LED dot (bottom-right corner, like the panel) reflecting the app's status-light writes
  • A keypad-backlight tint over the on-screen keypad reflecting the app's keypad-backlight writes
  • A screen-dim overlay reflecting the display-backlight percentage (lower backlight → darker overlay, matching the real panel)

Physical-keyboard mapping (call _keypad.Attach(inputElement) on the window or a focused control):

Physical Key KeypadKey
F1–F6 F1F6
Arrow keys Up, Down, Left, Right
Enter/Return Enter

Emulator options

EmulatorOptions configures the bezel window:

Property Type Default Description
PanelWidth int 800 Panel logical width (swapped if rotated 90°/270°)
PanelHeight int 480 Panel logical height (swapped if rotated 90°/270°)
Rotation DisplayRotation None Display rotation: None, Clockwise90, Clockwise180, Clockwise270
Title string? null Window title (default: "CR1140 Emulator")
ShowKeypad bool true Show the on-screen keypad
ShowKeyboardHints bool true Show physical-key hints (F1–F6, arrows) on the on-screen keypad
KeyCaptions Func<KeypadKey,string?>? null Live caption per key, shown under the fixed hardware label; polled ~30 Hz so it follows the soft-key footer layout. Return the action the key triggers, or null for none

Scope: actuation surfaces only

The emulator emulates the device's actuation surfaces — the display (app screen at panel resolution + backlight dimming), the keypad, the status LED, and the keypad backlight. Read-only system telemetry (SystemTelemetry / ProcFs / DeviceInfo) is deliberately NOT redirected — it reads the real host, so on macOS the Telemetry screen shows ?/nulls and on a Linux host it shows host stats. This is intentional: the emulator provides honest host readout, never fabricated device values.

Running the demo emulator

The cr1140-avalonia-demo reference app auto-selects the emulator on macOS/Windows (off-device), or when forced with --emulator or CR1140_EMULATOR=1 on a Linux desktop. On the device (Linux, no flag) the real fbdev/DRM StartLinuxDirect path is unchanged.

Run locally:

just run-emulator
# or
dotnet run --project cr1140-avalonia-demo

The demo's Program.cs splits into RunEmulator (classic-desktop lifetime with WindowKeypadInput + EmulatedDevice) and RunDevice (the prior evdev + rotating fbdev/DRM path). MainViewModel's constructor now takes IKeypadInput (not the concrete EvdevKeypadInput), so the same view-model runs in both environments.

Design Note

EvdevKeypadInput raises a managed KeyPressed event on a background reader thread. Your application code subscribes to this event and drives navigation, view-model state, or an FSM — the app-driven pattern.

The library does not currently inject Avalonia KeyDown events or manipulate focus — this is an intentional design decision to keep the input backend simple and explicit. If you need Avalonia's routed key-event system, you can extend EvdevKeypadInput to call inputSink.Input(new RawKeyEventArgs(...)) in the Initialize method.

Reference Application

See cr1140-avalonia-demo in the repository for a complete reference implementation:

  • Menu-driven navigation (Up/Down/Enter)
  • Multiple screens (Dashboard, Bale Counter, Knives, Wrapping, Telemetry, Settings, Key Events, LEDs, Brightness)
  • Soft-key footer driven by F1–F6
  • MVVM with INotifyPropertyChanged and compiled XAML bindings
  • A Telemetry screen driven by SystemTelemetry + DeviceInfo (live CPU/memory/temperature/uptime/load and eth0/can0 state, refreshed at 1 Hz)
  • A Brightness screen that adjusts the display backlight via Cr1140.Avalonia.Display (Backlight.SetPercent) — Up/Down or F1/F2 in 10 % steps, with a 10 % safety floor
  • Verified running on the physical CR1140 device

License

Dual-licensed: GPL-3.0-only OR Commercial.

  • Open source: Free under the GNU GPL v3.0 if your project is GPL-compatible.
  • Commercial: For closed-source or proprietary use, contact UpTux UG (haftungsbeschränkt) at info@uptux.de to arrange a commercial license.

See LICENSING.md in the repository for full details.


Author: Patrick Dahlke
Company: UpTux UG (haftungsbeschränkt)
Repository: https://github.com/UpTux/ifm-cr1140

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.12.0 99 9/7/2026
0.11.0 94 9/4/2026
0.8.0 90 9/3/2026
0.5.0 107 9/2/2026

0.12.0 — add a desktop emulator (namespace Cr1140.Avalonia.Emulator) for fast Avalonia app development off-device: run the same CR1140/CR1141 app in a device-bezel window on a dev host (macOS/Windows/Linux) instead of cross-publishing to the panel. New IKeypadInput interface (namespace Cr1140.Avalonia.Input) is the shared keypad event surface implemented by both the on-device EvdevKeypadInput and the desktop WindowKeypadInput (which maps the physical keyboard F1-F6/arrows/Enter and on-screen buttons through the same KeyGestureDetector). EmulatedDevice redirects the new internal LedSysfs.Root/Backlight.Root sysfs hooks to a seeded temp tree so an app's real LED/backlight writes render live in the bezel; EmulatorWindow hosts the app at the panel's rotation-aware logical size with an on-screen keypad, status LED, keypad-backlight tint, and screen-dim overlay; Cr1140Emulator.BuildWindow is the one-liner entry. The on-screen keypad matches the panel's physical single-row layout (F6 F4 F2 d-pad F1 F3 F5) and each F-key shows a live caption (EmulatorOptions.KeyCaptions) of the soft-key it currently triggers, so captions follow the app's footer layout. The emulator uses only core Avalonia (the consuming app supplies the windowing platform via Avalonia.Desktop); read-only system telemetry is not emulated (reads the host). 0.11.0 — add a systemd service watchdog client (namespace Cr1140.Avalonia.Systemd): SystemdWatchdog is a dependency-free sd_notify(3) client mirroring how the stock codesys.service supervises liveness (Type=notify + WatchdogSec=). Start() sends READY=1 then pings WATCHDOG=1 on a DispatcherTimer at half of WATCHDOG_USEC, driven on the Avalonia UI thread so a wedged UI/render thread trips the watchdog; it reads NOTIFY_SOCKET/WATCHDOG_USEC from the environment, speaks the AF_UNIX datagram protocol directly (no libsystemd), and no-ops when NOTIFY_SOCKET is absent so it is safe to construct and Start() unconditionally. 0.10.0 — add a fixed-FPS render cap: StartLinuxDrmRotated / StartLinuxFbDevRotated (namespace Cr1140.Avalonia.Output) now take an optional fps argument (default 60) that sets Avalonia's LinuxFramebufferPlatformOptions.Fps render-timer ceiling. Rendering is software Skia (no GPU on the i.MX 8M Nano), so every produced frame is CPU work (rasterize + rotate-blit + page-flip wait); capping to e.g. 24 fps roughly halves render-loop CPU on the free-running DRM present path, freeing headroom for the rest of the app. fps <= 0 leaves the platform default (60) untouched. 0.9.0 — (1) add display-backlight control (namespace Cr1140.Avalonia.Display): the Backlight sysfs read/writer over /sys/class/backlight — Set/Read/Max/ListBacklights mirror the Rust cr1140-hal sys backlight functions, plus SetPercent/ReadPercent (0-100% helpers) and the Default node name + MaxHint constants, so an app can offer a simple operator brightness control without knowing the panel's raw max_brightness. (2) add a performance/telemetry overlay (namespace Cr1140.Avalonia.Diagnostics): the pure host-testable FrameStats ring buffer (Fps/Mspf/FrameCount/Metric for Total/Render/Present), the FrameStatsRecorder that the two output backends feed one FrameSample per presented frame (Render = Skia rasterize, Present = rotate-blit + page-flip/vsync wait, Total = present cadence, real V-Sync), and the non-interactive PerfOverlay control + AttachPerfOverlay(TopLevel) helper with an optional keypad-gesture toggle and a system-info block (adds DeviceInfo.CpuModel/CpuCount). Backend instrumentation is additive/opt-in (null recorder = no overhead); every new sysfs call is a safe no-op off-device (writes false, reads null), like LedSysfs/ProcFs. 0.8.0 — wire the onboard LEDs into the package (namespace Cr1140.Avalonia.Leds), mirroring the Rust cr1140-hal/cr1140-sdk LED framework: the Led enum and LedSysfs sysfs read/writer (Name/Max/Set/Read/SetTyped/SetKbdBacklight/ListLeds over /sys/class/leds) for the RGB status light and RGB keypad button backlight, plus the animation layer — LedMode (Solid/Dim/Pulse/Blink/Flash/Heartbeat), the pure host-testable LedAnimation (Level/Scale), and LedDriver (color + mode, writes only on change). Off-device every call is a safe no-op. 0.7.0 — add a DRM/KMS output path for tear-free rendering: RotatingDrmOutput (an IOutputBackend that presents Avalonia's software-rendered frame through double-buffered DUMB buffers and a KMS page-flip, with the same DisplayRotation support as the fbdev backend) and the StartLinuxDrmRotated AppBuilder extension (namespace Cr1140.Avalonia.Output). Skia keeps rendering on the CPU (no GL required); the shared FramebufferRotator does the rotate-blit. 0.6.0 — add display rotation for panels mounted in any orientation: DisplayRotation enum, RotatingFbdevOutput (an IOutputBackend that rotate-blits Avalonia's frame onto the framebuffer), the pure host-testable FramebufferRotator, and the StartLinuxFbDevRotated AppBuilder extension (namespace Cr1140.Avalonia.Output). 0.5.0 — EvdevKeypadInput now raises KeyReleased (evdev key-up) and derived gestures KeyTapped, KeyDoubleTapped, KeyHeld (long-press), and KeyHolding (press-and-hold auto-repeat), configurable via new KeyGestureOptions; add the pure, host-testable KeyGestureDetector state machine. KeyPressed is unchanged. 0.4.0 — add SystemTelemetry (CPU/memory/SoC+board temp/uptime/load from /proc + thermal sysfs), MemInfo, TelemetrySnapshot, CpuSampler, ProcFs, and DeviceInfo.