Skip to content

Fix M-display losing its layout after a restart (duplicate-serial disambiguation) - #2

Merged
BeardedTech0o merged 29 commits into
mainfrom
claude/m-display-content-loss-7j91zv
Aug 11, 2026
Merged

Fix M-display losing its layout after a restart (duplicate-serial disambiguation)#2
BeardedTech0o merged 29 commits into
mainfrom
claude/m-display-content-loss-7j91zv

Conversation

@BeardedTech0o

Copy link
Copy Markdown
Owner

Summary

  • main was significantly behind the actual app: it only contained an early M1 scaffold, while all of the real Widget Designer functionality (device dropdown, rename, brightness, integrations, reconnect handling, the duplicate-serial workaround, etc.) lived on the never-merged claude/ax206-display-tray-app-rjlvh4 branch. This PR brings main up to that branch's tip, plus a fix for the reported bug on top.
  • Root cause of the reported bug: these AX206 panels don't expose a unique per-unit USB serial number — multiple physical units report the same placeholder string (e.g. 20201115). The app disambiguates colliding panels by appending their USB port location (20201115@6-17, 20201115@6-18), but only once a collision is actually observed within a single discovery scan; that memory lived only in-process (AmbiguousSerialTracker). On a full app/PC restart, if just one panel of a colliding pair had finished USB enumeration by the time the very first scan ran (common at boot, when hubs power up a beat apart), it came back with its bare, undisambiguated serial. That bare ID matched neither saved @location-suffixed profile, so the host treated the panel as a brand-new display, auto-provisioned a fresh default clock/CPU/RAM layout, and drove that to the physical screen — while the old profile sat untouched and unattached in config.json, which is why the Widget Designer kept showing its previous content.
  • Fix: seed AmbiguousSerialTracker's collision memory at startup (and on every manual refresh) from any {serial}@{location}-shaped profile ID already present in the saved config, so a lone panel is still correctly recognized as part of a known-colliding pair even on the very first post-restart scan — closing the restart race instead of only handling the mid-session reconnect case.

Changes

  • IAx206DeviceDiscovery: new SeedKnownAmbiguousSerialNumbers member (default no-op for implementations that don't disambiguate by serial collision).
  • LibUsbAx206DeviceDiscovery: implements it by delegating to AmbiguousSerialTracker.
  • AmbiguousSerialTracker: new Seed method to prime the collision memory from an external source.
  • DisplayManagerHostedService: seeds known-ambiguous serials from saved config profiles before every discovery scan in both StartAsync and RefreshDevicesAsync.
  • Added unit tests covering the new Seed behavior.

Test plan

  • Reviewed the change carefully by hand (existing AmbiguousSerialTrackerTests plus two new cases covering seeding).
  • CI build/test run (this sandbox has no .NET SDK available to build locally — network access to the dotnet install script was blocked by the environment's proxy allowlist).
  • Manual verification on real hardware: restart the app/PC with both colliding-serial panels connected and confirm the previously-configured layout reappears on both screens instead of a default layout being provisioned.

Generated by Claude Code

claude added 29 commits July 13, 2026 05:51
The path from hardware sensors to LCD pixels, end to end:

- RenderDataHub (Rendering): copy-on-write key/value snapshot store;
  display loops read an immutable snapshot per frame with no lock on
  the render path. DeviceDisplayLoop takes an optional
  IRenderDataProvider and feeds it into WidgetRenderContext.Data.
- SystemStatWidget ("stat"): renders one labeled numeric reading
  ("CPU 43%") from a configured dataKey, with "--" as the placeholder
  when the sensor is absent. TextWidget ("text") for static labels.
  Shared WidgetTextRenderer centers and shrinks-to-fit text; ClockWidget
  refactored onto it.
- SystemStatsPublisher + SystemStatKeys (DataSources): maps a
  SystemStatsSnapshot onto stable, config-referenced data keys;
  missing sensors remove their key so widgets fall back to the
  placeholder instead of showing stale numbers.
- SystemMonitorPumpService (App): BackgroundService polling
  LibreHardwareMonitor every 2s into the hub; poll failures are logged
  and skipped.
- Default auto-provisioned layout is now clock + CPU load / CPU temp /
  RAM row (existing saved configs are untouched).

79 tests passing (14 new: hub semantics, publisher key lifecycle, stat
widget rendering incl. placeholder/non-numeric paths, factory parsing,
loop data flow).
Replaces the M1 read-only clock preview with a real per-device layout
editor - the goal being that a user never opens config.json by hand:

- Pick a device from a dropdown (reads saved DeviceProfileConfig list;
  works without the device plugged in, since resolution/widgets are
  already persisted).
- Add Clock / Text / System Stat widgets from toolbar buttons.
- Click a widget to select it, drag its body to move, drag a corner
  handle to resize - both clamped to stay fully on-canvas. This is a
  transparent WPF overlay (DesignerWidgetOverlay) laid over a single
  SKElement that composes the *real* FrameCompositor output each tick,
  so the preview is pixel-for-pixel what the physical display shows,
  not a mockup.
- A property panel built per widget type from dropdowns and text
  fields only (time format presets, a curated color palette, a
  friendly list of available sensor readings for stat widgets) - no
  free-form JSON anywhere.
- Save writes the edited layout back through ConfigService.

Two supporting changes so edits actually reach the screen:
- WidgetDesignItem (Ax206Display.Rendering): a mutable, testable
  FromConfig/ToConfig wrapper around the immutable WidgetConfig record,
  since dragging/resizing needs in-place mutation.
- DeviceDisplayLoop.UpdatePlacements + a 3s config-poll task in
  DisplayManagerHostedService: a running display now picks up a saved
  Widget Designer edit without an app restart.

Also: a Windows-only NuGet restore for Ax206Display.App turns out not
to require a Windows host after all (only building against the
WindowsDesktop SDK does) - generated and committed its
packages.lock.json and switched the Windows CI job to --locked-mode,
closing a gap the workflow had flagged since M1.

87 tests passing. The WPF designer UI itself (WidgetDesignerWindow,
DesignerWidgetOverlay, WidgetCatalog) can only be compiled by the
Windows CI job - this sandbox has no WindowsDesktop SDK workload to
verify it against locally.
SaveButton.IsEnabled already tracks whether there are unsaved changes;
_isDirty duplicated that without ever being read anywhere.
- Network speed: INetworkSpeedSource/NetworkInterfaceSpeedSource sums
  bytes sent/received across all up, non-loopback/tunnel adapters and
  reports the rate since the previous poll (NetworkSpeedRateCalculator
  clamps a counter reset to zero instead of a negative speed).
  NetworkSpeedPumpService polls every 2s and publishes Download/Upload
  in Mbps into the existing RenderDataHub under new NetworkSpeedKeys -
  they show up as ordinary entries in the Widget Designer's "System
  Stat" reading dropdown, no new widget type needed.
- Fonts: WidgetTextRenderer takes an optional fontFamily, resolved via
  SKTypeface.FromFamilyName and disposed correctly (SKTypeface.Default
  is a shared instance and is never disposed) - unknown/uninstalled
  family names fall back gracefully rather than throwing. Threaded
  through Clock/Text/Stat widgets and WidgetFactory; the Designer gets
  a "Font" dropdown (Default + 10 common Windows-installed families)
  next to the existing color picker on every widget type.
- Colors: palette grown from 9 to 17 curated, LCD-legible swatches.

93 tests passing (8 new: rate-calculator edge cases, publisher Mbps
conversion and key lifecycle, factory font-setting parsing, and an
unknown-font-family render regression test).
…X fixes

Formatting:
- WidgetFontStyle (a record CLASS - see its doc comment for why a
  struct silently zeroed SizeScale via new WidgetFontStyle(), a real
  bug caught by the test suite before it shipped) replaces the lone
  fontFamily string threaded through Clock/Text/Stat widgets. Bold and
  Italic resolve to an SKFontStyle via SKTypeface.FromFamilyName;
  typefaces are deliberately never disposed (a font+style typeface can
  alias a shared/cached instance - disposing one broke SKTypeface.Default
  itself on this environment's limited font set). Designer gets Bold/
  Italic checkboxes and a Small/Default/Large/Extra Large size dropdown
  next to the existing font/color pickers.

Backgrounds:
- DeviceProfileConfig.BackgroundImagePath; FrameCompositor.ComposeFrame
  draws it stretched-to-fill beneath all widgets. DeviceDisplayLoop
  gets UpdateBackgroundImage (mirrors UpdatePlacements) so a saved
  change reaches the running display without a restart;
  DisplayManagerHostedService's config-poll loop only re-decodes the
  file when its path actually changes. Designer gets "Background..."
  (Microsoft.Win32.OpenFileDialog) and "Clear Background" buttons.

Grid overlay:
- A session-local "Show Grid" checkbox draws evenly-spaced alignment
  lines directly in the preview's paint surface - a design-time aid
  only, never persisted or sent to the physical display.

Three UX fixes:
- The app now opens the Widget Designer on startup instead of only
  putting an icon in the tray with nothing visible.
- Tray Exit actually quits: it called IHostApplicationLifetime
  .StopApplication(), which stops hosted services but has no
  connection to the WPF Application's lifetime - with
  ShutdownMode=OnExplicitShutdown nothing else ever called Shutdown(),
  so the process lingered until killed from Task Manager. Now calls
  Application.Current.Shutdown(), which drives App.OnExit's existing
  host.StopAsync()/Dispose() correctly.
- (Unrelated to this session's other startup logs: a rejected USB
  device during discovery and a mid-blit NoDevice disconnect are
  pre-existing, separate issues - not touched here.)

104 tests passing.
Root cause of the NoDevice exception seen earlier: once
DeviceDisplayLoop.RunAsync threw (a USB disconnect mid-blit), the per-
device task just logged a warning and ended for good - the display
stayed dark until the whole app was restarted.

DisplayManagerHostedService now supervises each device slot instead of
firing its loop once: on any loop failure, it disposes the dead
transport, waits 5s, and re-runs discovery looking for the same device
by DeviceId. If found, it rebuilds placements/background from the
current config and resumes rendering; if not, it keeps retrying every
5s until it reappears or the app shuts down.

Two correctness points worth calling out:
- Discovery scans are now serialized through a semaphore
  (DiscoverSafelyAsync) - without it, two displays reconnecting around
  the same time could race to open/claim the same USB devices.
- _loopsByDeviceId and _backgroundImagePathsByDeviceId became
  ConcurrentDictionary: they're now written by N per-device supervisor
  tasks concurrently while WatchConfigForChangesAsync enumerates them
  on its own polling task, which a plain Dictionary doesn't allow
  safely.

This is orchestration logic entirely inside the WPF App project, so -
consistent with the rest of DisplayManagerHostedService - it has no
new Linux-testable surface; the existing 104 tests are unaffected and
still pass. Verified only by the Windows CI compile.
Adds a new Integrations window (tray menu: "Integrations...") for
configuring external data sources entirely through the GUI - no
config.json editing required, consistent with the rest of the app.

Proxmox VE:
- ProxmoxClient gains GetGuestStatusesAsync, listing every VM (qemu)
  and container (lxc) across all cluster nodes via the existing
  ticket-based auth.
- ProxmoxGuestDirectory holds a copy-on-write snapshot of the latest
  guest list so the Widget Designer's stat picker can list live guests
  (CPU/Mem) without touching the network on the UI thread.
- ProxmoxPumpService polls every 5s and re-authenticates automatically
  if a poll fails (e.g. ticket expiry).
- WidgetDesignerWindow's stat dropdown now merges in one CPU and one
  Memory entry per discovered guest via BuildAvailableStatKeys().

Pi-hole:
- New PiHoleClient/PiHoleStatsPublisher/PiHoleStatKeys, using the v5
  token-based summaryRaw endpoint (not "summary" - that endpoint
  returns some fields as comma-formatted strings instead of numbers).
- PiHolePumpService polls every 10s; no login/session needed.

Both integrations share:
- IntegrationConfig (extended with Realm for Proxmox) + SecretStore
  for the password/token, and IntegrationHttpClientFactory for
  building an HttpClient that pins a detected certificate rather than
  bypassing TLS validation wholesale.
- TlsCertificateProbe: a trust-on-first-use helper that opens a
  throwaway TLS handshake to capture a host's certificate thumbprint
  so the user can confirm it before it's pinned, instead of typing a
  thumbprint in blind. Verified against a real loopback TLS server in
  TlsCertificateProbeTests, not just compiled.
- IntegrationsWindow: independent Test & Save / Remove flows per
  integration; a saved password/token is never redisplayed, and
  leaving the field blank on save reuses the existing secret.

Also fixes a concurrency gap in SecretStore: it was a plain
Dictionary with no synchronization, which was fine while it only had
one reader/writer, but the new pump services now read it
concurrently while IntegrationsWindow can write to it. Added a
lock-guarded _syncRoot around every dictionary access.

104 -> 114 tests passing on the cross-platform slnf. The WPF App
project (IntegrationsWindow, TrayIconHostedService, HostFactory)
still only builds on the Windows CI job.
…ndow

Brightness:
- DeviceProfileConfig.Brightness (0-7, default 7 - full, matching prior
  hardware behavior for existing configs with no saved value).
- DeviceDisplayLoop.SetBrightnessAsync sends the hardware
  SetProperty(Brightness, ...) command directly (clamped to 0-7); this is
  a one-off property write, not part of the per-frame blit path.
- DisplayManagerHostedService applies it on connect/reconnect and
  hot-reloads it on save, same pattern as the background image path.
- Widget Designer toolbar gets a Brightness slider next to
  Background/Show Grid.

UniFi:
- Extends the UniFiClient/IUniFiClient scaffold that already existed in
  the repo (session login + CSRF token against a UniFi OS console) to
  parse actual numbers instead of just subsystem status strings:
  connected client count (num_user on the lan/wlan subsystems) and WAN
  throughput (rx/tx byte rates on the wan subsystem).
- New UniFiStatKeys/UniFiStatsPublisher, following the same shape as
  Proxmox/Pi-hole's.
- New UniFiPumpService (App): polls every 10s, re-logs-in automatically
  if a poll fails (expired session cookie/CSRF token), same shape as
  ProxmoxPumpService.
- IntegrationConfig gains a Site field (UniFi controller site, e.g.
  "default"), alongside the existing Realm field Proxmox uses.

Integrations window:
- Converted from one long scrolling stack into a TabControl - Proxmox,
  Pi-hole, and UniFi each get their own tab instead of being stacked
  under separators.

Also fixed while touching the Widget Designer's stat dropdown: Pi-hole's
stat keys were added to RenderDataHub in an earlier milestone but were
never added to WidgetCatalog.StatKeys, so there was no way to actually
select a Pi-hole reading in the GUI. Added those alongside the new UniFi
keys.

126 tests passing (up from 114), cross-platform build clean. The App
project (brightness slider, UniFi tab, pump service, dropdown wiring)
only builds on the Windows CI job.
The Windows build caught this (Linux only compiles the cross-platform
slnf, which excludes the WPF App project): int.ToString() without an
IFormatProvider is analyzer-flagged since its output can vary by the
current user's locale. The brightness label is just a small integer
0-7, not locale-sensitive display data, so InvariantCulture is correct.
…igner

Root cause of "additional screens never get detected, even after a
restart": LibUsbAx206DeviceDiscovery derived each display's ID from its
USB serial-number descriptor, but generic/clone AX206 panels commonly
burn the same fixed placeholder string into every unit of a firmware
batch instead of a real per-unit serial. Two panels reporting an
identical serial collided onto the same DeviceId, so only one config
profile was ever created no matter how many were plugged in.

LibUsbAx206DeviceDiscovery now disambiguates any serial-number collision
found within a scan by appending the USB port location. This does mean a
disambiguated panel's identity is tied to its USB port - moving it to a
different port looks like a new device - but that's an inherent limit of
hardware with no real unique identifier, not something addressable in
software. LibUsbAx206Transport.DeviceId is now settable (internal) so
discovery can apply this after the initial probe, once every device on
the scan is known.

Also added a Refresh button next to the Widget Designer's device
dropdown, so newly plugged-in displays (duplicate-serial or not) can be
picked up without restarting the app:
- DisplayManagerHostedService.RefreshDevicesAsync re-scans and starts
  supervising anything not already handled, auto-provisioning a default
  layout the same way StartAsync does at launch.
- A new _supervisedDeviceIds set (distinct from _loopsByDeviceId, which
  only holds an entry while a loop is actively connected) tracks "has a
  supervisor task ever been spawned for this ID" for that task's whole
  lifetime, so a device mid-reconnect can't be mistaken for new and
  double-supervised.
- StartAsync's config-change-watcher loop now always starts, even with
  zero devices found at launch, so a device adopted later via Refresh
  gets live hot-reload immediately instead of needing special-casing.
- DisplayManagerHostedService is now also registered as an injectable
  singleton (previously only as IHostedService) so the Designer window
  can call it directly, same pattern already used for TrayIconHostedService.

120 tests passing, cross-platform build clean. Both changes are in
Windows-only code paths (App project, and the libusb-backed Transport
implementation) that can only be compile-verified on the Windows CI job.
Nothing previously let a stale device profile be cleared from config
through the GUI - e.g. the leftover entry the serial-number collision
fix in the last commit leaves behind for anyone who already had one
auto-provisioned device before pulling that fix (a real case: two
panels reporting the same firmware-burned serial used to collide onto
one profile: "Display (20201115)"; after the fix they're correctly
split into ".../20201115@6-17" and ".../20201115@6-18", but the old
undisambiguated entry doesn't disappear on its own since nothing ever
prunes config.Devices).

Remove Device deletes the selected profile (layout, background,
brightness) after a confirmation prompt, since a saved layout can't be
recovered once gone. If that physical display is still plugged in, it
reappears as a "new" device with a fresh default layout the next time
it's discovered or Refreshed - config has no way to distinguish
"unplugged for good" from "temporarily disconnected".

App-project-only change (WidgetDesignerWindow), so it can only be
compile-verified on the Windows CI job - no cross-platform files
touched, so no change in the 120-test baseline.
Font sizes: replaced the Small/Medium/Large/Extra-Large presets with
an explicit pixel size list (8-96px) plus "Auto (fit to box)", the
prior default behavior. WidgetFontStyle gains FixedSizePixels - when
set, WidgetTextRenderer uses it exactly and skips the width-shrink
step (a fixed size should mean what it says; the compositor's existing
per-widget clip keeps any overflow contained). The old relative
"fontScale" setting is still read by WidgetFactory so layouts saved
before this change keep rendering the same way; the designer just
doesn't offer it as a choice anymore.

Rename: a Rename button next to the device dropdown opens a small
modal text prompt (WPF has no built-in input box) and saves
DeviceProfileConfig.Name.

Snap-to-align: dragging a widget now pulls its edges/center into line
with the canvas edges, the canvas center, and other widgets' edges/
centers once within 6px, same idea as Canva/Figma alignment guides.
The snap math (DesignerSnapEngine) lives in Ax206Display.Rendering so
it's unit-testable without WPF; DesignerWidgetOverlay just calls it
during a body drag (not resize) and the window draws a dashed guide
line for whichever axis snapped, clearing it on mouse-up.

134 tests passing (up from 130), cross-platform build clean. The forge
repo theming request is still blocked - repo access wasn't approved.
Repo access to forge wasn't available, but the user pasted the three
files that actually define its look: tailwind.config.js, app.css, and
app.html. Ported the dark-mode palette, Poppins font, and rounded-card
shapes into a WPF ResourceDictionary (Theme/ForgeTheme.xaml), merged
into Application.Resources so it cascades to every window - including
ones built entirely in code-behind (most of this app's property panel
and the Rename dialog), since WPF's implicit-style lookup walks up the
type hierarchy with no explicit Style reference needed.

Retemplated: Window, Button (two variants - a neutral default matching
forge's .btn-secondary, and an explicit AccentButtonStyle applied to
each window's one real call-to-action, matching .btn-primary),
TextBox, PasswordBox, CheckBox, Slider, ComboBox + ComboBoxItem,
ScrollBar, TabControl + TabItem, and ContextMenu + MenuItem (the tray
icon's right-click menu).

Two things this can't reach, called out in the theme file's own doc
comment: MessageBox.Show and Microsoft.Win32.OpenFileDialog are native
Win32 dialogs, not WPF windows - there's no supported way to skin
either one.

App-project-only change (all WPF XAML/resources), so it can only be
compile-verified on the Windows CI job; the 130-test cross-platform
baseline is unaffected.
The Windows build caught this (Linux only compiles the cross-platform
slnf, which excludes the WPF App project): the theme file's top
comment quoted CSS custom property names like --surface and
--border-c, and XML doesn't allow '--' inside a comment body. Reworded
to describe them without the literal token.
The theme mostly worked, but two things stayed white as shown in a
screenshot: the toolbar strips in both windows, and the OS title bar.

Toolbars: the unkeyed Style TargetType="Window" in ForgeTheme.xaml was
relying on implicit-style resolution walking up from WidgetDesignerWindow/
IntegrationsWindow (both Window subclasses) to that base-type style -
that didn't happen in practice, so the windows' own Background stayed
the WPF default (white) even though every individual control inside
them (Buttons, TextBoxes, ComboBoxes - all exactly type Button/TextBox/
ComboBox, no inheritance ambiguity) was already picking up its own
implicit style correctly. That's exactly why individual elements were
already dark/readable but the strips behind them weren't. Fixed by
setting Background/Foreground/FontFamily explicitly on each Window and
its root panel instead of trusting the inheritance to happen. Also
added an explicit ScrollViewer style and a Background on the
TabControl's content area for the same reason - anywhere a plain
WPF-default background could still show through.

Title bar: that's OS-drawn chrome, not WPF - no resource in
ForgeTheme.xaml could ever reach it. Added DarkTitleBar.Apply(), a
small DwmSetWindowAttribute P/Invoke (DWMWA_USE_IMMERSIVE_DARK_MODE),
called from both real windows' constructors and the rename dialog.

App-project-only change; 130-test cross-platform baseline unaffected.
The Windows build caught this: the analyzer flags an unused P/Invoke
return value since ignoring an HRESULT can hide a real failure. Here
it genuinely is fire-and-forget - an older Windows build without dark
title bar support just fails the call and the title bar stays light,
which needs no handling - so an explicit discard (_ = ...) is the
correct fix, not the value being unused by oversight.
… clipping

Save/Test & Save buttons: AccentButtonStyle no longer fills a solid
lime block - it now inherits the plain grey button template and only
overrides the text color to the same accent lime the brightness slider
uses, matching every other button's look/hover/pressed behavior.

Widget Designer toolbar: both rows were a horizontal StackPanel, which
never wraps - on a narrower window its overflow (in practice, the Save
button) silently extended past the visible window bounds with no way
to reach it, rather than actually being visible anywhere. Switched to
WrapPanel so extra buttons drop to a second line instead.

App-project-only change; 130-test cross-platform baseline unaffected.
Pi-hole v6 replaced the old stateless per-request API token with a
session login: POST /api/auth with an "app password" (a scoped
credential from Settings -> API -> App Passwords) returns a session id
(sid), sent as the X-FTL-SID header on later requests instead of a
query-string token. The stats endpoint moved too: GET /api/stats/summary
replaces /admin/api.php?summaryRaw, with queries.total/queries.blocked/
queries.percent_blocked instead of the old flat field names. The old v5
client just doesn't work against a v6 install - this wasn't a
connection issue on its own.

PiHoleClient: rewritten around LoginAsync (POST /api/auth, capture sid)
+ GetSummaryAsync (GET /api/stats/summary with the sid header) -
PiHoleSummary drops its Status field along with it, since that came
from a separate v6 endpoint (/api/dns/blocking) not worth a second
poll call for what was only ever a cosmetic confirmation message.

PiHolePumpService: restructured to log in once and reuse the session
(re-authenticating only when a poll fails, e.g. an expired session),
the same shape as ProxmoxPumpService/UniFiPumpService - previously it
rebuilt a stateless client every 10s poll, which would have meant a
fresh v6 login every 10s, wasteful and likely to trip v6's brute-force
login throttling.

Also split the Pi-hole tab's single free-text "Host URL" box into
separate Host / Port / "Use HTTPS" fields: a user configuring this hit
a bare "connection refused" trying the app's assumed default port
before realizing their Pi-hole was actually reachable on a different
port. An explicit Port field (defaulting to 80) makes that visible
instead of buried inside a URL string the user has to get exactly
right themselves.

132 tests passing (up from 130; +2 net new: a no-sid-in-response
failure case and a call-before-login-throws case). Cross-platform
build clean. The App project (IntegrationsWindow's new Host/Port/HTTPS
fields, PiHolePumpService) only builds on the Windows CI job.
Root cause of "Failed: No such host is known. (http:80)": the Host
field's own label says "no http:// or port", but typing a URL there is
the natural instinct (that's what URLs normally look like), and
BuildPiHoleBaseUrl() just concatenated "scheme://" + whatever was in
the box + ":port" with no validation - "http://192.168.1.42" in the
Host field plus the code's own "http://" produced
"http://http://192.168.1.42:8080". .NET's Uri parser reads that as
host "http" (stopping at the first ":"), and since nothing after it
parses as a valid port, falls back to that scheme's default port 80 -
hence a DNS failure on a host literally named "http", with no hint in
the error of what actually went wrong.

Added HostNormalizer (Ax206Display.DataSources.Http, cross-platform
and unit-tested) that strips an accidental scheme/port/path/query from
what's meant to be a bare hostname field, and wired it into both
BuildPiHoleBaseUrl and the pre-save validation - the Host box is also
now rewritten to the normalized value after Test & Save so the user
can see what was actually used.

142 tests passing (up from 132; +10 for HostNormalizer). Cross-platform
build clean. IntegrationsWindow.xaml.cs itself only builds on Windows.
…lliding-serial panels

ProvisionDefaultProfileAsync was called with no try/catch in StartAsync's
device loop; a transient USB failure there took the whole generic host
down instead of just skipping that one display - the direct cause of a
full-app crash seen in the field. Now logs a warning and skips the
device, in both StartAsync and RefreshDevicesAsync.

Also, DisambiguateDuplicateSerialNumbers only appended the disambiguating
"@location" suffix to a serial that collided within the same discovery
scan. If only one panel of a colliding pair re-enumerated after a drop
(e.g. mid-reconnect), it came back with its bare serial, which matched
neither supervisor's suffixed device ID and sat unmatched until the other
panel happened to enumerate in the same scan. Extracted the ambiguity
memory into a small testable AmbiguousSerialTracker that remembers a
colliding serial for the life of the discovery instance, so a lone
re-enumeration still gets disambiguated correctly.
…, add a gauge widget

Widget Designer's "Reading" dropdown now groups every data key under a
non-selectable category header (Local Device, Network, Pi-hole, UniFi,
Proxmox) instead of one long flat list, via a WPF grouped ComboBox
(ListCollectionView + PropertyGroupDescription).

Exposed several numeric fields each integration's API already returns
but never surfaced as a selectable reading:
- Pi-hole (/api/stats/summary, already polled): domains on the
  blocklist, cached/forwarded query counts, unique domains, and
  active/total client counts.
- UniFi: LAN and WLAN client counts split out from the combined total
  (the per-subsystem data was already being fetched).
- Proxmox: per-node CPU%, memory%, and uptime - GetNodeStatusesAsync
  was already called to discover nodes before listing guests, but its
  numbers were discarded. Added ProxmoxNodeKeys/ProxmoxNodeDirectory
  mirroring the existing guest-level pattern.

Added a new Gauge widget type: a compact 270-degree arc gauge with its
own configurable color (independent of text color), min/max range, and
the same font/label/unit/decimals controls as the stat widget. No
background fill, so it composites over the device's background image
like every other widget.
Converts the user-supplied 1024x1024 artwork (dark rounded square,
lime accent circle, photo-frame glyph) into a multi-resolution .ico
(16-256px) and wires it up via ApplicationIcon. This becomes both the
taskbar/exe icon and the tray icon, since TrayIconHostedService pulls
its icon from the running exe via Icon.ExtractAssociatedIcon.
The gauge widget's field count (Reading/Label/Unit/Decimals/Min/Max/
Gauge color/Text color/Font/Size) can exceed the fixed-height property
panel, especially on a shorter window - the bottom fields were clipped
with no way to reach them. Wrapped PropertyPanel in a ScrollViewer.

(The gauge's label already sits under the value and auto-shrinks by
measured text width via the shared WidgetTextRenderer - no change
needed there.)
…ow it

The value text and the label were both sized purely from the widget's
rectangular height, with no awareness of the circular arc drawn behind
them - on anything but a very generous box, the value's digits and the
label both ended up overlapping the ring's stroke.

Split the widget's height into two physically separate regions instead:
a circular gauge area (arc + value) on top, and - only when a label is
set - a footer strip entirely below it for the label. The two can never
overlap by construction, since the label is drawn outside the circle's
bounding box rather than into its bottom gap.

The value itself is now confined to a square safely inside the ring
(60% of its diameter, comfortably short of the ~70% an inscribed square
would allow) and always shrinks to fit both dimensions - including an
explicitly chosen size, via a new WidgetTextRenderer.alwaysShrinkToFitWidth
flag, since letting a big chosen value size bleed past its box here
means visibly crossing the ring around it.

Added a dedicated "Value text size" control to the gauge's property
panel, separate from the existing Font section's Size (which now sizes
only the label, since it lives in its own plain rectangular strip like
any other widget's text). Relabeled that shared control to "Label text
size" specifically for the gauge to keep the two from being confused.
…ropdown scroll

Added "Space Mono" to the font family list. Unlike the rest of the
list, it isn't a Windows system font, so it'll only render as Space
Mono on a machine that has it installed - SkiaSharp falls back
gracefully to the default font otherwise, same as any other missing
family name.

Added a "Label distance" control to the gauge: labelGapPx pushes the
label strip further from the ring (the ring's own area shrinks by the
same amount so everything still fits within Height), defaulting to 0
to match the existing layout exactly for anyone who hasn't touched it.

Fixed jumpy/rigid mouse-wheel scrolling in the Reading dropdown: the
custom ComboBox template in ForgeTheme.xaml doesn't bind its nested
ScrollViewer's CanContentScroll, so it was picking up the ComboBox
default style's inherited "True" (line-by-line/item scrolling) -
increasingly noticeable now that the grouped list has 20+ entries.
Set CanContentScroll=false and disabled virtualization (no benefit at
this list size, and one less way for grouping + scrolling to misbehave
together) on that specific dropdown instance.
"Space Mono" was added to the font family list, but resolved through
the same SKTypeface.FromFamilyName path as every other listed font -
those are all pre-installed Windows system fonts, but Space Mono isn't,
so on a machine without it separately installed, SkiaSharp silently
substituted the default typeface. Selecting it in the designer visibly
did nothing.

Bundled the actual font (Regular/Bold/Italic/BoldItalic, SIL Open Font
License 1.1, fonts/OFL.txt included) as embedded resources in
Ax206Display.Rendering and special-cased it in
WidgetTextRenderer.ResolveTypeface to load from there via
SKTypeface.FromStream instead, cached per (bold, italic) pair so it's
decoded once rather than on every frame. Now renders identically
regardless of what's installed on the host machine - verified by
rendering a sample to a PNG and confirming the actual Space Mono
letterforms.
Small position tweaks previously required a mouse drag, which is hard
to do precisely by exactly N pixels. Arrow keys now move the selected
widget: 1px plain, 5px with Shift, 10px with Ctrl, 20px with
Ctrl+Shift - four fixed step sizes reachable through the two modifier
keys this window wasn't already using for anything else.

Skipped while focus is on a TextBox, ComboBox, or the brightness
Slider, since those already use the arrow keys for their own purpose
(cursor movement, dropdown selection, value adjustment) - nudging would
otherwise fight normal editing in the property panel.

Reuses the same clamp-to-canvas-bounds logic as dragging, and updates
the overlay via its existing SyncPosition() so the selection outline
and position readout stay in sync exactly as they do during a drag.
Selecting a widget (by clicking it, or via the toolbar's Add buttons)
never moved keyboard focus anywhere - it stayed wherever it last was,
which in practice is almost always some ComboBox or TextBox left over
from editing a previous widget's properties. OnWindowPreviewKeyDown's
"don't hijack an editing control's arrow keys" check then matched
every time, silently refusing to nudge anything regardless of what was
actually selected on screen.

OverlayCanvas is now focusable and gets keyboard focus on every
selection, so arrow keys land on a neutral element instead of
whatever property control was last touched. FocusVisualStyle is
nulled out on it since the existing blue handle outline around the
selected widget is already the selection indicator - a dashed
rectangle around the entire canvas would just be visual noise.
Two panels sharing the same non-unique firmware serial only got their
"@location" disambiguation applied when both were seen colliding in the
same discovery scan, and that memory lived only in the in-process
AmbiguousSerialTracker. On a full app/PC restart, if just one panel of
the pair had finished USB enumeration by the very first scan (common at
boot, when hubs power up a beat apart), it came back with its bare,
undisambiguated serial. That bare ID matched neither saved
"@location"-suffixed profile, so the host mistook the panel for a brand
new display, auto-provisioned a fresh default (clock + CPU/RAM) layout,
and drove that to the physical screen - while the old profile sat
unchanged and unattached in config, still showing its previous content
in the Widget Designer.

Fix: seed the tracker at startup (and on every RefreshDevicesAsync)
from any "{serial}@{location}"-shaped profile ID already in the saved
config, so a lone panel is still correctly recognized as part of a
known-colliding pair even on the very first post-restart scan.
@BeardedTech0o
BeardedTech0o merged commit 4467714 into main Aug 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants