feat: implement OTA updates UI and API

Implements OTA updates via WebSocket and HTTP headers.
Adds SwapColors support for Tidbyt Gen 1.
Adds /static/firmware/ route to serve binaries.
Refactors firmware logic to use data.DeviceType.
Adds translation strings for EN and DE.
Conditionally shows firmware update UI.

Fixes #600
This commit is contained in:
Ingmar Stein
2025-12-18 22:17:05 +01:00
parent ddc8d591ae
commit e89d8af366
15 changed files with 265 additions and 41 deletions

View File

@@ -30,6 +30,7 @@ The entire backend has been rewritten in Go (1.25+). This change offers:
* `reset-password`: Manually reset a user's password.
* `health`: Perform health checks against the running server.
* **Passkey Authentication:** Added support for passkey authentication (requires HTTPS on some browsers) for more secure and convenient logins.
* **Over-The-Air (OTA) Updates:** Devices running compatible firmware can now be updated directly from the web interface. Updates are delivered via WebSocket commands or HTTP headers, streamlining the firmware management process.
* **App Configuration Export/Import:** Users can now export app configurations to a JSON file and import them back into existing app installations. This makes it easy to backup configurations or replicate complex setups across different apps.
* **ZIP-packaged App Support:** Users can now upload and run apps packaged as ZIP files. This enables more complex apps that split logic across multiple files, reference external assets like images, and include metadata via manifest files.

View File

@@ -501,6 +501,10 @@ type Device struct {
ColorFilter *ColorFilter `json:"color_filter"`
NightColorFilter *ColorFilter `json:"night_color_filter"`
// OTA
SwapColors bool `json:"swap_colors"`
PendingUpdateURL string `json:"pending_update_url,omitempty"`
Apps []App `gorm:"foreignKey:DeviceID;references:ID" json:"apps"`
}
@@ -512,6 +516,7 @@ func (dt DeviceType) Supports2x() bool {
return false
}
}
func (dt DeviceType) SupportsFirmware() bool {
switch dt {
case DeviceTidbytGen1, DeviceTidbytGen2, DevicePixoticker, DeviceTronbytS3, DeviceTronbytS3Wide, DeviceMatrixPortal, DeviceMatrixPortalWS:
@@ -520,6 +525,31 @@ func (dt DeviceType) SupportsFirmware() bool {
return false
}
}
func (dt DeviceType) FirmwareFilename(swapColors bool) string {
switch dt {
case DeviceTidbytGen1:
if swapColors {
return "tidbyt-gen1_swap.bin"
}
return "tidbyt-gen1.bin"
case DeviceTidbytGen2:
return "tidbyt-gen2.bin"
case DevicePixoticker:
return "pixoticker.bin"
case DeviceTronbytS3:
return "tronbyt-S3.bin"
case DeviceTronbytS3Wide:
return "tronbyt-s3-wide.bin"
case DeviceMatrixPortal:
return "matrixportal-s3.bin"
case DeviceMatrixPortalWS:
return "matrixportal-s3-waveshare.bin"
default:
return ""
}
}
func (d *Device) GetTimezone() string {
if d.Timezone != nil && *d.Timezone != "" {
return *d.Timezone

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"os"
"path/filepath"
"tronbyt-server/internal/data"
)
const (
@@ -14,8 +16,8 @@ const (
PlaceholderURL = "XplaceholderREMOTEURL___________________________________________________________________________________________________________"
)
func Generate(dataDir string, deviceType string, ssid, password, url string, swapColors bool) ([]byte, error) {
filename := getFirmwareFilename(deviceType, swapColors)
func Generate(dataDir string, deviceType data.DeviceType, ssid, password, url string, swapColors bool) ([]byte, error) {
filename := deviceType.FirmwareFilename(swapColors)
path := filepath.Join(dataDir, "firmware", filename)
content, err := os.ReadFile(path)
@@ -58,29 +60,6 @@ func Generate(dataDir string, deviceType string, ssid, password, url string, swa
return updateFirmwareData(content)
}
func getFirmwareFilename(deviceType string, swapColors bool) string {
switch deviceType {
case "tidbyt_gen2":
return "tidbyt-gen2.bin"
case "pixoticker":
return "pixoticker.bin"
case "tronbyt_s3":
return "tronbyt-S3.bin"
case "tronbyt_s3_wide":
return "tronbyt-s3-wide.bin"
case "matrixportal_s3":
return "matrixportal-s3.bin"
case "matrixportal_s3_waveshare":
return "matrixportal-s3-waveshare.bin"
default:
if swapColors {
return "tidbyt-gen1_swap.bin"
}
return "tidbyt-gen1.bin"
}
}
func updateFirmwareData(data []byte) ([]byte, error) {
// Image format: [Data ...][Checksum 1B][SHA256 32B]
// Total length - 33 is the data length.

View File

@@ -2,9 +2,7 @@ package server
import (
"net/http"
"os"
"path/filepath"
"strings"
"log/slog"
"tronbyt-server/internal/auth"
@@ -277,10 +275,9 @@ func (s *Server) handleEditUserGet(w http.ResponseWriter, r *http.Request) {
return
}
firmwareVersion := "unknown"
firmwareFile := filepath.Join(s.DataDir, "firmware", "firmware_version.txt")
if bytes, err := os.ReadFile(firmwareFile); err == nil {
firmwareVersion = strings.TrimSpace(string(bytes))
firmwareVersion := s.GetFirmwareVersion()
if firmwareVersion == "" {
firmwareVersion = "unknown"
}
var systemRepoInfo *gitutils.RepoInfo

View File

@@ -202,7 +202,7 @@ func (s *Server) handleFirmwareGeneratePost(w http.ResponseWriter, r *http.Reque
return
}
binData, err := firmware.Generate(s.DataDir, string(device.Type), ssid, password, imgURL, swapColors)
binData, err := firmware.Generate(s.DataDir, device.Type, ssid, password, imgURL, swapColors)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate firmware: %v", err), http.StatusInternalServerError)
return
@@ -215,3 +215,45 @@ func (s *Server) handleFirmwareGeneratePost(w http.ResponseWriter, r *http.Reque
// Log error, but can't change HTTP status after writing headers.
}
}
func (s *Server) handleTriggerOTA(w http.ResponseWriter, r *http.Request) {
device := GetDevice(r)
binName := device.Type.FirmwareFilename(device.SwapColors)
if binName == "" {
s.flashAndRedirect(w, r, "OTA not supported for this device type", fmt.Sprintf("/devices/%s/update", device.ID), http.StatusSeeOther)
return
}
firmwarePath := filepath.Join(s.DataDir, "firmware", binName)
if _, err := os.Stat(firmwarePath); os.IsNotExist(err) {
s.flashAndRedirect(w, r, "Firmware binary not found. Please update firmware binaries in Admin settings.", fmt.Sprintf("/devices/%s/update", device.ID), http.StatusSeeOther)
return
}
// Construct URL
// Using the new /static/firmware/ route
baseURL := s.GetBaseURL(r)
// Ensure baseURL has no trailing slash, but /static/firmware/ does
updateURL := fmt.Sprintf("%s/static/firmware/%s", strings.TrimRight(baseURL, "/"), binName)
if err := s.DB.Model(device).Update("pending_update_url", updateURL).Error; err != nil {
slog.Error("Failed to save pending update", "error", err)
s.flashAndRedirect(w, r, "Internal Error", fmt.Sprintf("/devices/%s/update", device.ID), http.StatusSeeOther)
return
}
device.PendingUpdateURL = updateURL
// Notify Device (to wake up WS loop)
s.Broadcaster.Notify(device.ID, nil)
s.flashAndRedirect(w, r, "OTA Update queued. Device should update shortly.", fmt.Sprintf("/devices/%s/update", device.ID), http.StatusSeeOther)
}
func (s *Server) GetFirmwareVersion() string {
versionBytes, err := os.ReadFile(filepath.Join(s.DataDir, "firmware", "firmware_version.txt"))
if err != nil {
return ""
}
return strings.TrimSpace(string(versionBytes))
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"tronbyt-server/internal/data"
@@ -204,6 +205,23 @@ func (s *Server) handleUpdateDeviceGet(w http.ResponseWriter, r *http.Request) {
locales := []string{"en_US", "de_DE"} // Add more as needed or scan directory
localizer := s.getLocalizer(r)
// Check if specific firmware exists for this device
firmwareAvailable := false
firmwareVersion := "Unknown"
binName := device.Type.FirmwareFilename(device.SwapColors)
if binName != "" {
firmwarePath := filepath.Join(s.DataDir, "firmware", binName)
if _, err := os.Stat(firmwarePath); err == nil {
firmwareAvailable = true
// Read version
v := s.GetFirmwareVersion()
if v != "" {
firmwareVersion = v
}
}
}
s.renderTemplate(w, r, "update", TemplateData{
User: user,
Device: device,
@@ -215,6 +233,8 @@ func (s *Server) handleUpdateDeviceGet(w http.ResponseWriter, r *http.Request) {
BrightnessUI: bUI,
NightBrightnessUI: nbUI,
DimBrightnessUI: dbUI,
FirmwareAvailable: firmwareAvailable,
FirmwareVersion: firmwareVersion,
Localizer: localizer,
})
@@ -414,6 +434,9 @@ func (s *Server) handleUpdateDevicePost(w http.ResponseWriter, r *http.Request)
// 8. API Key
device.APIKey = r.FormValue("api_key")
// 9. OTA
device.SwapColors = r.FormValue("swap_colors") == "on"
if err := s.DB.Save(device).Error; err != nil {
slog.Error("Failed to update device", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -579,6 +602,7 @@ func (s *Server) handleImportDeviceConfig(w http.ResponseWriter, r *http.Request
device.Info = importedDevice.Info
device.ColorFilter = importedDevice.ColorFilter
device.NightColorFilter = importedDevice.NightColorFilter
device.SwapColors = importedDevice.SwapColors
if err := tx.Save(device).Error; err != nil {
return fmt.Errorf("failed to save updated device: %w", err)

View File

@@ -77,6 +77,19 @@ func (s *Server) handleNextApp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
// Check for Pending Update
if updateURL := device.PendingUpdateURL; updateURL != "" {
slog.Info("Sending OTA update header", "device", device.ID, "url", updateURL)
w.Header().Set("Tronbyt-OTA-URL", updateURL)
// Clear pending update
if err := s.DB.Model(device).Update("pending_update_url", "").Error; err != nil {
slog.Error("Failed to clear pending update", "error", err)
} else {
device.PendingUpdateURL = ""
}
}
// Determine Brightness
brightness := device.GetEffectiveBrightness()
w.Header().Set("Tronbyt-Brightness", fmt.Sprintf("%d", brightness))

View File

@@ -4,8 +4,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -28,10 +26,9 @@ func (s *Server) handleUpdateFirmware(w http.ResponseWriter, r *http.Request) {
}
if r.Header.Get("Accept") == "application/json" {
version := "unknown"
firmwareDir := filepath.Join(s.DataDir, "firmware")
if vBytes, e := os.ReadFile(filepath.Join(firmwareDir, "firmware_version.txt")); e == nil {
version = strings.TrimSpace(string(vBytes))
version := s.GetFirmwareVersion()
if version == "" {
version = "unknown"
}
resp := map[string]any{"success": err == nil, "version": version}

View File

@@ -77,6 +77,7 @@ type TemplateData struct {
// Firmware
FirmwareBinsAvailable bool
FirmwareAvailable bool
FirmwareVersion string
ServerVersion string
CommitHash string

View File

@@ -10,6 +10,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"time"
@@ -194,6 +195,10 @@ func (s *Server) routes() {
s.Router.Handle("GET /static/favicon.ico", http.StripPrefix("/static/", fileServer))
}
// Serve firmware binaries
firmwareDir := filepath.Join(s.DataDir, "firmware")
s.Router.Handle("GET /static/firmware/", http.StripPrefix("/static/firmware/", http.FileServer(http.Dir(firmwareDir))))
// App Preview (Specific path)
s.Router.HandleFunc("GET /preview/app/{id}", s.handleSystemAppThumbnail)
@@ -257,6 +262,7 @@ func (s *Server) routes() {
// Firmware
s.Router.HandleFunc("GET /devices/{id}/firmware", s.RequireLogin(s.RequireDevice(s.handleFirmwareGenerateGet)))
s.Router.HandleFunc("POST /devices/{id}/firmware", s.RequireLogin(s.RequireDevice(s.handleFirmwareGeneratePost)))
s.Router.HandleFunc("POST /devices/{id}/ota", s.RequireLogin(s.RequireDevice(s.handleTriggerOTA))) // OTA Update
s.Router.HandleFunc("GET /devices/{id}/update", s.RequireLogin(s.RequireDevice(s.handleUpdateDeviceGet)))
s.Router.HandleFunc("POST /devices/{id}/update", s.RequireLogin(s.RequireDevice(s.handleUpdateDevicePost)))

View File

@@ -154,6 +154,27 @@ func (s *Server) wsWriteLoop(ctx context.Context, conn *websocket.Conn, initialD
var app *data.App
var err error
// Check for Pending Update
if updateURL := device.PendingUpdateURL; updateURL != "" {
slog.Info("Sending OTA update command", "device", device.ID, "url", updateURL)
if err := conn.WriteJSON(map[string]string{
"ota_url": updateURL,
}); err != nil {
slog.Error("Failed to write OTA update message", "error", err)
return
}
// Clear pending update to avoid loops
if err := s.DB.Model(&device).Update("pending_update_url", "").Error; err != nil {
slog.Error("Failed to clear pending update", "error", err)
} else {
device.PendingUpdateURL = ""
}
// Wait a bit to let the device process, then return or continue?
// The device will likely close connection and reboot.
// Let's just continue loop, but we won't send image this cycle probably?
// Or we can just let it flow. The device should handle it.
}
if pendingImage != nil {
imgData = pendingImage
pendingImage = nil

View File

@@ -1444,5 +1444,44 @@
},
"Error parsing config file.": {
"other": "Fehler beim Parsen der Konfigurationsdatei."
},
"Unknown": {
"other": "Unbekannt"
},
"Firmware Update": {
"other": "Firmware-Update"
},
"Device Version": {
"other": "Geräte-Version"
},
"Available Version": {
"other": "Verfügbare Version"
},
"Swap Colors (Fix for some Tidbyt Gen 1 displays)": {
"other": "Farben tauschen (Fix für manche Tidbyt Gen 1 Displays)"
},
"Note: Changing this setting will require saving before updating firmware.": {
"other": "Hinweis: Speichern Sie diese Einstellung, bevor Sie das Firmware-Update starten."
},
"Install Latest Firmware": {
"other": "Neueste Firmware installieren"
},
"This will trigger an Over-The-Air update. The device will reboot. Continue?": {
"other": "Dies startet ein Over-The-Air Update. Das Gerät wird neu starten. Fortfahren?"
},
"Updates the device to the latest version available on the server.": {
"other": "Aktualisiert das Gerät auf die neueste auf dem Server verfügbare Version."
},
"OTA not supported for this device type": {
"other": "OTA wird für diesen Gerätetyp nicht unterstützt"
},
"Firmware binary not found. Please update firmware binaries in Admin settings.": {
"other": "Firmware-Datei nicht gefunden. Bitte aktualisieren Sie die Firmware-Dateien in den Admin-Einstellungen."
},
"Internal Error": {
"other": "Interner Fehler"
},
"OTA Update queued. Device should update shortly.": {
"other": "OTA-Update eingereiht. Das Gerät sollte in Kürze aktualisiert werden."
}
}

View File

@@ -1438,5 +1438,41 @@
},
"Error parsing config file.": {
"other": "Error parsing config file."
},
"Firmware Update": {
"other": "Firmware Update"
},
"Device Version": {
"other": "Device Version"
},
"Available Version": {
"other": "Available Version"
},
"Swap Colors (Fix for some Tidbyt Gen 1 displays)": {
"other": "Swap Colors (Fix for some Tidbyt Gen 1 displays)"
},
"Note: Changing this setting will require saving before updating firmware.": {
"other": "Note: Changing this setting will require saving before updating firmware."
},
"Install Latest Firmware": {
"other": "Install Latest Firmware"
},
"This will trigger an Over-The-Air update. The device will reboot. Continue?": {
"other": "This will trigger an Over-The-Air update. The device will reboot. Continue?"
},
"Updates the device to the latest version available on the server.": {
"other": "Updates the device to the latest version available on the server."
},
"OTA not supported for this device type": {
"other": "OTA not supported for this device type"
},
"Firmware binary not found. Please update firmware binaries in Admin settings.": {
"other": "Firmware binary not found. Please update firmware binaries in Admin settings."
},
"Internal Error": {
"other": "Internal Error"
},
"OTA Update queued. Device should update shortly.": {
"other": "OTA Update queued. Device should update shortly."
}
}

View File

@@ -1757,3 +1757,13 @@ document.addEventListener('DOMContentLoaded', function () {
}, true);
}
});
function triggerOTA(deviceId, confirmMessage) {
if (confirm(confirmMessage)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/devices/${deviceId}/ota`;
document.body.appendChild(form);
form.submit();
}
}

View File

@@ -532,10 +532,38 @@
<input name="notes" id="notes" value="{{ .Device.Notes }}" class="notes-input" placeholder="{{ t .Localizer "Optional notes about this device" }}">
</td>
</tr>
</table>
</div>
</table>
</div>
{{ if .FirmwareAvailable }}
<div class="device-settings-section">
<h2>{{ t .Localizer "Firmware Update" }}</h2>
<div style="padding: 10px;">
<p><strong>{{ t .Localizer "Device Version" }}:</strong> {{ if .Device.Info.FirmwareVersion }}{{ .Device.Info.FirmwareVersion }}{{ else }}{{ t .Localizer "Unknown" }}{{ end }}</p>
<p><strong>{{ t .Localizer "Available Version" }}:</strong> {{ if .FirmwareVersion }}{{ .FirmwareVersion }}{{ else }}{{ t .Localizer "Unknown" }}{{ end }}</p>
{{ if eq .Device.Type "tidbyt_gen1" }}
<div style="margin-bottom: 10px;">
<label for="swap_colors" style="cursor: pointer;">
<input type="checkbox" name="swap_colors" id="swap_colors" {{ if .Device.SwapColors }}checked{{ end }}>
{{ t .Localizer "Swap Colors (Fix for some Tidbyt Gen 1 displays)" }}
</label>
<p class="small-text">{{ t .Localizer "Note: Changing this setting will require saving before updating firmware." }}</p>
</div>
{{ end }}
<button type="button" class="w3-button w3-blue config-management-btn" onclick="triggerOTA('{{ .Device.ID }}', '{{ t .Localizer "This will trigger an Over-The-Air update. The device will reboot. Continue?" }}')">
<i class="fa-solid fa-cloud-arrow-down"></i> {{ t .Localizer "Install Latest Firmware" }}
</button>
<small class="small-text" style="display: block; margin-top: 5px;">{{ t .Localizer "Updates the device to the latest version available on the server." }}</small>
</div>
</div>
{{ end }}
<div class="device-settings-section">
<div class="device-settings-section">
<h2>{{ t .Localizer "Configuration Management" }}</h2>
<div class="config-management-container">