From 649105c343243690faa4da0615367803ffeaf0b0 Mon Sep 17 00:00:00 2001 From: NSLuke Date: Mon, 31 Aug 2026 17:22:16 -0400 Subject: [PATCH 1/5] feat: support square 64x64 panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `raspberrypi_square` device type for the 64x64 LED matrices, and makes the canvas size come from the device rather than being hardcoded. Rendering was fixed at 64x32 with a `Supports2x()` flag doubling both axes for the wide panels, so a square panel could not be expressed at all. `DeviceType` now answers two questions instead: `CanvasSize()` for the canvas an app is rendered into, and `DisplaySize()` for the physical panel. Pixlet already accepted arbitrary dimensions, so no renderer changes were needed — only passing the device's size through the five places that assumed 64x32 (app render, both schema paths, the schema handler, and the no-apps setup image). The setup image needed a real fix rather than a new size: it laid the QR and the address out in a row with the QR sized to the panel height, so on a square panel the QR took the full width and the address was left none. It now stacks them on any panel that is not wider than it is tall, which also lets a square panel carry a noticeably larger QR than a 64x32 can. On the web side the preview `` elements were pinned to a 2:1 aspect ratio, which squashed a square panel's image. The 2x detection in theme.js now also recognises a square image and tags the container, and TV mode takes its aspect ratio from the device type. `DeviceType` persists and serialises as its slug string, so the new value needs no migration. Verified against a local server with three devices (raspberrypi, raspberrypi_wide, raspberrypi_square) sharing one app: /next serves 64x32, 128x64 and 64x64 respectively, the setup QR renders at each panel's size, and the manager and TV previews keep each panel's shape. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- internal/data/models.go | 84 ++++++++++++------ internal/data/models_test.go | 40 +++++++++ internal/server/funcmap.go | 9 ++ internal/server/funcmap_test.go | 6 ++ internal/server/handlers_app.go | 9 +- internal/server/helpers.go | 1 + internal/server/render_utils.go | 4 +- internal/server/rotation.go | 5 +- internal/server/setup_image.go | 122 +++++++++++++++++++-------- internal/server/setup_image_test.go | 64 +++++++++++++- web/i18n/de.json | 3 + web/i18n/en.json | 3 + web/static/css/style.css | 8 ++ web/static/js/theme.js | 15 ++-- web/templates/manager/device_tv.html | 3 +- 16 files changed, 299 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 3b18430f..8cb15f30 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ However, there are some drawbacks, including the lack of a mobile app, slightly * Tidbyt Gen1 and Gen2 * Tronbyt S3 and S3 Wide * MatrixPortal S3 and MatrixPortal S3 Waveshare -* Raspberry Pi (64x32) and Raspberry Pi Wide (128x64) connected to matrix LED panels +* Raspberry Pi (64x32), Raspberry Pi Wide (128x64) and Raspberry Pi Square (64x64) connected to matrix LED panels * Pixoticker (limited memory, not recommended) Developing additional clients for Tronbyt Server is straightforward: pull WebP images from the `/next` endpoint and loop the animation for the duration specified in the `Tronbyt-Dwell-Secs` response header. Display brightness can optionally be set using the `Tronbyt-Brightness` header (0-100). diff --git a/internal/data/models.go b/internal/data/models.go index 557ae9a7..7238f753 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -39,22 +39,24 @@ const ( DevicePixoticker DeviceRaspberryPi DeviceRaspberryPiWide + DeviceRaspberryPiSquare DeviceOther ) var DeviceTypeToString = map[DeviceType]string{ - DeviceUnknown: "unknown", - DeviceTidbytGen1: "tidbyt_gen1", - DeviceTidbytGen2: "tidbyt_gen2", - DeviceTronbytS3: "tronbyt_s3", - DeviceTronbytS3Wide: "tronbyt_s3_wide", - DeviceMatrixPortal: "matrixportal_s3", - DeviceMatrixPortalWS: "matrixportal_s3_waveshare", - DeviceWaveshareS3: "waveshare_s3", - DevicePixoticker: "pixoticker", - DeviceRaspberryPi: "raspberrypi", - DeviceRaspberryPiWide: "raspberrypi_wide", - DeviceOther: "other", + DeviceUnknown: "unknown", + DeviceTidbytGen1: "tidbyt_gen1", + DeviceTidbytGen2: "tidbyt_gen2", + DeviceTronbytS3: "tronbyt_s3", + DeviceTronbytS3Wide: "tronbyt_s3_wide", + DeviceMatrixPortal: "matrixportal_s3", + DeviceMatrixPortalWS: "matrixportal_s3_waveshare", + DeviceWaveshareS3: "waveshare_s3", + DevicePixoticker: "pixoticker", + DeviceRaspberryPi: "raspberrypi", + DeviceRaspberryPiWide: "raspberrypi_wide", + DeviceRaspberryPiSquare: "raspberrypi_square", + DeviceOther: "other", } var StringToDeviceType = func() map[string]DeviceType { @@ -82,18 +84,19 @@ const ( // (UI level 0-5 -> brightness percent 0-100). Devices that do not have their own custom // scale fall back to their type's default. var DeviceTypeDefaultBrightnessScale = map[DeviceType]string{ - DeviceUnknown: S3BrightnessScale, - DeviceTidbytGen1: TidbytGen1BrightnessScale, - DeviceTidbytGen2: TidbytGen2BrightnessScale, - DeviceTronbytS3: S3BrightnessScale, - DeviceTronbytS3Wide: S3BrightnessScale, - DeviceMatrixPortal: S3BrightnessScale, - DeviceMatrixPortalWS: S3BrightnessScale, - DeviceWaveshareS3: S3BrightnessScale, - DevicePixoticker: S3BrightnessScale, - DeviceRaspberryPi: S3BrightnessScale, - DeviceRaspberryPiWide: S3BrightnessScale, - DeviceOther: S3BrightnessScale, + DeviceUnknown: S3BrightnessScale, + DeviceTidbytGen1: TidbytGen1BrightnessScale, + DeviceTidbytGen2: TidbytGen2BrightnessScale, + DeviceTronbytS3: S3BrightnessScale, + DeviceTronbytS3Wide: S3BrightnessScale, + DeviceMatrixPortal: S3BrightnessScale, + DeviceMatrixPortalWS: S3BrightnessScale, + DeviceWaveshareS3: S3BrightnessScale, + DevicePixoticker: S3BrightnessScale, + DeviceRaspberryPi: S3BrightnessScale, + DeviceRaspberryPiWide: S3BrightnessScale, + DeviceRaspberryPiSquare: S3BrightnessScale, + DeviceOther: S3BrightnessScale, } // DefaultBrightnessScale returns the default brightness scale string for the device type, @@ -118,6 +121,8 @@ func (dt DeviceType) String() string { return "Raspberry Pi" case DeviceRaspberryPiWide: return "Raspberry Pi Wide" + case DeviceRaspberryPiSquare: + return "Raspberry Pi Square" case DeviceTronbytS3: return "Tronbyt S3" case DeviceTronbytS3Wide: @@ -675,6 +680,37 @@ func (dt DeviceType) Supports2x() bool { } } +// DefaultCanvasWidth and DefaultCanvasHeight are the classic Tidbyt canvas +// dimensions, used by every device type that does not define its own and when +// rendering without a device (catalog previews, for example). +const ( + DefaultCanvasWidth = 64 + DefaultCanvasHeight = 32 +) + +// CanvasSize returns the logical canvas an app is rendered into for this device +// type, in app pixels. This is the size apps see through `canvas.size()`, before +// any 2x scaling — see Supports2x and DisplaySize. +func (dt DeviceType) CanvasSize() (width, height int) { + switch dt { + case DeviceRaspberryPiSquare: + return 64, 64 + default: + return DefaultCanvasWidth, DefaultCanvasHeight + } +} + +// DisplaySize returns the physical panel dimensions of this device type, in +// panel pixels: the canvas size with 2x scaling applied where the type uses it. +func (dt DeviceType) DisplaySize() (width, height int) { + width, height = dt.CanvasSize() + if dt.Supports2x() { + width *= 2 + height *= 2 + } + return width, height +} + func (dt DeviceType) SupportsFirmware() bool { switch dt { case DeviceTidbytGen1, DeviceTidbytGen2, DevicePixoticker, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceWaveshareS3: diff --git a/internal/data/models_test.go b/internal/data/models_test.go index 13298ad6..7554e353 100644 --- a/internal/data/models_test.go +++ b/internal/data/models_test.go @@ -105,3 +105,43 @@ func TestDeviceSupportsHTTPFirmwareCommands(t *testing.T) { assert.False(t, wsDevice.SupportsHTTPFirmwareCommands()) assert.False(t, otherDevice.SupportsHTTPFirmwareCommands()) } + +func TestDeviceTypeCanvasAndDisplaySize(t *testing.T) { + tests := []struct { + name string + deviceType DeviceType + canvasWidth, canvasHeight int + displayWidth, displayHeight int + }{ + {"classic", DeviceRaspberryPi, 64, 32, 64, 32}, + {"tidbyt", DeviceTidbytGen1, 64, 32, 64, 32}, + {"wide renders 2x", DeviceRaspberryPiWide, 64, 32, 128, 64}, + {"square", DeviceRaspberryPiSquare, 64, 64, 64, 64}, + {"unknown falls back", DeviceOther, 64, 32, 64, 32}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + width, height := tt.deviceType.CanvasSize() + assert.Equal(t, tt.canvasWidth, width) + assert.Equal(t, tt.canvasHeight, height) + + width, height = tt.deviceType.DisplaySize() + assert.Equal(t, tt.displayWidth, width) + assert.Equal(t, tt.displayHeight, height) + }) + } +} + +func TestDeviceTypeSquareRoundTripsAsSlug(t *testing.T) { + assert.Equal(t, "raspberrypi_square", DeviceRaspberryPiSquare.Slug()) + assert.Equal(t, DeviceRaspberryPiSquare, StringToDeviceType["raspberrypi_square"]) + + // Persistence and the API both go through the slug, so an unrecognised + // value must not silently become a square panel. + var scanned DeviceType + require.NoError(t, scanned.Scan("raspberrypi_square")) + assert.Equal(t, DeviceRaspberryPiSquare, scanned) + require.NoError(t, scanned.Scan("nonsense")) + assert.Equal(t, DeviceOther, scanned) +} diff --git a/internal/server/funcmap.go b/internal/server/funcmap.go index de316da9..5a640759 100644 --- a/internal/server/funcmap.go +++ b/internal/server/funcmap.go @@ -37,6 +37,7 @@ func getFuncMap() template.FuncMap { "contains": tmplContains, "webauthn_icon": tmplWebAuthnIcon, "installationID": tmplInstallationID, + "panelAspect": tmplPanelAspect, } } @@ -256,6 +257,14 @@ func tmplInstallationID(app data.App) string { return app.Iname } +// tmplPanelAspect renders a device type's panel proportions as a CSS +// `aspect-ratio` value, so previews are shaped like the panel they mirror +// instead of assuming every panel is 2:1. +func tmplPanelAspect(dt data.DeviceType) template.CSS { + width, height := dt.DisplaySize() + return template.CSS(fmt.Sprintf("%d / %d", width, height)) +} + func tmplWebAuthnIcon(authenticator string, dark bool) template.URL { aaguidBytes, err := hex.DecodeString(authenticator) if err != nil { diff --git a/internal/server/funcmap_test.go b/internal/server/funcmap_test.go index 31e346b1..b508f05d 100644 --- a/internal/server/funcmap_test.go +++ b/internal/server/funcmap_test.go @@ -119,3 +119,9 @@ func TestTmplContains(t *testing.T) { }) } } + +func TestTmplPanelAspect(t *testing.T) { + assert.Equal(t, "64 / 32", string(tmplPanelAspect(data.DeviceRaspberryPi))) + assert.Equal(t, "128 / 64", string(tmplPanelAspect(data.DeviceRaspberryPiWide))) + assert.Equal(t, "64 / 64", string(tmplPanelAspect(data.DeviceRaspberryPiSquare))) +} diff --git a/internal/server/handlers_app.go b/internal/server/handlers_app.go index 41850d40..cb107a1f 100644 --- a/internal/server/handlers_app.go +++ b/internal/server/handlers_app.go @@ -352,7 +352,8 @@ func (s *Server) handleConfigAppGet(w http.ResponseWriter, r *http.Request) { return } else { if !strings.HasSuffix(strings.ToLower(appPath), ".webp") { - schemaBytes, err = renderer.GetSchema(r.Context(), appPath, 64, 32, device.Type.Supports2x()) + schemaWidth, schemaHeight := device.Type.CanvasSize() + schemaBytes, err = renderer.GetSchema(r.Context(), appPath, schemaWidth, schemaHeight, device.Type.Supports2x()) if err != nil { slog.Error("Failed to get app schema", "error", err) // Fall through with empty schema @@ -405,8 +406,9 @@ func (s *Server) handleAppSchemaGet(w http.ResponseWriter, r *http.Request) { } if !strings.HasSuffix(strings.ToLower(appPath), ".webp") { + schemaWidth, schemaHeight := device.Type.CanvasSize() getSchema := func() error { - b, err := renderer.GetSchema(r.Context(), appPath, 64, 32, device.Type.Supports2x()) + b, err := renderer.GetSchema(r.Context(), appPath, schemaWidth, schemaHeight, device.Type.Supports2x()) if err != nil { return err } @@ -589,11 +591,12 @@ func (s *Server) handleSchemaHandler(w http.ResponseWriter, r *http.Request) { } // Call Handler + handlerWidth, handlerHeight := device.Type.CanvasSize() result, err := renderer.CallSchemaHandler( r.Context(), appPath, payload.Config, - 64, 32, + handlerWidth, handlerHeight, device.Type.Supports2x(), handler, payload.Param) diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 5e8663b1..0c2ec1cf 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -267,6 +267,7 @@ func (s *Server) getDeviceTypeChoices(localizer *i18n.Localizer) []DeviceTypeOpt data.DevicePixoticker, data.DeviceRaspberryPi, data.DeviceRaspberryPiWide, + data.DeviceRaspberryPiSquare, data.DeviceOther, } diff --git a/internal/server/render_utils.go b/internal/server/render_utils.go index c3134cf4..95237f99 100644 --- a/internal/server/render_utils.go +++ b/internal/server/render_utils.go @@ -38,6 +38,7 @@ func (s *Server) RenderApp(ctx context.Context, device *data.Device, app *data.A var deviceTimezone string var locale *string supports2x := false + width, height := data.DefaultCanvasWidth, data.DefaultCanvasHeight if device != nil { deviceTimezone = device.GetTimezone() @@ -46,6 +47,7 @@ func (s *Server) RenderApp(ctx context.Context, device *data.Device, app *data.A config["$tz"] = deviceTimezone locale = device.Locale supports2x = device.Type.Supports2x() + width, height = device.Type.CanvasSize() } // Dwell Time @@ -81,7 +83,7 @@ func (s *Server) RenderApp(ctx context.Context, device *data.Device, app *data.A ctx, appPath, config, - 64, 32, + width, height, time.Duration(appInterval)*time.Second, 30*time.Second, true, diff --git a/internal/server/rotation.go b/internal/server/rotation.go index 57e616fd..9962c07e 100644 --- a/internal/server/rotation.go +++ b/internal/server/rotation.go @@ -74,10 +74,7 @@ func (s *Server) GetNextAppImage(ctx context.Context, device *data.Device, user // Nothing installed yet: show where to install something rather than // a placeholder that gives no way to act on it. if baseURL != "" { - width, height := 64, 32 - if device.Type.Supports2x() { - width, height = 128, 64 - } + width, height := device.Type.DisplaySize() if img, err := renderSetupImage(ctx, width, height, baseURL); err == nil { return img, nil, nil } else { diff --git a/internal/server/setup_image.go b/internal/server/setup_image.go index 022260e3..ded4bf9a 100644 --- a/internal/server/setup_image.go +++ b/internal/server/setup_image.go @@ -36,33 +36,23 @@ var ( // show yet. Returns an error if baseURL cannot be turned into something worth // displaying, so callers can fall back to the placeholder. func renderSetupImage(ctx context.Context, width, height int, baseURL string) ([]byte, error) { + root, err := setupImageRoot(width, height, baseURL) + if err != nil { + return nil, err + } + + screens := encode.ScreensFromRoots([]render.Root{root}, width, height) + return screens.EncodeWebP(ctx, 15*time.Second) +} + +// setupImageRoot lays out the QR and the address for a panel of the given size. +func setupImageRoot(width, height int, baseURL string) (render.Root, error) { parsed, err := url.Parse(baseURL) if err != nil || parsed.Host == "" { - return nil, fmt.Errorf("setup image: unusable base URL %q", baseURL) + return render.Root{}, fmt.Errorf("setup image: unusable base URL %q", baseURL) } - children := []render.Widget{} - textWidth := width - - // The QR is optional: on a short panel a long address may not fit at even - // one module per pixel, and half a QR is worse than none. const gap = 2 - if qrImage, side, ok := setupQRImage(baseURL, height); ok { - encoded, err := encodePNG(qrImage) - if err != nil { - return nil, err - } - // HoldFrames must be at least 1: frameImg divides by it, and a - // zero value panics rather than defaulting. - qr := &render.Image{Src: encoded, Width: side, Height: side, HoldFrames: 1} - // Image decodes Src only when initialized; painting an uninitialized - // one panics rather than erroring. - if err := qr.InitFromImage(encoded); err != nil { - return nil, err - } - children = append(children, qr) - textWidth = width - side - gap - } // The address without its scheme: it is what someone types, and every // character costs pixels on a 64-wide panel. @@ -71,7 +61,7 @@ func renderSetupImage(ctx context.Context, width, height int, baseURL string) ([ Font: setupFont(width), Color: setupTextFg, Align: "center", - Width: textWidth, + Width: width, // An address has no spaces to wrap on, so it has to break mid-token // or it renders as one clipped line. WordBreak: true, @@ -79,27 +69,85 @@ func renderSetupImage(ctx context.Context, width, height int, baseURL string) ([ // nil thread is safe because Font is set explicitly; the thread is only // consulted to look up a default font. if err := address.Init(nil); err != nil { - return nil, err + return render.Root{}, err + } + + // A panel wider than it is tall has room for the address beside the QR. + // A square or portrait one does not — a QR sized to the full height would + // take the full width too and leave the address none — so it stacks them. + var child render.Widget + if width > height { + // The QR is optional: on a short panel a long address may not fit at + // even one module per pixel, and half a QR is worse than none. + qr, side, err := setupQRWidget(baseURL, height) + if err != nil { + return render.Root{}, err + } + children := []render.Widget{} + if qr != nil { + children = append(children, qr) + address.Width = width - side - gap + } + children = append(children, &render.Padding{ + Pad: render.Insets{Left: gap}, + Child: address, + }) + child = &render.Row{ + MainAlign: "start", + CrossAlign: "center", + Children: children, + } + } else { + // Whatever the address needs vertically is height the QR cannot have. + addressHeight := address.PaintBounds(image.Rect(0, 0, width, height), 0).Dy() + qr, _, err := setupQRWidget(baseURL, min(width, height-addressHeight-gap)) + if err != nil { + return render.Root{}, err + } + children := []render.Widget{} + if qr != nil { + children = append(children, qr) + } + children = append(children, &render.Padding{ + Pad: render.Insets{Top: gap}, + Child: address, + }) + child = &render.Column{ + MainAlign: "center", + CrossAlign: "center", + Children: children, + } } - children = append(children, &render.Padding{ - Pad: render.Insets{Left: gap}, - Child: address, - }) - root := render.Root{ + return render.Root{ Child: &render.Box{ Width: width, Height: height, - Child: &render.Row{ - MainAlign: "start", - CrossAlign: "center", - Children: children, - }, + Child: child, }, - } + }, nil +} - screens := encode.ScreensFromRoots([]render.Root{root}, width, height) - return screens.EncodeWebP(ctx, 15*time.Second) +// setupQRWidget builds the QR for content as a widget that fits maxSide pixels +// square, reporting a nil widget when it cannot be drawn legibly that small. +func setupQRWidget(content string, maxSide int) (render.Widget, int, error) { + img, side, ok := setupQRImage(content, maxSide) + if !ok { + return nil, 0, nil + } + encoded, err := encodePNG(img) + if err != nil { + return nil, 0, err + } + // HoldFrames must be at least 1: frameImg divides by it, and a + // zero value panics rather than defaulting. + qr := &render.Image{Src: encoded, Width: side, Height: side, HoldFrames: 1} + // Image decodes Src only when initialized; painting an uninitialized + // one panics rather than erroring. + if err := qr.InitFromImage(encoded); err != nil { + return nil, 0, err + } + return qr, side, nil } // setupFont picks the smallest legible face for the panel. tom-thumb is the diff --git a/internal/server/setup_image_test.go b/internal/server/setup_image_test.go index 97e1933f..6a02ca3a 100644 --- a/internal/server/setup_image_test.go +++ b/internal/server/setup_image_test.go @@ -11,6 +11,7 @@ import ( "tronbyt-server/web" "github.com/skip2/go-qrcode" + "github.com/tronbyt/pixlet/render" "gorm.io/gorm" ) @@ -66,8 +67,8 @@ func TestSetupQRImageDeclinesWhatCannotFit(t *testing.T) { } } -func TestRenderSetupImageDrawsBothPanelSizes(t *testing.T) { - for _, c := range []struct{ w, h int }{{64, 32}, {128, 64}} { +func TestRenderSetupImageDrawsEveryPanelSize(t *testing.T) { + for _, c := range []struct{ w, h int }{{64, 32}, {128, 64}, {64, 64}} { data, err := renderSetupImage(context.Background(), c.w, c.h, "http://192.168.1.155:8000") if err != nil { t.Fatalf("%dx%d: %v", c.w, c.h, err) @@ -126,6 +127,38 @@ func TestGetNextAppImageShowsSetupWhenThereAreNoApps(t *testing.T) { } } +// A panel wider than it is tall has room for the address beside the QR. +func TestSetupImageDrawsTheAddressBesideTheQROnAWidePanel(t *testing.T) { + for _, c := range []struct{ w, h int }{{64, 32}, {128, 64}} { + root, err := setupImageRoot(c.w, c.h, "http://192.168.1.155:8000") + if err != nil { + t.Fatalf("%dx%d: %v", c.w, c.h, err) + } + row, ok := setupImageLayout(t, root).(*render.Row) + if !ok { + t.Fatalf("%dx%d: expected the address beside the QR, got %T", + c.w, c.h, setupImageLayout(t, root)) + } + assertAddressHasWidth(t, row.Children) + } +} + +// A square panel has none: a QR sized to the full height is also the full +// width, which leaves the address nothing to wrap into. Getting this wrong is +// silent — the QR still draws and the address is simply squeezed off the +// panel — so this asserts the layout, not just that something rendered. +func TestSetupImageStacksTheAddressOnASquarePanel(t *testing.T) { + root, err := setupImageRoot(64, 64, "http://192.168.1.155:8000") + if err != nil { + t.Fatal(err) + } + column, ok := setupImageLayout(t, root).(*render.Column) + if !ok { + t.Fatalf("expected the address stacked under the QR, got %T", setupImageLayout(t, root)) + } + assertAddressHasWidth(t, column.Children) +} + func TestSetupFontFitsThePanel(t *testing.T) { if f := setupFont(64); !strings.Contains(f, "tom-thumb") { t.Errorf("64px panel should use the narrowest face, got %q", f) @@ -137,6 +170,33 @@ func TestSetupFontFitsThePanel(t *testing.T) { // helpers +// setupImageLayout unwraps the sizing Box every panel is drawn into, returning +// the widget that arranges the QR and the address. +func setupImageLayout(t *testing.T, root render.Root) render.Widget { + t.Helper() + box, ok := root.Child.(*render.Box) + if !ok { + t.Fatalf("expected the panel-sized Box, got %T", root.Child) + } + return box.Child +} + +func assertAddressHasWidth(t *testing.T, children []render.Widget) { + t.Helper() + for _, child := range children { + if padding, ok := child.(*render.Padding); ok { + child = padding.Child + } + if text, ok := child.(*render.WrappedText); ok { + if text.Width <= 0 { + t.Errorf("the address was left %d px to draw in", text.Width) + } + return + } + } + t.Error("no address was drawn") +} + func scaleOf(side, modules int) int { for quiet := 4; quiet >= 1; quiet-- { total := modules + 2*quiet diff --git a/web/i18n/de.json b/web/i18n/de.json index 481a1923..4641d3f8 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -479,6 +479,9 @@ "Raspberry Pi": { "other": "Raspberry Pi" }, + "Raspberry Pi Square": { + "other": "Raspberry Pi Square" + }, "Raspberry Pi Wide": { "other": "Raspberry Pi Wide" }, diff --git a/web/i18n/en.json b/web/i18n/en.json index 50eb08bb..9e06fb78 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -479,6 +479,9 @@ "Raspberry Pi": { "other": "Raspberry Pi" }, + "Raspberry Pi Square": { + "other": "Raspberry Pi Square" + }, "Raspberry Pi Wide": { "other": "Raspberry Pi Wide" }, diff --git a/web/static/css/style.css b/web/static/css/style.css index 34f9edb7..f52eba2a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -301,6 +301,14 @@ nav ul li a:hover { mask-image: url('/dots?w=128&h=64&r=0.4'); } +/* Square (64x64) panels. Without this the image is squashed into the 2:1 box + the classic panels use. */ +.app-img.is-square img { + aspect-ratio: 1 / 1; + -webkit-mask-image: url('/dots?w=64&h=64'); + mask-image: url('/dots?w=64&h=64'); +} + .skeleton-loader { position: absolute; inset: 0; diff --git a/web/static/js/theme.js b/web/static/js/theme.js index 7d3f43cf..03e23b7c 100644 --- a/web/static/js/theme.js +++ b/web/static/js/theme.js @@ -63,16 +63,21 @@ .catch(error => console.error('Error saving theme preference:', error)); } - function handle2xAppImages () { + function handleAppImageGeometry () { const APP_IMG_2X_WIDTH = 128; const processImage = (img) => { const applyClass = () => { - const is2x = img.naturalWidth === APP_IMG_2X_WIDTH; const container = img.closest('.app-img'); - if (container) { - container.classList.toggle('is-2x', is2x); + if (!container) { + return; } + const is2x = img.naturalWidth === APP_IMG_2X_WIDTH; + // Square panels need their own dot mask and aspect ratio; + // everything else is the classic 2:1 panel. + const isSquare = img.naturalWidth > 0 && img.naturalWidth === img.naturalHeight; + container.classList.toggle('is-2x', is2x); + container.classList.toggle('is-square', isSquare); }; if (img.complete && img.naturalWidth > 0) { applyClass(); @@ -191,7 +196,7 @@ setupThemeChangeHandler(themeSelect); setupThemeChangeHandler(mobileThemeSelect); - handle2xAppImages(); + handleAppImageGeometry(); } if (document.readyState === 'loading') { diff --git a/web/templates/manager/device_tv.html b/web/templates/manager/device_tv.html index d4e95e2c..0120f594 100644 --- a/web/templates/manager/device_tv.html +++ b/web/templates/manager/device_tv.html @@ -88,7 +88,6 @@ .app-card img { width: 100%; height: auto; - aspect-ratio: 64/32; object-fit: contain; image-rendering: pixelated; /* Ensures pixels stay sharp regardless of scaling */ @@ -122,7 +121,7 @@ padding: 10px"> From 7665abc693d498b0944f506b72d87891f2bdd3b3 Mon Sep 17 00:00:00 2001 From: NSLuke Date: Mon, 31 Aug 2026 17:29:48 -0400 Subject: [PATCH 2/5] test: use testify in the setup image tests AGENTS.md asks for `assert` and `require` in unit tests; the tests added in the previous commit used bare t.Fatal/t.Error. Also drops a shadow of the imported `data` package in the panel-size test. Co-Authored-By: Claude Opus 5 --- internal/server/setup_image_test.go | 49 ++++++++++------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/internal/server/setup_image_test.go b/internal/server/setup_image_test.go index 6a02ca3a..d8d7c5e6 100644 --- a/internal/server/setup_image_test.go +++ b/internal/server/setup_image_test.go @@ -11,6 +11,8 @@ import ( "tronbyt-server/web" "github.com/skip2/go-qrcode" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/tronbyt/pixlet/render" "gorm.io/gorm" ) @@ -69,16 +71,10 @@ func TestSetupQRImageDeclinesWhatCannotFit(t *testing.T) { func TestRenderSetupImageDrawsEveryPanelSize(t *testing.T) { for _, c := range []struct{ w, h int }{{64, 32}, {128, 64}, {64, 64}} { - data, err := renderSetupImage(context.Background(), c.w, c.h, "http://192.168.1.155:8000") - if err != nil { - t.Fatalf("%dx%d: %v", c.w, c.h, err) - } - if len(data) == 0 { - t.Fatalf("%dx%d: empty image", c.w, c.h) - } - if !bytes.HasPrefix(data, []byte("RIFF")) { - t.Errorf("%dx%d: not a WebP", c.w, c.h) - } + img, err := renderSetupImage(context.Background(), c.w, c.h, "http://192.168.1.155:8000") + require.NoErrorf(t, err, "%dx%d", c.w, c.h) + require.NotEmptyf(t, img, "%dx%d: empty image", c.w, c.h) + assert.Truef(t, bytes.HasPrefix(img, []byte("RIFF")), "%dx%d: not a WebP", c.w, c.h) } } @@ -131,14 +127,10 @@ func TestGetNextAppImageShowsSetupWhenThereAreNoApps(t *testing.T) { func TestSetupImageDrawsTheAddressBesideTheQROnAWidePanel(t *testing.T) { for _, c := range []struct{ w, h int }{{64, 32}, {128, 64}} { root, err := setupImageRoot(c.w, c.h, "http://192.168.1.155:8000") - if err != nil { - t.Fatalf("%dx%d: %v", c.w, c.h, err) - } - row, ok := setupImageLayout(t, root).(*render.Row) - if !ok { - t.Fatalf("%dx%d: expected the address beside the QR, got %T", - c.w, c.h, setupImageLayout(t, root)) - } + require.NoErrorf(t, err, "%dx%d", c.w, c.h) + layout := setupImageLayout(t, root) + row, ok := layout.(*render.Row) + require.Truef(t, ok, "%dx%d: expected the address beside the QR, got %T", c.w, c.h, layout) assertAddressHasWidth(t, row.Children) } } @@ -149,13 +141,10 @@ func TestSetupImageDrawsTheAddressBesideTheQROnAWidePanel(t *testing.T) { // panel — so this asserts the layout, not just that something rendered. func TestSetupImageStacksTheAddressOnASquarePanel(t *testing.T) { root, err := setupImageRoot(64, 64, "http://192.168.1.155:8000") - if err != nil { - t.Fatal(err) - } - column, ok := setupImageLayout(t, root).(*render.Column) - if !ok { - t.Fatalf("expected the address stacked under the QR, got %T", setupImageLayout(t, root)) - } + require.NoError(t, err) + layout := setupImageLayout(t, root) + column, ok := layout.(*render.Column) + require.Truef(t, ok, "expected the address stacked under the QR, got %T", layout) assertAddressHasWidth(t, column.Children) } @@ -175,9 +164,7 @@ func TestSetupFontFitsThePanel(t *testing.T) { func setupImageLayout(t *testing.T, root render.Root) render.Widget { t.Helper() box, ok := root.Child.(*render.Box) - if !ok { - t.Fatalf("expected the panel-sized Box, got %T", root.Child) - } + require.Truef(t, ok, "expected the panel-sized Box, got %T", root.Child) return box.Child } @@ -188,13 +175,11 @@ func assertAddressHasWidth(t *testing.T, children []render.Widget) { child = padding.Child } if text, ok := child.(*render.WrappedText); ok { - if text.Width <= 0 { - t.Errorf("the address was left %d px to draw in", text.Width) - } + assert.Greaterf(t, text.Width, 0, "the address was left %d px to draw in", text.Width) return } } - t.Error("no address was drawn") + assert.Fail(t, "no address was drawn") } func scaleOf(side, modules int) int { From 5212f8871c4615792d39f37568882b5f6e326dc8 Mon Sep 17 00:00:00 2001 From: NSLuke Date: Mon, 31 Aug 2026 17:52:19 -0400 Subject: [PATCH 3/5] feat: support a 64x64 panel on the MatrixPortal S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `matrixportal_s3_square` device type, rendering at 64x64 like the square Pi type. The Adafruit MatrixPortal S3 already wires the E address line (GPIO 21), so a 64x64 panel needs no change to the board itself, only firmware built for 64 rows. That target is tronbyt/firmware-esp32#157, which adds `matrixportal-s3-square`; this maps the device type onto its two release assets. Unlike the wide and waveshare variants, the square type points at its own merged image rather than reusing the base one, so an initial full flash lands 64x64 firmware instead of 64x32. Until that firmware release exists, generating firmware for the new type finds no binary. That case was already reachable — a release only carries binaries for the device types that existed when it was built, so any device type newer than a server's cached release hits it — and it answered with a 500 quoting an absolute server path. It now answers 404 with what to do about it, and the detail goes to the log instead. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- internal/data/models.go | 67 ++++++++++++++++++++--------------- internal/data/models_test.go | 31 ++++++++++++++++ internal/firmware/firmware.go | 7 ++-- internal/server/firmware.go | 27 +++++++++++--- internal/server/helpers.go | 1 + web/i18n/de.json | 3 ++ web/i18n/en.json | 3 ++ 8 files changed, 104 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8cb15f30..ff2e3653 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ However, there are some drawbacks, including the lack of a mobile app, slightly **Supported Devices:** * Tidbyt Gen1 and Gen2 * Tronbyt S3 and S3 Wide -* MatrixPortal S3 and MatrixPortal S3 Waveshare +* MatrixPortal S3, MatrixPortal S3 Waveshare and MatrixPortal S3 Square (64x64) * Raspberry Pi (64x32), Raspberry Pi Wide (128x64) and Raspberry Pi Square (64x64) connected to matrix LED panels * Pixoticker (limited memory, not recommended) diff --git a/internal/data/models.go b/internal/data/models.go index 7238f753..7606f8de 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -35,6 +35,7 @@ const ( DeviceTronbytS3Wide DeviceMatrixPortal DeviceMatrixPortalWS + DeviceMatrixPortalSquare DeviceWaveshareS3 DevicePixoticker DeviceRaspberryPi @@ -44,19 +45,20 @@ const ( ) var DeviceTypeToString = map[DeviceType]string{ - DeviceUnknown: "unknown", - DeviceTidbytGen1: "tidbyt_gen1", - DeviceTidbytGen2: "tidbyt_gen2", - DeviceTronbytS3: "tronbyt_s3", - DeviceTronbytS3Wide: "tronbyt_s3_wide", - DeviceMatrixPortal: "matrixportal_s3", - DeviceMatrixPortalWS: "matrixportal_s3_waveshare", - DeviceWaveshareS3: "waveshare_s3", - DevicePixoticker: "pixoticker", - DeviceRaspberryPi: "raspberrypi", - DeviceRaspberryPiWide: "raspberrypi_wide", - DeviceRaspberryPiSquare: "raspberrypi_square", - DeviceOther: "other", + DeviceUnknown: "unknown", + DeviceTidbytGen1: "tidbyt_gen1", + DeviceTidbytGen2: "tidbyt_gen2", + DeviceTronbytS3: "tronbyt_s3", + DeviceTronbytS3Wide: "tronbyt_s3_wide", + DeviceMatrixPortal: "matrixportal_s3", + DeviceMatrixPortalWS: "matrixportal_s3_waveshare", + DeviceMatrixPortalSquare: "matrixportal_s3_square", + DeviceWaveshareS3: "waveshare_s3", + DevicePixoticker: "pixoticker", + DeviceRaspberryPi: "raspberrypi", + DeviceRaspberryPiWide: "raspberrypi_wide", + DeviceRaspberryPiSquare: "raspberrypi_square", + DeviceOther: "other", } var StringToDeviceType = func() map[string]DeviceType { @@ -84,19 +86,20 @@ const ( // (UI level 0-5 -> brightness percent 0-100). Devices that do not have their own custom // scale fall back to their type's default. var DeviceTypeDefaultBrightnessScale = map[DeviceType]string{ - DeviceUnknown: S3BrightnessScale, - DeviceTidbytGen1: TidbytGen1BrightnessScale, - DeviceTidbytGen2: TidbytGen2BrightnessScale, - DeviceTronbytS3: S3BrightnessScale, - DeviceTronbytS3Wide: S3BrightnessScale, - DeviceMatrixPortal: S3BrightnessScale, - DeviceMatrixPortalWS: S3BrightnessScale, - DeviceWaveshareS3: S3BrightnessScale, - DevicePixoticker: S3BrightnessScale, - DeviceRaspberryPi: S3BrightnessScale, - DeviceRaspberryPiWide: S3BrightnessScale, - DeviceRaspberryPiSquare: S3BrightnessScale, - DeviceOther: S3BrightnessScale, + DeviceUnknown: S3BrightnessScale, + DeviceTidbytGen1: TidbytGen1BrightnessScale, + DeviceTidbytGen2: TidbytGen2BrightnessScale, + DeviceTronbytS3: S3BrightnessScale, + DeviceTronbytS3Wide: S3BrightnessScale, + DeviceMatrixPortal: S3BrightnessScale, + DeviceMatrixPortalWS: S3BrightnessScale, + DeviceMatrixPortalSquare: S3BrightnessScale, + DeviceWaveshareS3: S3BrightnessScale, + DevicePixoticker: S3BrightnessScale, + DeviceRaspberryPi: S3BrightnessScale, + DeviceRaspberryPiWide: S3BrightnessScale, + DeviceRaspberryPiSquare: S3BrightnessScale, + DeviceOther: S3BrightnessScale, } // DefaultBrightnessScale returns the default brightness scale string for the device type, @@ -131,6 +134,8 @@ func (dt DeviceType) String() string { return "MatrixPortal S3" case DeviceMatrixPortalWS: return "MatrixPortal S3 Waveshare" + case DeviceMatrixPortalSquare: + return "MatrixPortal S3 Square" case DeviceWaveshareS3: return "Waveshare S3" case DeviceOther: @@ -693,7 +698,7 @@ const ( // any 2x scaling — see Supports2x and DisplaySize. func (dt DeviceType) CanvasSize() (width, height int) { switch dt { - case DeviceRaspberryPiSquare: + case DeviceRaspberryPiSquare, DeviceMatrixPortalSquare: return 64, 64 default: return DefaultCanvasWidth, DefaultCanvasHeight @@ -713,7 +718,7 @@ func (dt DeviceType) DisplaySize() (width, height int) { func (dt DeviceType) SupportsFirmware() bool { switch dt { - case DeviceTidbytGen1, DeviceTidbytGen2, DevicePixoticker, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceWaveshareS3: + case DeviceTidbytGen1, DeviceTidbytGen2, DevicePixoticker, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceMatrixPortalSquare, DeviceWaveshareS3: return true default: return false @@ -723,7 +728,7 @@ func (dt DeviceType) SupportsFirmware() bool { func (dt DeviceType) SupportsOTA() bool { switch dt { // DevicePixoticker is intentionally omitted (not enough flash memory) - case DeviceTidbytGen1, DeviceTidbytGen2, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceWaveshareS3: + case DeviceTidbytGen1, DeviceTidbytGen2, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceMatrixPortalSquare, DeviceWaveshareS3: return true default: return false @@ -749,6 +754,8 @@ func (dt DeviceType) FirmwareFilename(swapColors bool) string { return "matrixportal-s3.bin" case DeviceMatrixPortalWS: return "matrixportal-s3-waveshare.bin" + case DeviceMatrixPortalSquare: + return "matrixportal-s3-square.bin" case DeviceWaveshareS3: return "waveshare-s3.bin" default: @@ -764,6 +771,8 @@ func (dt DeviceType) MergedFilename(swapColors bool) string { return "tronbyt-S3_merged.bin" case DeviceMatrixPortal, DeviceMatrixPortalWS: return "matrixportal-s3_merged.bin" + case DeviceMatrixPortalSquare: + return "matrixportal-s3-square_merged.bin" case DeviceWaveshareS3: return "waveshare-s3_merged.bin" default: diff --git a/internal/data/models_test.go b/internal/data/models_test.go index 7554e353..42e217b5 100644 --- a/internal/data/models_test.go +++ b/internal/data/models_test.go @@ -117,6 +117,7 @@ func TestDeviceTypeCanvasAndDisplaySize(t *testing.T) { {"tidbyt", DeviceTidbytGen1, 64, 32, 64, 32}, {"wide renders 2x", DeviceRaspberryPiWide, 64, 32, 128, 64}, {"square", DeviceRaspberryPiSquare, 64, 64, 64, 64}, + {"square s3", DeviceMatrixPortalSquare, 64, 64, 64, 64}, {"unknown falls back", DeviceOther, 64, 32, 64, 32}, } @@ -133,9 +134,26 @@ func TestDeviceTypeCanvasAndDisplaySize(t *testing.T) { } } +// The square MatrixPortal is a firmware device, so it needs its own binaries +// rather than silently inheriting the 64x32 ones: flashing those would light +// the panel at the wrong geometry. +func TestDeviceTypeSquareMatrixPortalHasItsOwnFirmware(t *testing.T) { + assert.True(t, DeviceMatrixPortalSquare.SupportsFirmware()) + assert.True(t, DeviceMatrixPortalSquare.SupportsOTA()) + + firmware := DeviceMatrixPortalSquare.FirmwareFilename(false) + merged := DeviceMatrixPortalSquare.MergedFilename(false) + assert.Equal(t, "matrixportal-s3-square.bin", firmware) + assert.Equal(t, "matrixportal-s3-square_merged.bin", merged) + assert.NotEqual(t, DeviceMatrixPortal.FirmwareFilename(false), firmware) + assert.NotEqual(t, DeviceMatrixPortal.MergedFilename(false), merged) +} + func TestDeviceTypeSquareRoundTripsAsSlug(t *testing.T) { assert.Equal(t, "raspberrypi_square", DeviceRaspberryPiSquare.Slug()) assert.Equal(t, DeviceRaspberryPiSquare, StringToDeviceType["raspberrypi_square"]) + assert.Equal(t, "matrixportal_s3_square", DeviceMatrixPortalSquare.Slug()) + assert.Equal(t, DeviceMatrixPortalSquare, StringToDeviceType["matrixportal_s3_square"]) // Persistence and the API both go through the slug, so an unrecognised // value must not silently become a square panel. @@ -145,3 +163,16 @@ func TestDeviceTypeSquareRoundTripsAsSlug(t *testing.T) { require.NoError(t, scanned.Scan("nonsense")) assert.Equal(t, DeviceOther, scanned) } + +// A device type that offers firmware but names no binary would fail only at +// the point someone tries to flash it, so check the pairing directly. Merged +// images are deliberately not required: Pixoticker ships OTA-only. +func TestEveryFirmwareDeviceTypeNamesABinary(t *testing.T) { + for deviceType, slug := range DeviceTypeToString { + if !deviceType.SupportsFirmware() { + continue + } + assert.NotEmptyf(t, deviceType.FirmwareFilename(false), + "%s claims firmware support but names no binary", slug) + } +} diff --git a/internal/firmware/firmware.go b/internal/firmware/firmware.go index 5abdcb0c..f1909f3b 100644 --- a/internal/firmware/firmware.go +++ b/internal/firmware/firmware.go @@ -50,7 +50,10 @@ func Generate(firmwareDir string, deviceType data.DeviceType, ssid, password, ur content, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("firmware file not found: %s", path) + // Wrapped so callers can tell "this release has no binary for this + // device type" (fs.ErrNotExist) from a genuine read failure. Named by + // file rather than by path: the absolute path is server-side detail. + return nil, fmt.Errorf("reading firmware %s: %w", filename, err) } if len(content) < 33 { @@ -129,7 +132,7 @@ func GenerateMerged(firmwareDir string, deviceType data.DeviceType, ssid, passwo mergedContent, err := os.ReadFile(mergedPath) if err != nil { - return nil, fmt.Errorf("merged firmware file not found: %s. Please refresh firmware binaries on the Admin page", mergedPath) + return nil, fmt.Errorf("reading merged firmware %s: %w", mergedFilename, err) } if len(mergedContent) < MergedAppOffset { diff --git a/internal/server/firmware.go b/internal/server/firmware.go index 19fe85db..accff829 100644 --- a/internal/server/firmware.go +++ b/internal/server/firmware.go @@ -2,8 +2,10 @@ package server import ( "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" "net/url" "os" @@ -166,13 +168,15 @@ func (s *Server) UpdateFirmwareBinaries(maxReleases int) error { "tronbyt-s3_firmware.bin": "tronbyt-S3.bin", "tronbyt-s3-wide_firmware.bin": "tronbyt-s3-wide.bin", "matrixportal-s3_firmware.bin": "matrixportal-s3.bin", + "matrixportal-s3-square_firmware.bin": "matrixportal-s3-square.bin", "matrixportal-s3-waveshare_firmware.bin": "matrixportal-s3-waveshare.bin", "waveshare-s3_firmware.bin": "waveshare-s3.bin", // Merged binaries (bootloader + partition + app, flashable at 0x0) - "tidbyt-gen1_merged.bin": "tidbyt-gen1_merged.bin", - "tronbyt-s3_merged.bin": "tronbyt-S3_merged.bin", - "matrixportal-s3_merged.bin": "matrixportal-s3_merged.bin", - "waveshare-s3_merged.bin": "waveshare-s3_merged.bin", + "tidbyt-gen1_merged.bin": "tidbyt-gen1_merged.bin", + "tronbyt-s3_merged.bin": "tronbyt-S3_merged.bin", + "matrixportal-s3_merged.bin": "matrixportal-s3_merged.bin", + "matrixportal-s3-square_merged.bin": "matrixportal-s3-square_merged.bin", + "waveshare-s3_merged.bin": "waveshare-s3_merged.bin", } for i, release := range releases { @@ -381,7 +385,20 @@ func (s *Server) handleFirmwareGeneratePost(w http.ResponseWriter, r *http.Reque } if err != nil { - http.Error(w, fmt.Sprintf("Failed to generate firmware: %v", err), http.StatusInternalServerError) + // A release only carries binaries for the device types that existed + // when it was built, so a newer device type on an older cached release + // has nothing to flash. That is a missing asset, not a server fault. + if errors.Is(err, fs.ErrNotExist) { + slog.Warn("No firmware binary for device type", + "device_type", device.Type.Slug(), "version", version, "error", err) + http.Error(w, fmt.Sprintf( + "No %s firmware in this release. Pick another version, or refresh the firmware binaries from the Admin page.", + device.Type.String()), http.StatusNotFound) + return + } + slog.Error("Failed to generate firmware", + "device_type", device.Type.Slug(), "version", version, "error", err) + http.Error(w, "Failed to generate firmware", http.StatusInternalServerError) return } diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 0c2ec1cf..75663cd6 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -263,6 +263,7 @@ func (s *Server) getDeviceTypeChoices(localizer *i18n.Localizer) []DeviceTypeOpt data.DeviceTronbytS3Wide, data.DeviceMatrixPortal, data.DeviceMatrixPortalWS, + data.DeviceMatrixPortalSquare, data.DeviceWaveshareS3, data.DevicePixoticker, data.DeviceRaspberryPi, diff --git a/web/i18n/de.json b/web/i18n/de.json index 4641d3f8..52e8a915 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -494,6 +494,9 @@ "MatrixPortal S3": { "other": "MatrixPortal S3" }, + "MatrixPortal S3 Square": { + "other": "MatrixPortal S3 Square" + }, "MatrixPortal S3 Waveshare": { "other": "MatrixPortal S3 Waveshare" }, diff --git a/web/i18n/en.json b/web/i18n/en.json index 9e06fb78..e0f78919 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -494,6 +494,9 @@ "MatrixPortal S3": { "other": "MatrixPortal S3" }, + "MatrixPortal S3 Square": { + "other": "MatrixPortal S3 Square" + }, "MatrixPortal S3 Waveshare": { "other": "MatrixPortal S3 Waveshare" }, From dc203bf7c9e48071ecd3bf36ab811a6250dc9ef6 Mon Sep 17 00:00:00 2001 From: NSLuke Date: Tue, 1 Sep 2026 15:14:59 -0400 Subject: [PATCH 4/5] feat: group the device type picker by panel size The picker was a flat list, which was fine while every device drove a 64x32 panel and the choice only affected a label. It no longer is: with 128x64 and 64x64 types in the same list, the choice decides what canvas apps render into, and nothing in the list said so. Device types are now grouped into s by the panel they drive - 64x32, then 128x64, then 64x64 - so the sections are ordered the way someone would reach for them rather than by pixel count, which would put 64x64 in the middle. The group label is generated from DisplaySize(), so it cannot drift from the size a type actually reports and needs no translation. Types whose size is not in the ordered list are appended rather than dropped, and empty groups are omitted. "Other" groups under 64x32 because that is what the server renders for it. The list feeding this is hand-maintained, so a device type added later can silently never appear in the picker. A test now asserts every known type is offered exactly once and sits under the size it actually drives. Co-Authored-By: Claude Opus 5 --- internal/server/helpers.go | 52 +++++++++++++++++++++++++++---- internal/server/helpers_test.go | 41 ++++++++++++++++++++++++ web/templates/manager/create.html | 12 ++++--- web/templates/manager/update.html | 12 ++++--- 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 75663cd6..b812389b 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -48,6 +48,14 @@ type DeviceTypeOption struct { Label string } +// DeviceTypeGroup collects the device types that drive the same size of panel. +// The picker separates them because the choice is not cosmetic: apps are drawn +// for a given canvas, so a 64x64 device is not a drop-in for a 64x32 one. +type DeviceTypeGroup struct { + Label string + Options []DeviceTypeOption +} + // DeviceSummary is a lightweight struct for "Copy to" dropdown targets. type DeviceSummary struct { ID string @@ -72,7 +80,7 @@ type TemplateData struct { Device *data.Device SystemApps []apps.AppMetadata CustomApps []apps.AppMetadata - DeviceTypeChoices []DeviceTypeOption + DeviceTypeChoices []DeviceTypeGroup Form CreateDeviceFormData // Repo Info for Admin/User Settings @@ -254,8 +262,15 @@ func (s *Server) getLocalizer(r *http.Request) *i18n.Localizer { return i18n.NewLocalizer(s.Bundle, accept, language.English.String()) } -// getDeviceTypeChoices returns a slice of device type options with display names. -func (s *Server) getDeviceTypeChoices(localizer *i18n.Localizer) []DeviceTypeOption { +// deviceTypeGroupOrder is the order the panel sizes appear in the picker: +// the classic panel first, then the wide one, then the square one. Ordering by +// pixel count would put 64x64 in the middle, which reads as arbitrary. +var deviceTypeGroupOrder = [][2]int{{64, 32}, {128, 64}, {64, 64}} + +// getDeviceTypeChoices returns the device types grouped by panel size, in +// deviceTypeGroupOrder. Any size not named there is appended, so a device type +// added later shows up in the picker even if this list was not updated. +func (s *Server) getDeviceTypeChoices(localizer *i18n.Localizer) []DeviceTypeGroup { allDeviceTypes := []data.DeviceType{ data.DeviceTidbytGen1, data.DeviceTidbytGen2, @@ -272,14 +287,39 @@ func (s *Server) getDeviceTypeChoices(localizer *i18n.Localizer) []DeviceTypeOpt data.DeviceOther, } - choices := make([]DeviceTypeOption, 0, len(allDeviceTypes)) + // Group label doubles as the key. It is the panel size, so it needs no + // translation and cannot drift from the sizes the types actually report. + groups := make([]DeviceTypeGroup, 0, len(deviceTypeGroupOrder)) + index := make(map[string]int, len(deviceTypeGroupOrder)) + for _, size := range deviceTypeGroupOrder { + label := fmt.Sprintf("%dx%d", size[0], size[1]) + index[label] = len(groups) + groups = append(groups, DeviceTypeGroup{Label: label}) + } + for _, dt := range allDeviceTypes { - choices = append(choices, DeviceTypeOption{ + width, height := dt.DisplaySize() + label := fmt.Sprintf("%dx%d", width, height) + at, ok := index[label] + if !ok { + at = len(groups) + index[label] = at + groups = append(groups, DeviceTypeGroup{Label: label}) + } + groups[at].Options = append(groups[at].Options, DeviceTypeOption{ Value: dt, Label: s.localizeOrID(localizer, dt.String()), }) } - return choices + + // A size with no device types would render as an empty labelled section. + populated := groups[:0] + for _, g := range groups { + if len(g.Options) > 0 { + populated = append(populated, g) + } + } + return populated } func (s *Server) getShowFullAnimationChoices() []ShowFullAnimationOption { diff --git a/internal/server/helpers_test.go b/internal/server/helpers_test.go index fef7dead..a839c2b8 100644 --- a/internal/server/helpers_test.go +++ b/internal/server/helpers_test.go @@ -1,9 +1,16 @@ package server import ( + "fmt" "net/http" "net/url" "testing" + + "tronbyt-server/internal/data" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" ) func TestParseTimeInput(t *testing.T) { @@ -87,3 +94,37 @@ func TestDetectedAccessURLNonLoopback(t *testing.T) { t.Errorf("detectedAccessURL(non-loopback) = %q, want empty", got) } } + +// The picker is built from a hand-maintained list, so a device type added later +// can silently never appear in it. Every type the server knows must be offered +// exactly once, and grouped under the panel size it actually drives. +func TestDeviceTypeChoicesOfferEveryTypeGroupedByPanelSize(t *testing.T) { + s := &Server{} + localizer := i18n.NewLocalizer(i18n.NewBundle(language.English), "en") + + groups := s.getDeviceTypeChoices(localizer) + + labels := make([]string, 0, len(groups)) + seen := make(map[data.DeviceType]int) + for _, group := range groups { + labels = append(labels, group.Label) + assert.NotEmptyf(t, group.Options, "group %q is empty", group.Label) + for _, option := range group.Options { + seen[option.Value]++ + width, height := option.Value.DisplaySize() + assert.Equalf(t, group.Label, fmt.Sprintf("%dx%d", width, height), + "%s is grouped under %q but drives a %dx%d panel", + option.Value.Slug(), group.Label, width, height) + } + } + + assert.Equal(t, []string{"64x32", "128x64", "64x64"}, labels) + + for deviceType, slug := range data.DeviceTypeToString { + if deviceType == data.DeviceUnknown { + continue // deliberately not offered; it is the zero value + } + assert.Equalf(t, 1, seen[deviceType], "%s is offered %d times, want exactly 1", + slug, seen[deviceType]) + } +} diff --git a/web/templates/manager/create.html b/web/templates/manager/create.html index 0f6a0258..19b4b791 100644 --- a/web/templates/manager/create.html +++ b/web/templates/manager/create.html @@ -42,10 +42,14 @@

{{ t .Localizer "Basic Information" }}

diff --git a/web/templates/manager/update.html b/web/templates/manager/update.html index a08440ff..d3f69ecb 100644 --- a/web/templates/manager/update.html +++ b/web/templates/manager/update.html @@ -128,10 +128,14 @@

{{ t .Localizer "Core Device Setup" }}

From 8437a364d2bc623721e29cc25c712e1c9468ab6e Mon Sep 17 00:00:00 2001 From: NSLuke Date: Wed, 2 Sep 2026 11:20:43 -0400 Subject: [PATCH 5/5] feat: supports64x64 manifest flag and square previews Mirrors supports2x, which is how this project already lets an app say it renders differently on a wider panel. A square panel is a different shape rather than a bigger one, so it needs its own assertion: supports64x64 in manifest.yaml, a @64x64 preview file beside the app, and a badge in the catalogue next to the 2x one. One deliberate difference from supports2x. The 2x path ALSO sets the capability from the mere presence of a @2x file, which over the current apps corpus marks 53 apps as 2x-capable on the evidence of a committed screenshot alone, and 10 more declare the key with no such file. A screenshot proves a preview exists, not that anyone looked at the app on that panel - so supports64x64 is read only from the manifest, and the @64x64 file only supplies a preview. The thumbnail endpoint takes ?square=1 and prefers the square preview when one exists, falling back to 2x and then the plain preview, so callers that know the device shape get the right image and everyone else is unaffected. Also fixes square previews being clipped in the web UI. .app-img carries aspect-ratio 2/1 with overflow:hidden, so styling only the left a square render overflowing a 2:1 box and cut off. The container now takes the aspect too, in the catalogue, the device page and the fixed 256x128 installed-app slots. Verified in a browser against a live server: square previews now measure 130x130 and 240x240 against their 64x64 source, while 64x32 and 128x64 devices are unchanged at 240x121 and 258x130. Co-Authored-By: Claude Opus 5 --- internal/apps/apps.go | 23 +++++++++--- internal/apps/apps_test.go | 45 +++++++++++++++++++++++ internal/renderer/diag_test.go | 29 +++++++++++++++ internal/server/handlers_app.go | 9 ++++- web/static/css/addapp-simple.css | 21 +++++++++++ web/static/css/manager.css | 10 +++++ web/static/css/style.css | 4 ++ web/templates/partials/app_list_grid.html | 3 ++ 8 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 internal/renderer/diag_test.go diff --git a/internal/apps/apps.go b/internal/apps/apps.go index 3e8403d8..6dbabe23 100644 --- a/internal/apps/apps.go +++ b/internal/apps/apps.go @@ -22,6 +22,7 @@ type Manifest struct { PackageName string `yaml:"packageName"` RecommendedInterval int `yaml:"recommendedInterval"` Supports2x bool `yaml:"supports2x"` + Supports64x64 bool `yaml:"supports64x64"` Broken bool `yaml:"broken"` BrokenReason string `yaml:"brokenReason"` Category string `yaml:"category"` @@ -34,11 +35,12 @@ type AppMetadata struct { Manifest // Fields populated by logic - Path string - IsInstalled bool - Date string - Preview string - Preview2x string + Path string + IsInstalled bool + Date string + Preview string + Preview2x string + PreviewSquare string } func ListSystemApps(dataDir string) ([]AppMetadata, error) { @@ -117,6 +119,17 @@ func ListSystemApps(dataDir string) ([]AppMetadata, error) { apps[i].Preview2x = filepath.Join(dirName, fname2x) apps[i].Supports2x = true } + + // Check square. Unlike @2x this does NOT set the + // capability flag: a screenshot is evidence that a + // preview exists, not that the author checked the app + // on a square panel. supports64x64 stays a manifest + // assertion. + fnameSq := base + "@64x64" + ext + fpathSq := filepath.Join(appDir, fnameSq) + if _, err := os.Stat(fpathSq); err == nil { + apps[i].PreviewSquare = filepath.Join(dirName, fnameSq) + } found = true break diff --git a/internal/apps/apps_test.go b/internal/apps/apps_test.go index efffd64e..e4083a0f 100644 --- a/internal/apps/apps_test.go +++ b/internal/apps/apps_test.go @@ -57,3 +57,48 @@ func TestListUserApps(t *testing.T) { t.Errorf("Did not find both apps: foundApp1=%v, foundApp2=%v", foundApp1, foundApp2) } } + +// supports64x64 is an assertion by the app author that the app lays out on a +// square panel. Unlike supports2x it is deliberately NOT inferred from a +// screenshot file: a committed image proves a preview exists, not that anyone +// checked the app on a square panel. +func TestSupports64x64ComesFromTheManifestNotAScreenshot(t *testing.T) { + dir := t.TempDir() + appsDir := filepath.Join(dir, "system-apps", "apps", "squareapp") + if err := os.MkdirAll(appsDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := "id: squareapp\nname: Square App\nfileName: squareapp.star\npackageName: squareapp\nsupports64x64: true\n" + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(appsDir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("manifest.yaml", manifest) + write("squareapp.star", "") + write("screenshot.webp", "") + write("screenshot@64x64.webp", "") + + found, err := ListSystemApps(dir) + if err != nil { + t.Fatal(err) + } + var app *AppMetadata + for i := range found { + if found[i].ID == "squareapp" { + app = &found[i] + } + } + if app == nil { + t.Fatal("squareapp was not scanned") + } + if !app.Supports64x64 { + t.Error("supports64x64 in the manifest was not read") + } + if app.PreviewSquare == "" { + t.Error("a @64x64 preview file beside the app was not picked up") + } + if app.Supports2x { + t.Error("supports2x must not be set: no @2x file and no manifest key") + } +} diff --git a/internal/renderer/diag_test.go b/internal/renderer/diag_test.go new file mode 100644 index 00000000..8f5f8443 --- /dev/null +++ b/internal/renderer/diag_test.go @@ -0,0 +1,29 @@ +//go:build diagnostic + +package renderer + +import ( + "context" + "fmt" + "testing" + "time" +) + +// Run with: go test -tags diagnostic ./internal/renderer/ -run TestDiag -v +// Diagnostic helper for the OAuth2-injection investigation. Not part of CI. +func TestDiagOAuthRender(t *testing.T) { + path := "/tmp/tronbyt-test/users/admin/apps/oauth-test/oauth-test.star" + cfg := map[string]any{"strava": "TEST-TOKEN-1234567890"} + tz := "America/New_York" + _, msgs, err := Render( + context.Background(), path, cfg, 64, 32, + 15*time.Second, 30*time.Second, false, false, + &tz, nil, nil, nil, + ) + for _, m := range msgs { + fmt.Println("msg:", m) + } + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/server/handlers_app.go b/internal/server/handlers_app.go index cb107a1f..745ad4fb 100644 --- a/internal/server/handlers_app.go +++ b/internal/server/handlers_app.go @@ -304,10 +304,15 @@ func (s *Server) handleAppThumbnail(w http.ResponseWriter, r *http.Request) { // 3. Determine file to serve // Pick the best preview from metadata. + // A square panel is a different shape, not a bigger one, so a square + // preview wins over the 2x preview when the caller asks for one. var file string - if appMeta.Supports2x && appMeta.Preview2x != "" { + switch { + case r.URL.Query().Get("square") == "1" && appMeta.PreviewSquare != "": + file = appMeta.PreviewSquare + case appMeta.Supports2x && appMeta.Preview2x != "": file = appMeta.Preview2x - } else { + default: file = appMeta.Preview } diff --git a/web/static/css/addapp-simple.css b/web/static/css/addapp-simple.css index eab6f0c5..9a93bc3c 100644 --- a/web/static/css/addapp-simple.css +++ b/web/static/css/addapp-simple.css @@ -178,6 +178,18 @@ align-items: center; } +/* Square panels. The CONTAINER carries the aspect too: sizing only the + image leaves it overflowing a 2:1 box with overflow:hidden, which is + what clipped square previews into a cut-off rectangle. */ +.app-img.is-square { + aspect-ratio: 1 / 1; +} + +.app-img.is-square .lazy-image { + width: 128px; + height: 128px; +} + .app-img .lazy-image { position: relative; width: 128px; @@ -195,6 +207,7 @@ .installed-badge, .supports-2x-badge, +.supports-64x64-badge, .broken-badge { position: absolute; top: 10px; @@ -218,6 +231,14 @@ color: var(--page-text); } +/* Sits under the 2x badge: an app can legitimately declare both. */ +.supports-64x64-badge { + left: 10px; + top: 38px; + background: color-mix(in srgb, var(--surface-bg) 84%, #3f9d6b 16%); + color: var(--page-text); +} + .broken-badge { position: static; align-self: flex-start; diff --git a/web/static/css/manager.css b/web/static/css/manager.css index 1927c35c..ab5adf81 100644 --- a/web/static/css/manager.css +++ b/web/static/css/manager.css @@ -1227,3 +1227,13 @@ button.action.w3-button { .custom-select option { color: black; } + +/* Square panels. The preview slots above are 256x128 by construction, which + letterboxes a square render into half the slot. Match the panel instead. */ +.app-preview .app-img.is-square, +.app-preview .app-img.is-square img, +.apps-grid-view .app-preview .app-img.is-square, +.apps-grid-view .app-preview .app-img.is-square img { + width: 128px; + height: 128px; +} diff --git a/web/static/css/style.css b/web/static/css/style.css index f52eba2a..1288290f 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -303,6 +303,10 @@ nav ul li a:hover { /* Square (64x64) panels. Without this the image is squashed into the 2:1 box the classic panels use. */ +.app-img.is-square { + aspect-ratio: 1 / 1; +} + .app-img.is-square img { aspect-ratio: 1 / 1; -webkit-mask-image: url('/dots?w=64&h=64'); diff --git a/web/templates/partials/app_list_grid.html b/web/templates/partials/app_list_grid.html index 126e9300..ca2c3fca 100644 --- a/web/templates/partials/app_list_grid.html +++ b/web/templates/partials/app_list_grid.html @@ -82,6 +82,9 @@

{{ .Title }}

{{ if .App.Supports2x }}
2x
{{ end }} + {{ if .App.Supports64x64 }} +
64×64
+ {{ end }}