Skip to content
Merged
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
11 changes: 11 additions & 0 deletions internal/data/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,10 @@ type Device struct {
RequireAPIKey bool `json:"require_api_key"`
PendingUpdateURL string `json:"pending_update_url,omitempty"`

// HTTP device commands (delivered via /next response headers)
PendingImageURL string `json:"pending_image_url,omitempty"`
PendingReboot bool `json:"pending_reboot,omitempty"`

Apps []*App `gorm:"foreignKey:DeviceID;references:ID" json:"apps"`
}

Expand Down Expand Up @@ -981,6 +985,13 @@ func (d *Device) SupportsFirmwareFeatures() bool {
return semver.Compare(v, minFirmwareFeaturesVersion) >= 0
}

func (d *Device) SupportsHTTPFirmwareCommands() bool {
if d.Info.ProtocolType != ProtocolHTTP {
return false
}
return d.Type.SupportsFirmware()
}

// GetApp looks up an app by its iname (installation ID) in the device's Apps list.
func (d *Device) GetApp(iname string) *App {
for i := range d.Apps {
Expand Down
19 changes: 19 additions & 0 deletions internal/data/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,22 @@ func TestDeviceGetDimModeIsActiveUsesManualOverride(t *testing.T) {

assert.True(t, device.GetDimModeIsActive())
}

func TestDeviceSupportsHTTPFirmwareCommands(t *testing.T) {
httpDevice := Device{
Type: DeviceTidbytGen1,
Info: DeviceInfo{ProtocolType: ProtocolHTTP},
}
wsDevice := Device{
Type: DeviceTidbytGen1,
Info: DeviceInfo{ProtocolType: ProtocolWS},
}
otherDevice := Device{
Type: DeviceOther,
Info: DeviceInfo{ProtocolType: ProtocolHTTP},
}

assert.True(t, httpDevice.SupportsHTTPFirmwareCommands())
assert.False(t, wsDevice.SupportsHTTPFirmwareCommands())
assert.False(t, otherDevice.SupportsHTTPFirmwareCommands())
}
8 changes: 3 additions & 5 deletions internal/server/handlers_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ func (s *Server) handleDeleteInstallationAPI(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRebootDeviceAPI(w http.ResponseWriter, r *http.Request) {
device := GetDevice(r)

if err := s.sendRebootCommand(device.ID); err != nil {
if err := s.sendRebootCommand(r.Context(), device.ID); err != nil {
slog.Error("Failed to send reboot command", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
Expand Down Expand Up @@ -1057,13 +1057,11 @@ func (s *Server) handleUpdateFirmwareSettingsAPI(w http.ResponseWriter, r *http.
return
}

jsonPayload, err := json.Marshal(payload)
if err != nil {
slog.Error("Failed to marshal firmware settings payload", "error", err)
if err := s.sendFirmwareSettingsCommand(r.Context(), device.ID, payload); err != nil {
slog.Error("Failed to send firmware settings command", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
s.Broadcaster.Notify(device.ID, DeviceCommandMessage{Payload: jsonPayload})

w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("Firmware settings updated.")); err != nil {
Expand Down
16 changes: 16 additions & 0 deletions internal/server/handlers_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,14 @@ func TestHandleUpdateFirmwareSettingsAPI(t *testing.T) {
case <-time.After(1 * time.Second):
t.Fatal("timed out waiting for broadcaster notification")
}

var updatedDevice data.Device
if err := s.DB.First(&updatedDevice, "id = ?", deviceID).Error; err != nil {
t.Fatalf("Failed to fetch device: %v", err)
}
if updatedDevice.PendingImageURL != "http://example.com/test.png" {
t.Errorf("expected pending image URL to be queued, got %q", updatedDevice.PendingImageURL)
}
}

func TestHandleRebootDeviceAPI(t *testing.T) {
Expand All @@ -1219,6 +1227,14 @@ func TestHandleRebootDeviceAPI(t *testing.T) {
if rr.Body.String() != "Reboot command sent." {
t.Errorf("Expected body 'Reboot command sent.', got '%s'", rr.Body.String())
}

var updatedDevice data.Device
if err := s.DB.First(&updatedDevice, "id = ?", deviceID).Error; err != nil {
t.Fatalf("Failed to fetch device: %v", err)
}
if !updatedDevice.PendingReboot {
t.Error("expected pending reboot to be queued")
}
}

func TestSavePushedImage_CoalesceID(t *testing.T) {
Expand Down
10 changes: 5 additions & 5 deletions internal/server/handlers_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ func (s *Server) handleUpdateDeviceGet(w http.ResponseWriter, r *http.Request) {
firmwareImgURL := ""
if device.Info.ImageURL != nil {
firmwareImgURL = *device.Info.ImageURL
} else if device.Info.ProtocolType == data.ProtocolHTTP {
firmwareImgURL = device.ImgURL
}
if device.RequireAPIKey {
defaultImgURL = s.getImageURLWithKey(r, device.ID, device.APIKey)
Expand Down Expand Up @@ -1112,7 +1114,7 @@ func (s *Server) handleImportNewDeviceConfig(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRebootDevice(w http.ResponseWriter, r *http.Request) {
device := GetDevice(r)

if err := s.sendRebootCommand(device.ID); err != nil {
if err := s.sendRebootCommand(r.Context(), device.ID); err != nil {
slog.Error("Failed to send reboot command", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
Expand Down Expand Up @@ -1164,13 +1166,11 @@ func (s *Server) handleUpdateFirmwareSettings(w http.ResponseWriter, r *http.Req
return
}

jsonPayload, err := json.Marshal(payload)
if err != nil {
slog.Error("Failed to marshal firmware settings payload", "error", err)
if err := s.sendFirmwareSettingsCommand(r.Context(), device.ID, payload); err != nil {
slog.Error("Failed to send firmware settings command", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
s.Broadcaster.Notify(device.ID, DeviceCommandMessage{Payload: jsonPayload})

w.WriteHeader(http.StatusOK)
}
13 changes: 1 addition & 12 deletions internal/server/handlers_device_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,7 @@ 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 := gorm.G[data.Device](s.DB).Where("id = ?", device.ID).Update(r.Context(), "pending_update_url", ""); err != nil {
slog.Error("Failed to clear pending update", "error", err)
} else {
device.PendingUpdateURL = ""
}
}
s.sendPendingHTTPDeviceHeaders(w, r.Context(), device)

// Determine Brightness
brightness := device.GetEffectiveBrightness()
Expand Down
49 changes: 49 additions & 0 deletions internal/server/handlers_device_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,55 @@ func TestHandleNextApp_FirmwareUpdate(t *testing.T) {
}
}

func TestHandleNextApp_PendingHTTPHeaders(t *testing.T) {
s := newTestServerAPI(t)

device := data.Device{
ID: "httpdevice",
Username: "admin",
PendingUpdateURL: "http://example.com/firmware.bin",
PendingImageURL: "http://example.com/new-next",
PendingReboot: true,
}
if err := s.DB.Create(&device).Error; err != nil {
t.Fatalf("Failed to create device: %v", err)
}

req := httptest.NewRequest(http.MethodGet, "/httpdevice/next", nil)
req.SetPathValue("id", "httpdevice")

rr := httptest.NewRecorder()
s.handleNextApp(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}

if got := rr.Header().Get("Tronbyt-OTA-URL"); got != "http://example.com/firmware.bin" {
t.Errorf("Expected Tronbyt-OTA-URL header, got %q", got)
}
if got := rr.Header().Get("Tronbyt-Image-URL"); got != "http://example.com/new-next" {
t.Errorf("Expected Tronbyt-Image-URL header, got %q", got)
}
if got := rr.Header().Get("Tronbyt-Reboot"); got != "true" {
t.Errorf("Expected Tronbyt-Reboot header, got %q", got)
}

var updatedDevice data.Device
if err := s.DB.First(&updatedDevice, "id = ?", "httpdevice").Error; err != nil {
t.Fatalf("Failed to fetch device: %v", err)
}
if updatedDevice.PendingUpdateURL != "" {
t.Errorf("Expected pending update URL to be cleared, got %q", updatedDevice.PendingUpdateURL)
}
if updatedDevice.PendingImageURL != "" {
t.Errorf("Expected pending image URL to be cleared, got %q", updatedDevice.PendingImageURL)
}
if updatedDevice.PendingReboot {
t.Error("Expected pending reboot to be cleared")
}
}

func TestHandleNextApp_APIKey(t *testing.T) {
s := newTestServerAPI(t)

Expand Down
55 changes: 54 additions & 1 deletion internal/server/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,11 +673,64 @@ func (s *Server) getWebsocketURLWithKey(r *http.Request, deviceID string, apiKey

var rebootPayloadJSON = []byte(`{"reboot":true}`)

func (s *Server) sendRebootCommand(deviceID string) error {
func (s *Server) sendRebootCommand(ctx context.Context, deviceID string) error {
if _, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).Update(ctx, "pending_reboot", true); err != nil {
return err
}
s.Broadcaster.Notify(deviceID, DeviceCommandMessage{Payload: rebootPayloadJSON})
return nil
}

func (s *Server) sendFirmwareSettingsCommand(ctx context.Context, deviceID string, payload map[string]any) error {
if imageURL, ok := payload["image_url"].(string); ok && imageURL != "" {
if _, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).Update(ctx, "pending_image_url", imageURL); err != nil {
return err
}
}

jsonPayload, err := json.Marshal(payload)
if err != nil {
return err
}
s.Broadcaster.Notify(deviceID, DeviceCommandMessage{Payload: jsonPayload})
return nil
}

func (s *Server) sendPendingHTTPDeviceHeaders(w http.ResponseWriter, ctx context.Context, device *data.Device) {
if updateURL := device.PendingUpdateURL; updateURL != "" {
slog.Info("Sending OTA update header", "device", device.ID, "url", updateURL)
w.Header().Set("Tronbyt-OTA-URL", updateURL)

if _, err := gorm.G[data.Device](s.DB).Where("id = ?", device.ID).Update(ctx, "pending_update_url", ""); err != nil {
slog.Error("Failed to clear pending update", "error", err)
} else {
device.PendingUpdateURL = ""
}
}

if imageURL := device.PendingImageURL; imageURL != "" {
slog.Info("Sending image URL update header", "device", device.ID, "url", imageURL)
w.Header().Set("Tronbyt-Image-URL", imageURL)

if _, err := gorm.G[data.Device](s.DB).Where("id = ?", device.ID).Update(ctx, "pending_image_url", ""); err != nil {
slog.Error("Failed to clear pending image URL", "error", err)
} else {
device.PendingImageURL = ""
}
}

if device.PendingReboot {
slog.Info("Sending reboot header", "device", device.ID)
w.Header().Set("Tronbyt-Reboot", "true")

if _, err := gorm.G[data.Device](s.DB).Where("id = ?", device.ID).Update(ctx, "pending_reboot", false); err != nil {
slog.Error("Failed to clear pending reboot", "error", err)
} else {
device.PendingReboot = false
Comment on lines +704 to +729

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize command acknowledgement with queueing.

These unconditional clears race with a new command queued after handleNextApp loaded device. A poll can send image URL A, a request can queue B, and this update then clears B—so B is never delivered. Reload and claim/ack pending commands under a row lock or use command generations/conditional acknowledgements so a later enqueue survives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/helpers.go` around lines 704 - 729, Update the pending
command handling in handleNextApp so acknowledgement is serialized with
enqueueing: reload the device state and claim each pending update under a row
lock, or use generation-aware conditional updates. Ensure clearing
pending_update_url, pending_image_url, and pending_reboot only acknowledges the
command that was sent, preserving any newer command queued after the initial
device load.

}
}
}

// orderedAppsPreload defines a GORM preload function to sort associated apps by their 'order' field.
var orderedAppsPreload = func(db gorm.PreloadBuilder) error {
db.Order(clause.OrderByColumn{Column: clause.Column{Name: "order"}, Desc: false})
Expand Down
6 changes: 6 additions & 0 deletions web/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,12 @@
"Sync current Image URL to device via WebSocket": {
"other": "Aktuelle Bild-URL via WebSocket an Gerät senden"
},
"Sync current Image URL to device via HTTP headers": {
"other": "Aktuelle Bild-URL via HTTP-Header an Gerät senden"
},
"HTTP firmware commands require firmware version 1.6.5 or later. Commands are delivered on the device's next image poll.": {
"other": "HTTP-Firmware-Befehle erfordern Firmware-Version 1.6.5 oder höher. Befehle werden beim nächsten Bildabruf des Geräts übermittelt."
},
"Sync Hostname to device via WebSocket": {
"other": "Hostname via WebSocket an Gerät senden"
},
Expand Down
6 changes: 6 additions & 0 deletions web/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,12 @@
"Sync current Image URL to device via WebSocket": {
"other": "Sync current Image URL to device via WebSocket"
},
"Sync current Image URL to device via HTTP headers": {
"other": "Sync current Image URL to device via HTTP headers"
},
"HTTP firmware commands require firmware version 1.6.5 or later. Commands are delivered on the device's next image poll.": {
"other": "HTTP firmware commands require firmware version 1.6.5 or later. Commands are delivered on the device's next image poll."
},
"Sync Hostname to device via WebSocket": {
"other": "Sync Hostname to device via WebSocket"
},
Expand Down
37 changes: 37 additions & 0 deletions web/templates/manager/update.html
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,43 @@ <h3>{{ t .Localizer "Live Firmware Settings" }}</h3>
</button>
</div>
</div>
{{ else if .Device.SupportsHTTPFirmwareCommands }}
<div class="settings-card-subsection">
<h3>{{ t .Localizer "Live Firmware Settings" }}</h3>
<p class="settings-help">
{{ t .Localizer "HTTP firmware commands require firmware version 1.6.5 or later. Commands are delivered on the device's next image poll." }}
</p>
<div class="settings-field-list">
<div class="settings-field-row">
<div class="settings-field-label">{{ t .Localizer "Image URL" }}</div>
<div class="settings-field-control">
<div class="settings-inline-group">
<input type="text"
id="reported_img_url"
value="{{ .FirmwareImgURL }}"
class="settings-input settings-input-wide">
<button type="button"
class="w3-button w3-blue"
onclick="updateFirmwareSettings({image_url: document.getElementById('reported_img_url').value})"
title="{{ t .Localizer "Sync current Image URL to device via HTTP headers" }}">
<i class="fa-solid fa-sync"></i>
</button>
</div>
<p class="settings-help settings-note-danger">
<i class="fa-solid fa-triangle-exclamation"></i>
{{ t .Localizer "Warning: Entering an incorrect URL can break the device's connection to this server. Fixing this requires re-flashing the device." }}
</p>
</div>
</div>
</div>
<div class="settings-actions">
<button type="button"
class="w3-button w3-deep-orange"
onclick="rebootDevice()">
<i class="fa-solid fa-power-off"></i> {{ t .Localizer "Reboot Device" }}
</button>
</div>
</div>
{{ end }}
<div class="settings-card-subsection">
<h3>{{ t .Localizer "Configuration Management" }}</h3>
Expand Down
Loading