Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ 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
* Raspberry Pi (64x32) and Raspberry Pi Wide (128x64) connected to matrix LED panels
* 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)

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).
Expand Down
23 changes: 18 additions & 5 deletions internal/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/apps/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
97 changes: 71 additions & 26 deletions internal/data/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,30 @@ const (
DeviceTronbytS3Wide
DeviceMatrixPortal
DeviceMatrixPortalWS
DeviceMatrixPortalSquare
DeviceWaveshareS3
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",
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 {
Expand Down Expand Up @@ -82,18 +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,
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,
Expand All @@ -118,6 +124,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:
Expand All @@ -126,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:
Expand Down Expand Up @@ -675,9 +685,40 @@ 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, DeviceMatrixPortalSquare:
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:
case DeviceTidbytGen1, DeviceTidbytGen2, DevicePixoticker, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS, DeviceMatrixPortalSquare, DeviceWaveshareS3:
return true
default:
return false
Expand All @@ -687,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
Expand All @@ -713,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:
Expand All @@ -728,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:
Expand Down
71 changes: 71 additions & 0 deletions internal/data/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,74 @@ 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},
{"square s3", DeviceMatrixPortalSquare, 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)
})
}
}

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

// 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)
}
}
7 changes: 5 additions & 2 deletions internal/firmware/firmware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions internal/renderer/diag_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading