mirror of
https://github.com/tronbyt/server.git
synced 2025-12-19 08:25:46 +01:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -77,6 +77,7 @@ type TemplateData struct {
|
||||
|
||||
// Firmware
|
||||
FirmwareBinsAvailable bool
|
||||
FirmwareAvailable bool
|
||||
FirmwareVersion string
|
||||
ServerVersion string
|
||||
CommitHash string
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user