Cr1140.Avalonia
0.12.0
dotnet add package Cr1140.Avalonia --version 0.12.0
NuGet\Install-Package Cr1140.Avalonia -Version 0.12.0
<PackageReference Include="Cr1140.Avalonia" Version="0.12.0" />
<PackageVersion Include="Cr1140.Avalonia" Version="0.12.0" />
<PackageReference Include="Cr1140.Avalonia" />
paket add Cr1140.Avalonia --version 0.12.0
#r "nuget: Cr1140.Avalonia, 0.12.0"
#:package Cr1140.Avalonia@0.12.0
#addin nuget:?package=Cr1140.Avalonia&version=0.12.0
#tool nuget:?package=Cr1140.Avalonia&version=0.12.0
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):
Avalonia11.3.20Avalonia.LinuxFramebuffer11.3.20
Requirements
- Framebuffer device:
/dev/fb0or 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
inputgroup. - Platform: The Avalonia app must start via
StartLinuxFbDev(...)orStartLinuxDrm(...)— 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 F5to 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) |
F1 – F6 |
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 backgroundBorderBrush(default#333333) — footer strip borderBorderThickness(default0,2,0,0) — top border lineHeight(default64) — 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
0turns 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 downKeyReleased— a key comes upKeyTapped— short press-and-release (no second tap)KeyDoubleTapped— two taps within the double-tap windowKeyHeld— key stays down past the hold threshold (long-press, fires once)KeyHolding— repeats while the key stays down afterKeyHeld(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, reflectingLedSysfs.SetTyped(Led.Status*, ...)writesKbdColor(RGB) — the keypad backlight, reflectingLedSysfs.SetKbdBacklight(r, g, b)orLedDriver.Tick()writesBacklightPercent(0..100) — the display brightness, reflectingBacklight.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 theWindowKeypadInputvia pointer events; each F-key keeps its fixed hardware label and shows a live caption of the soft-key it currently triggers (supply it viaEmulatorOptions.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 | F1–F6 |
| 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
INotifyPropertyChangedand 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 | Versions 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. |
-
net8.0
- Avalonia (>= 11.3.20)
- Avalonia.LinuxFramebuffer (>= 11.3.20)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
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.