From ac99caa26f71bb0e69356d572f3840871d65e872 Mon Sep 17 00:00:00 2001 From: Patrick Hofmann Date: Fri, 6 Feb 2026 01:37:04 +0100 Subject: [PATCH 1/6] MQTT support --- config.go | 11 + go.mod | 2 + go.sum | 4 + jsonrpc.go | 9 + main.go | 8 + mqtt.go | 1082 +++++++++++++++++++ serial.go | 18 +- ui/localization/messages/de.json | 20 + ui/localization/messages/en.json | 20 + ui/src/main.tsx | 5 + ui/src/routes/devices.$id.settings.mqtt.tsx | 221 ++++ ui/src/routes/devices.$id.settings.tsx | 9 + video.go | 5 + 13 files changed, 1410 insertions(+), 4 deletions(-) create mode 100644 mqtt.go create mode 100644 ui/src/routes/devices.$id.settings.mqtt.tsx diff --git a/config.go b/config.go index 182699353..5c4110da9 100644 --- a/config.go +++ b/config.go @@ -115,6 +115,7 @@ type Config struct { VideoSleepAfterSec int `json:"video_sleep_after_sec"` VideoQualityFactor float64 `json:"video_quality_factor"` NativeMaxRestart uint `json:"native_max_restart_attempts"` + MqttConfig *MQTTConfig `json:"mqtt_config"` } // GetUpdateAPIURL returns the update API URL @@ -198,6 +199,12 @@ func getDefaultConfig() Config { }(), DefaultLogLevel: "WARN", VideoQualityFactor: 1.0, + MqttConfig: &MQTTConfig{ + Enabled: false, + Port: 1883, + BaseTopic: "jetkvm", + EnableHADiscovery: false, + }, } } @@ -268,6 +275,10 @@ func LoadConfig() { loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig } + if loadedConfig.MqttConfig == nil { + loadedConfig.MqttConfig = getDefaultConfig().MqttConfig + } + // fixup old keyboard layout value if loadedConfig.KeyboardLayout == "en_US" { loadedConfig.KeyboardLayout = "en-US" diff --git a/go.mod b/go.mod index 6c47dc932..a8c4a6ba4 100644 --- a/go.mod +++ b/go.mod @@ -49,6 +49,7 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/creack/goselect v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -56,6 +57,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 69001e12c..f8a708889 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 h1:gT+RM6gdTIAzMT7HUvmT5mL8SyG8Wx7iS3+L0V34Km4= github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377/go.mod h1:v6o7m/E9bfvm79dE1iFiF+3T7zLBnrjYjkWMa1J+Hv0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -64,6 +66,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0= diff --git a/jsonrpc.go b/jsonrpc.go index 747107311..7aa78d9b7 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -714,6 +714,12 @@ func rpcSetActiveExtension(extensionId string) error { case "dc-power": _ = mountDCControl() } + + // Re-publish MQTT HA Discovery for the new extension + if mqttManager != nil { + mqttManager.RepublishHADiscovery() + } + return nil } @@ -1209,4 +1215,7 @@ var rpcHandlers = map[string]RPCHandler{ "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, "getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}}, "checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses}, + "getMqttSettings": {Func: rpcGetMqttSettings}, + "setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}}, + "getMqttStatus": {Func: rpcGetMqttStatus}, } diff --git a/main.go b/main.go index dd38d8811..ac9ec1bb4 100644 --- a/main.go +++ b/main.go @@ -115,6 +115,14 @@ func Main() { } initJiggler() + // Initialize MQTT + initMQTT() + defer func() { + if mqttManager != nil { + mqttManager.Close() + } + }() + // start video sleep mode timer startVideoSleepModeTicker() diff --git a/mqtt.go b/mqtt.go new file mode 100644 index 000000000..2f99a41c1 --- /dev/null +++ b/mqtt.go @@ -0,0 +1,1082 @@ +package kvm + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/gwatts/rootcerts" + "github.com/jetkvm/kvm/internal/logging" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +var mqttLogger = logging.GetSubsystemLogger("mqtt") + +type MQTTConfig struct { + Enabled bool `json:"enabled"` + Broker string `json:"broker"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + BaseTopic string `json:"base_topic"` + UseTLS bool `json:"use_tls"` + TLSInsecure bool `json:"tls_insecure"` + EnableHADiscovery bool `json:"enable_ha_discovery"` +} + +var mqttManager *MQTTManager + +type MQTTManager struct { + client mqtt.Client + deviceID string + baseTopic string + connected bool +} + +type mqttStatusPayload struct { + Online bool `json:"online"` +} + +// topic returns a fully qualified topic string using baseTopic. +func (m *MQTTManager) topic(parts ...string) string { + return m.baseTopic + "/" + strings.Join(parts, "/") +} + +func NewMQTTManager(cfg *MQTTConfig, deviceID string) (*MQTTManager, error) { + if cfg == nil || !cfg.Enabled { + return nil, fmt.Errorf("MQTT is not enabled") + } + + baseTopic := cfg.BaseTopic + if baseTopic == "" { + baseTopic = "jetkvm" + } + // Ensure baseTopic includes deviceID + if !strings.Contains(baseTopic, deviceID) { + baseTopic = baseTopic + "/" + deviceID + } + + m := &MQTTManager{ + deviceID: deviceID, + baseTopic: baseTopic, + } + + scheme := "tcp" + port := cfg.Port + if port == 0 { + port = 1883 + } + if cfg.UseTLS { + scheme = "ssl" + } + brokerURL := fmt.Sprintf("%s://%s:%d", scheme, cfg.Broker, port) + + opts := mqtt.NewClientOptions() + opts.AddBroker(brokerURL) + opts.SetClientID(fmt.Sprintf("jetkvm-%s", deviceID)) + opts.SetUsername(cfg.Username) + opts.SetPassword(cfg.Password) + opts.SetAutoReconnect(true) + opts.SetCleanSession(false) + opts.SetConnectRetryInterval(10 * time.Second) + + if cfg.UseTLS { + tlsConfig := &tls.Config{ + InsecureSkipVerify: cfg.TLSInsecure, //nolint:gosec + } + if !cfg.TLSInsecure { + tlsConfig.RootCAs = rootcerts.ServerCertPool() + } + opts.SetTLSConfig(tlsConfig) + } + + // Will message: offline status + willPayload, _ := json.Marshal(mqttStatusPayload{Online: false}) + opts.SetWill(m.topic("status"), string(willPayload), 1, true) + + opts.OnConnect = m.onConnect + opts.OnConnectionLost = m.onConnectionLost + + m.client = mqtt.NewClient(opts) + + if token := m.client.Connect(); token.Wait() && token.Error() != nil { + return nil, fmt.Errorf("failed to connect to MQTT broker %s: %w", brokerURL, token.Error()) + } + + return m, nil +} + +func (m *MQTTManager) onConnect(client mqtt.Client) { + mqttLogger.Info().Str("deviceID", m.deviceID).Msg("connected to MQTT broker") + m.connected = true + + // Publish online status + m.publish(m.topic("status"), mqttStatusPayload{Online: true}, true) + + // Publish Home Assistant discovery configs if enabled + if config.MqttConfig != nil && config.MqttConfig.EnableHADiscovery { + m.publishHADiscovery() + } + + // Subscribe to command topics + m.subscribeCommands() +} + +func (m *MQTTManager) onConnectionLost(client mqtt.Client, err error) { + mqttLogger.Warn().Err(err).Msg("MQTT connection lost") + m.connected = false +} + +// IsConnected returns the current connection state. +func (m *MQTTManager) IsConnected() bool { + return m.connected && m.client.IsConnected() +} + +// Close disconnects from the MQTT broker gracefully. +func (m *MQTTManager) Close() { + if m.client != nil && m.client.IsConnected() { + m.publish(m.topic("status"), mqttStatusPayload{Online: false}, true) + m.client.Disconnect(500) + } + m.connected = false +} + +// publish marshals the payload to JSON and publishes to the topic. +func (m *MQTTManager) publish(topic string, payload interface{}, retained bool) { + data, err := json.Marshal(payload) + if err != nil { + mqttLogger.Error().Err(err).Str("topic", topic).Msg("failed to marshal MQTT payload") + return + } + token := m.client.Publish(topic, 1, retained, data) + token.Wait() + if token.Error() != nil { + mqttLogger.Error().Err(token.Error()).Str("topic", topic).Msg("failed to publish MQTT message") + } +} + +// publishString publishes a raw string payload. +func (m *MQTTManager) publishString(topic string, payload string, retained bool) { + token := m.client.Publish(topic, 1, retained, payload) + token.Wait() + if token.Error() != nil { + mqttLogger.Error().Err(token.Error()).Str("topic", topic).Msg("failed to publish MQTT message") + } +} + +// --- Home Assistant MQTT Discovery --- + +type haDevice struct { + Identifiers []string `json:"identifiers"` + Name string `json:"name"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + SwVersion string `json:"sw_version,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + ConfigURL string `json:"configuration_url,omitempty"` +} + +type haDiscoveryPayload struct { + // Common + Name string `json:"name"` + UniqueID string `json:"unique_id"` + StateTopic string `json:"state_topic,omitempty"` + CommandTopic string `json:"command_topic,omitempty"` + ValueTemplate string `json:"value_template,omitempty"` + AvailabilityTopic string `json:"availability_topic"` + AvailTemplate string `json:"availability_template"` + Device *haDevice `json:"device"` + + // Sensor-specific + DeviceClass string `json:"device_class,omitempty"` + UnitOfMeasurement string `json:"unit_of_measurement,omitempty"` + StateClass string `json:"state_class,omitempty"` + PayloadOn string `json:"payload_on,omitempty"` + PayloadOff string `json:"payload_off,omitempty"` + PayloadPress string `json:"payload_press,omitempty"` + Icon string `json:"icon,omitempty"` + EntityCategory string `json:"entity_category,omitempty"` + ObjectID string `json:"object_id,omitempty"` + EnabledByDefault *bool `json:"enabled_by_default,omitempty"` + + // Select-specific + Options []string `json:"options,omitempty"` + + // Update-specific + LatestVersionTopic string `json:"latest_version_topic,omitempty"` + LatestVersionTemplate string `json:"latest_version_template,omitempty"` + PayloadInstall string `json:"payload_install,omitempty"` + ReleaseURL string `json:"release_url,omitempty"` +} + +func (m *MQTTManager) haDeviceInfo() *haDevice { + deviceID := m.deviceID + + // Build configuration URL from network state + configURL := "" + if networkManager != nil { + state, err := networkManager.GetInterfaceState(NetIfName) + if err == nil { + rpcState := state.ToRpcInterfaceState() + if rpcState != nil && rpcState.IPv4Address != "" { + scheme := "http" + if config.TLSMode != "" { + scheme = "https" + } + configURL = fmt.Sprintf("%s://%s", scheme, rpcState.IPv4Address) + } + } + } + + // Build software version from app + system versions + swVersion := "" + sysVer, appVer, err := GetLocalVersion() + if err == nil { + if appVer != nil && sysVer != nil { + swVersion = fmt.Sprintf("App %s / System %s", appVer.String(), sysVer.String()) + } else if appVer != nil { + swVersion = fmt.Sprintf("App %s", appVer.String()) + } + } + + return &haDevice{ + Identifiers: []string{deviceID}, + Name: fmt.Sprintf("JetKVM %s", deviceID), + Manufacturer: "JetKVM", + Model: "JetKVM", + SwVersion: swVersion, + SerialNumber: deviceID, + ConfigURL: configURL, + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func (m *MQTTManager) publishHADiscovery() { + device := m.haDeviceInfo() + availTopic := m.topic("status") + availTemplate := "{{ 'online' if value_json.online else 'offline' }}" + + // --- General entities (always published) --- + + // Binary sensor: Online + m.publishDiscovery("binary_sensor", "online", haDiscoveryPayload{ + Name: "Online", + UniqueID: fmt.Sprintf("jetkvm_%s_online", m.deviceID), + StateTopic: m.topic("status"), + ValueTemplate: "{{ 'ON' if value_json.online else 'OFF' }}", + DeviceClass: "connectivity", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Binary sensor: Video Signal + m.publishDiscovery("binary_sensor", "video_signal", haDiscoveryPayload{ + Name: "Video Signal", + UniqueID: fmt.Sprintf("jetkvm_%s_video_signal", m.deviceID), + StateTopic: m.topic("video", "state"), + ValueTemplate: "{{ 'ON' if value_json.ready else 'OFF' }}", + DeviceClass: "connectivity", + Icon: "mdi:video-input-hdmi", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Video Resolution (disabled by default) + disabledByDefault := boolPtr(false) + m.publishDiscovery("sensor", "video_resolution", haDiscoveryPayload{ + Name: "Video Resolution", + UniqueID: fmt.Sprintf("jetkvm_%s_video_resolution", m.deviceID), + StateTopic: m.topic("video", "state"), + ValueTemplate: "{{ value_json.width }}x{{ value_json.height }}", + Icon: "mdi:monitor", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Video FPS (disabled by default) + m.publishDiscovery("sensor", "video_fps", haDiscoveryPayload{ + Name: "Video FPS", + UniqueID: fmt.Sprintf("jetkvm_%s_video_fps", m.deviceID), + StateTopic: m.topic("video", "state"), + ValueTemplate: "{{ value_json.fps | round(1) }}", + UnitOfMeasurement: "fps", + StateClass: "measurement", + Icon: "mdi:speedometer", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Binary sensor: Cloud Connected + m.publishDiscovery("binary_sensor", "cloud_connected", haDiscoveryPayload{ + Name: "Cloud Connected", + UniqueID: fmt.Sprintf("jetkvm_%s_cloud_connected", m.deviceID), + StateTopic: m.topic("cloud", "state"), + ValueTemplate: "{{ 'ON' if value_json.connected else 'OFF' }}", + DeviceClass: "connectivity", + Icon: "mdi:cloud", + EntityCategory: "diagnostic", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Active Sessions + m.publishDiscovery("sensor", "active_sessions", haDiscoveryPayload{ + Name: "Active Sessions", + UniqueID: fmt.Sprintf("jetkvm_%s_active_sessions", m.deviceID), + StateTopic: m.topic("sessions", "state"), + ValueTemplate: "{{ value_json.active_sessions }}", + Icon: "mdi:account-multiple", + StateClass: "measurement", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Binary sensor: USB State + m.publishDiscovery("binary_sensor", "usb_state", haDiscoveryPayload{ + Name: "USB Connected", + UniqueID: fmt.Sprintf("jetkvm_%s_usb_state", m.deviceID), + StateTopic: m.topic("usb", "state"), + ValueTemplate: "{{ 'ON' if value_json.state == 'configured' else 'OFF' }}", + DeviceClass: "connectivity", + Icon: "mdi:usb", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: IP Address (disabled by default) + m.publishDiscovery("sensor", "ip_address", haDiscoveryPayload{ + Name: "IP Address", + UniqueID: fmt.Sprintf("jetkvm_%s_ip_address", m.deviceID), + StateTopic: m.topic("network", "state"), + ValueTemplate: "{{ value_json.ip_address }}", + Icon: "mdi:ip-network", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Hostname (disabled by default) + m.publishDiscovery("sensor", "hostname", haDiscoveryPayload{ + Name: "Hostname", + UniqueID: fmt.Sprintf("jetkvm_%s_hostname", m.deviceID), + StateTopic: m.topic("network", "state"), + ValueTemplate: "{{ value_json.hostname }}", + Icon: "mdi:dns", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Switch: Mouse Jiggler + m.publishDiscovery("switch", "jiggler", haDiscoveryPayload{ + Name: "Mouse Jiggler", + UniqueID: fmt.Sprintf("jetkvm_%s_jiggler", m.deviceID), + StateTopic: m.topic("jiggler", "state"), + CommandTopic: m.topic("jiggler", "set"), + ValueTemplate: "{{ 'ON' if value_json.enabled else 'OFF' }}", + PayloadOn: "ON", + PayloadOff: "OFF", + Icon: "mdi:mouse", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Button: Reboot + m.publishDiscovery("button", "reboot", haDiscoveryPayload{ + Name: "Reboot", + UniqueID: fmt.Sprintf("jetkvm_%s_reboot", m.deviceID), + CommandTopic: m.topic("reboot", "set"), + PayloadPress: "PRESS", + DeviceClass: "restart", + Icon: "mdi:restart", + EntityCategory: "config", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Update: Firmware Update + m.publishDiscovery("update", "firmware", haDiscoveryPayload{ + Name: "Firmware", + UniqueID: fmt.Sprintf("jetkvm_%s_firmware", m.deviceID), + StateTopic: m.topic("update", "state"), + LatestVersionTopic: m.topic("update", "state"), + LatestVersionTemplate: "{{ value_json.latest_version }}", + ValueTemplate: "{{ value_json.installed_version }}", + CommandTopic: m.topic("update", "install"), + PayloadInstall: "INSTALL", + DeviceClass: "firmware", + EntityCategory: "config", + ReleaseURL: "https://github.com/jetkvm/kvm/releases", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // --- Extension-dependent entities --- + + activeExtension := config.ActiveExtension + + if activeExtension == "atx-power" { + m.publishATXDiscovery(device, availTopic, availTemplate) + // Explicitly remove DC entities + m.removeDCDiscovery() + } else if activeExtension == "dc-power" { + m.publishDCDiscovery(device, availTopic, availTemplate) + // Explicitly remove ATX entities + m.removeATXDiscovery() + } else { + // No power extension active (e.g. serial or none) - remove both + m.removeATXDiscovery() + m.removeDCDiscovery() + } + + mqttLogger.Info().Str("extension", activeExtension).Msg("published Home Assistant discovery configs") +} + +func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTemplate string) { + // Binary sensor: ATX Power LED + m.publishDiscovery("binary_sensor", "power_led", haDiscoveryPayload{ + Name: "ATX Power LED", + UniqueID: fmt.Sprintf("jetkvm_%s_power_led", m.deviceID), + StateTopic: m.topic("atx", "state"), + ValueTemplate: "{{ 'ON' if value_json.power else 'OFF' }}", + DeviceClass: "power", + Icon: "mdi:led-on", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Binary sensor: ATX HDD LED + m.publishDiscovery("binary_sensor", "hdd_led", haDiscoveryPayload{ + Name: "ATX HDD LED", + UniqueID: fmt.Sprintf("jetkvm_%s_hdd_led", m.deviceID), + StateTopic: m.topic("atx", "state"), + ValueTemplate: "{{ 'ON' if value_json.hdd else 'OFF' }}", + Icon: "mdi:harddisk", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Button: ATX Power Short Press + m.publishDiscovery("button", "atx_power_short", haDiscoveryPayload{ + Name: "ATX Power (Short Press)", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_short", m.deviceID), + CommandTopic: m.topic("atx_power_short", "set"), + PayloadPress: "PRESS", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Button: ATX Power Long Press + m.publishDiscovery("button", "atx_power_long", haDiscoveryPayload{ + Name: "ATX Power (Long Press)", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_long", m.deviceID), + CommandTopic: m.topic("atx_power_long", "set"), + PayloadPress: "PRESS", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Button: ATX Reset + m.publishDiscovery("button", "atx_reset", haDiscoveryPayload{ + Name: "ATX Reset", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_reset", m.deviceID), + CommandTopic: m.topic("atx_reset", "set"), + PayloadPress: "PRESS", + Icon: "mdi:restart", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) +} + +func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemplate string) { + // Sensor: DC Voltage + m.publishDiscovery("sensor", "voltage", haDiscoveryPayload{ + Name: "DC Voltage", + UniqueID: fmt.Sprintf("jetkvm_%s_voltage", m.deviceID), + StateTopic: m.topic("dc", "state"), + ValueTemplate: "{{ value_json.voltage | round(2) }}", + DeviceClass: "voltage", + UnitOfMeasurement: "V", + StateClass: "measurement", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: DC Current + m.publishDiscovery("sensor", "current", haDiscoveryPayload{ + Name: "DC Current", + UniqueID: fmt.Sprintf("jetkvm_%s_current", m.deviceID), + StateTopic: m.topic("dc", "state"), + ValueTemplate: "{{ value_json.current | round(3) }}", + DeviceClass: "current", + UnitOfMeasurement: "A", + StateClass: "measurement", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: DC Power + m.publishDiscovery("sensor", "power", haDiscoveryPayload{ + Name: "DC Power", + UniqueID: fmt.Sprintf("jetkvm_%s_power", m.deviceID), + StateTopic: m.topic("dc", "state"), + ValueTemplate: "{{ value_json.power | round(2) }}", + DeviceClass: "power", + UnitOfMeasurement: "W", + StateClass: "measurement", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Switch: DC Power + m.publishDiscovery("switch", "dc_power", haDiscoveryPayload{ + Name: "DC Power", + UniqueID: fmt.Sprintf("jetkvm_%s_dc_power", m.deviceID), + StateTopic: m.topic("dc", "state"), + CommandTopic: m.topic("dc_power", "set"), + ValueTemplate: "{{ 'ON' if value_json.isOn else 'OFF' }}", + PayloadOn: "ON", + PayloadOff: "OFF", + DeviceClass: "switch", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) +} + +func (m *MQTTManager) publishDiscovery(component, objectID string, payload haDiscoveryPayload) { + payload.ObjectID = fmt.Sprintf("jetkvm_%s_%s", m.deviceID, objectID) + discoveryTopic := fmt.Sprintf("homeassistant/%s/jetkvm_%s/%s/config", component, m.deviceID, objectID) + m.publish(discoveryTopic, payload, true) +} + +// removeDiscovery removes a single HA discovery entity by publishing an empty retained payload. +func (m *MQTTManager) removeDiscovery(component, objectID string) { + discoveryTopic := fmt.Sprintf("homeassistant/%s/jetkvm_%s/%s/config", component, m.deviceID, objectID) + m.publishString(discoveryTopic, "", true) +} + +// removeATXDiscovery removes all ATX-related HA discovery entities. +func (m *MQTTManager) removeATXDiscovery() { + m.removeDiscovery("binary_sensor", "power_led") + m.removeDiscovery("binary_sensor", "hdd_led") + m.removeDiscovery("button", "atx_power_short") + m.removeDiscovery("button", "atx_power_long") + m.removeDiscovery("button", "atx_reset") +} + +// removeDCDiscovery removes all DC-related HA discovery entities. +func (m *MQTTManager) removeDCDiscovery() { + m.removeDiscovery("sensor", "voltage") + m.removeDiscovery("sensor", "current") + m.removeDiscovery("sensor", "power") + m.removeDiscovery("switch", "dc_power") +} + +// removeAllDiscovery removes all HA discovery entries (general + extension-specific). +func (m *MQTTManager) removeAllDiscovery() { + // General entities + m.removeDiscovery("binary_sensor", "online") + m.removeDiscovery("binary_sensor", "video_signal") + m.removeDiscovery("sensor", "video_resolution") + m.removeDiscovery("sensor", "video_fps") + m.removeDiscovery("binary_sensor", "cloud_connected") + m.removeDiscovery("sensor", "active_sessions") + m.removeDiscovery("binary_sensor", "usb_state") + m.removeDiscovery("sensor", "ip_address") + m.removeDiscovery("sensor", "hostname") + m.removeDiscovery("switch", "jiggler") + m.removeDiscovery("button", "reboot") + m.removeDiscovery("update", "firmware") + + // Extension-specific entities + m.removeATXDiscovery() + m.removeDCDiscovery() + + mqttLogger.Info().Msg("removed all HA discovery entries") +} + +// cleanupAllTopics removes all discovery entries and clears all state topics. +func (m *MQTTManager) cleanupAllTopics() { + // Remove all HA discovery entries + m.removeAllDiscovery() + + // Clear all state topics by publishing empty retained messages + stateTopics := []string{ + m.topic("status"), + m.topic("video", "state"), + m.topic("cloud", "state"), + m.topic("sessions", "state"), + m.topic("usb", "state"), + m.topic("jiggler", "state"), + m.topic("network", "state"), + m.topic("update", "state"), + m.topic("atx", "state"), + m.topic("dc", "state"), + } + for _, t := range stateTopics { + m.publishString(t, "", true) + } + + mqttLogger.Info().Msg("cleaned up all MQTT topics and discovery entries") +} + +// RepublishHADiscovery removes old extension entities and re-publishes all discovery configs. +// Call this when the active extension changes. +func (m *MQTTManager) RepublishHADiscovery() { + if !m.IsConnected() { + return + } + if config.MqttConfig == nil || !config.MqttConfig.EnableHADiscovery { + return + } + + // Re-publish all discovery configs (publishHADiscovery handles removal of inactive extension entities) + m.publishHADiscovery() + + mqttLogger.Info().Str("extension", config.ActiveExtension).Msg("republished HA discovery after extension change") +} + +// --- Command Subscriptions --- + +func (m *MQTTManager) subscribeCommands() { + commands := map[string]mqtt.MessageHandler{ + m.topic("dc_power", "set"): m.handleDCPowerCommand, + m.topic("atx_power_short", "set"): m.handleATXPowerShortCommand, + m.topic("atx_power_long", "set"): m.handleATXPowerLongCommand, + m.topic("atx_reset", "set"): m.handleATXResetCommand, + m.topic("jiggler", "set"): m.handleJigglerCommand, + m.topic("reboot", "set"): m.handleRebootCommand, + m.topic("update", "install"): m.handleUpdateInstallCommand, + } + + for topic, handler := range commands { + if token := m.client.Subscribe(topic, 1, handler); token.Wait() && token.Error() != nil { + mqttLogger.Error().Err(token.Error()).Str("topic", topic).Msg("failed to subscribe") + } + } + + mqttLogger.Info().Msg("subscribed to command topics") +} + +func (m *MQTTManager) handleDCPowerCommand(client mqtt.Client, msg mqtt.Message) { + payload := strings.TrimSpace(string(msg.Payload())) + mqttLogger.Info().Str("payload", payload).Msg("received DC power command") + + switch strings.ToUpper(payload) { + case "ON": + if err := setDCPowerState(true); err != nil { + mqttLogger.Error().Err(err).Msg("failed to set DC power on") + } + case "OFF": + if err := setDCPowerState(false); err != nil { + mqttLogger.Error().Err(err).Msg("failed to set DC power off") + } + default: + mqttLogger.Warn().Str("payload", payload).Msg("unknown DC power command") + } +} + +func (m *MQTTManager) handleATXPowerShortCommand(client mqtt.Client, msg mqtt.Message) { + mqttLogger.Info().Msg("received ATX power short press command") + if err := pressATXPowerButton(500 * time.Millisecond); err != nil { + mqttLogger.Error().Err(err).Msg("failed to press ATX power button (short)") + } +} + +func (m *MQTTManager) handleATXPowerLongCommand(client mqtt.Client, msg mqtt.Message) { + mqttLogger.Info().Msg("received ATX power long press command") + if err := pressATXPowerButton(5 * time.Second); err != nil { + mqttLogger.Error().Err(err).Msg("failed to press ATX power button (long)") + } +} + +func (m *MQTTManager) handleATXResetCommand(client mqtt.Client, msg mqtt.Message) { + mqttLogger.Info().Msg("received ATX reset command") + if err := pressATXResetButton(500 * time.Millisecond); err != nil { + mqttLogger.Error().Err(err).Msg("failed to press ATX reset button") + } +} + +func (m *MQTTManager) handleJigglerCommand(client mqtt.Client, msg mqtt.Message) { + payload := strings.TrimSpace(string(msg.Payload())) + mqttLogger.Info().Str("payload", payload).Msg("received jiggler command") + + switch strings.ToUpper(payload) { + case "ON": + if err := rpcSetJigglerState(true); err != nil { + mqttLogger.Error().Err(err).Msg("failed to enable jiggler") + } + case "OFF": + if err := rpcSetJigglerState(false); err != nil { + mqttLogger.Error().Err(err).Msg("failed to disable jiggler") + } + default: + mqttLogger.Warn().Str("payload", payload).Msg("unknown jiggler command") + } + + // Publish updated state immediately + m.publishJigglerState() +} + +func (m *MQTTManager) handleRebootCommand(client mqtt.Client, msg mqtt.Message) { + mqttLogger.Info().Msg("received reboot command via MQTT") + if err := rpcReboot(false); err != nil { + mqttLogger.Error().Err(err).Msg("failed to reboot") + } +} + +func (m *MQTTManager) handleUpdateInstallCommand(client mqtt.Client, msg mqtt.Message) { + mqttLogger.Info().Msg("received update install command via MQTT") + if err := rpcTryUpdate(); err != nil { + mqttLogger.Error().Err(err).Msg("failed to start update") + } +} + +// --- State Publishing --- + +// PublishATXState publishes the current ATX state to MQTT. +func (m *MQTTManager) PublishATXState(state ATXState) { + if !m.IsConnected() { + return + } + m.publish(m.topic("atx", "state"), state, true) +} + +// PublishDCState publishes the current DC power state to MQTT. +func (m *MQTTManager) PublishDCState(state DCPowerState) { + if !m.IsConnected() { + return + } + m.publish(m.topic("dc", "state"), state, true) +} + +// --- Extended State Payloads --- + +type mqttVideoState struct { + Ready bool `json:"ready"` + Error string `json:"error,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` +} + +type mqttUSBState struct { + State string `json:"state"` +} + +type mqttCloudState struct { + Connected bool `json:"connected"` +} + +type mqttSessionsState struct { + ActiveSessions int `json:"active_sessions"` +} + +type mqttJigglerState struct { + Enabled bool `json:"enabled"` +} + +type mqttNetworkState struct { + IPAddress string `json:"ip_address"` + Hostname string `json:"hostname"` +} + +type mqttUpdateState struct { + InstalledVersion string `json:"installed_version"` + LatestVersion string `json:"latest_version"` +} + +// PublishVideoState publishes the current video state to MQTT. +func (m *MQTTManager) PublishVideoState() { + if !m.IsConnected() { + return + } + state := mqttVideoState{ + Ready: lastVideoState.Ready, + Error: lastVideoState.Error, + Width: lastVideoState.Width, + Height: lastVideoState.Height, + FPS: lastVideoState.FramePerSecond, + } + m.publish(m.topic("video", "state"), state, true) +} + +// publishJigglerState publishes the current jiggler state. +func (m *MQTTManager) publishJigglerState() { + if !m.IsConnected() { + return + } + m.publish(m.topic("jiggler", "state"), mqttJigglerState{ + Enabled: config.JigglerEnabled, + }, true) +} + +// publishNetworkState publishes the current network state. +func (m *MQTTManager) publishNetworkState() { + if !m.IsConnected() || networkManager == nil { + return + } + + netState := mqttNetworkState{} + state, err := networkManager.GetInterfaceState(NetIfName) + if err == nil { + rpcState := state.ToRpcInterfaceState() + if rpcState != nil { + netState.IPAddress = rpcState.IPv4Address + netState.Hostname = rpcState.Hostname + } + } + m.publish(m.topic("network", "state"), netState, true) +} + +// publishUpdateState publishes the current update state. +func (m *MQTTManager) publishUpdateState() { + if !m.IsConnected() { + return + } + + updatePayload := mqttUpdateState{} + + // Get installed version + _, appVer, err := GetLocalVersion() + if err == nil && appVer != nil { + updatePayload.InstalledVersion = appVer.String() + updatePayload.LatestVersion = appVer.String() // Default: no update available + } + + // Check for available update + updateStatus, err := getUpdateStatus(config.IncludePreRelease) + if err == nil && updateStatus != nil { + if updateStatus.Local != nil { + updatePayload.InstalledVersion = updateStatus.Local.AppVersion + } + if updateStatus.Remote != nil && updateStatus.AppUpdateAvailable { + updatePayload.LatestVersion = updateStatus.Remote.AppVersion + } + } + + m.publish(m.topic("update", "state"), updatePayload, true) +} + +// publishExtendedStates publishes all extended metric states. +func (m *MQTTManager) publishExtendedStates() { + // Video state + m.PublishVideoState() + + // USB state + usbPayload := mqttUSBState{ + State: gadget.GetUsbState(), + } + m.publish(m.topic("usb", "state"), usbPayload, true) + + // Cloud state + cloudPayload := mqttCloudState{ + Connected: cloudConnectionState == CloudConnectionStateConnected, + } + m.publish(m.topic("cloud", "state"), cloudPayload, true) + + // Active sessions + sessionsPayload := mqttSessionsState{ + ActiveSessions: getActiveSessions(), + } + m.publish(m.topic("sessions", "state"), sessionsPayload, true) + + // Jiggler state + m.publishJigglerState() + + // Network state + m.publishNetworkState() + + // Update state + m.publishUpdateState() +} + +// StartPeriodicStatusUpdates starts a goroutine that periodically publishes the device status. +func (m *MQTTManager) StartPeriodicStatusUpdates(interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for range ticker.C { + if !m.IsConnected() { + continue + } + m.publish(m.topic("status"), mqttStatusPayload{Online: true}, true) + + // Publish current ATX state only if ATX extension is active + if config.ActiveExtension == "atx-power" { + m.PublishATXState(ATXState{ + Power: ledPWRState, + HDD: ledHDDState, + }) + } + + // Publish current DC state only if DC extension is active + if config.ActiveExtension == "dc-power" { + m.PublishDCState(dcState) + } + + // Publish extended metric states + m.publishExtendedStates() + } + }() +} + +// --- JSON-RPC Handlers --- + +type MQTTSettingsResponse struct { + Enabled bool `json:"enabled"` + Broker string `json:"broker"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + BaseTopic string `json:"base_topic"` + UseTLS bool `json:"use_tls"` + TLSInsecure bool `json:"tls_insecure"` + EnableHADiscovery bool `json:"enable_ha_discovery"` +} + +type MQTTStatusResponse struct { + Connected bool `json:"connected"` +} + +func rpcGetMqttSettings() (MQTTSettingsResponse, error) { + cfg := config.MqttConfig + if cfg == nil { + return MQTTSettingsResponse{}, nil + } + return MQTTSettingsResponse{ + Enabled: cfg.Enabled, + Broker: cfg.Broker, + Port: cfg.Port, + Username: cfg.Username, + Password: cfg.Password, + BaseTopic: cfg.BaseTopic, + UseTLS: cfg.UseTLS, + TLSInsecure: cfg.TLSInsecure, + EnableHADiscovery: cfg.EnableHADiscovery, + }, nil +} + +func rpcSetMqttSettings(settings MQTTSettingsResponse) error { + if settings.Enabled && settings.Broker == "" { + return fmt.Errorf("broker address is required when MQTT is enabled") + } + if settings.Port <= 0 || settings.Port > 65535 { + settings.Port = 1883 + } + if settings.BaseTopic == "" { + settings.BaseTopic = "jetkvm" + } + + oldConfig := config.MqttConfig + + // Cleanup before applying new settings + if mqttManager != nil && mqttManager.IsConnected() { + wasEnabled := oldConfig != nil && oldConfig.Enabled + wasHADiscovery := oldConfig != nil && oldConfig.EnableHADiscovery + + // If MQTT is being disabled, clean up all topics and discovery entries + if wasEnabled && !settings.Enabled { + mqttManager.cleanupAllTopics() + } else if wasHADiscovery && !settings.EnableHADiscovery { + // If only HA Discovery is being disabled, remove discovery entries + mqttManager.removeAllDiscovery() + } + } + + config.MqttConfig = &MQTTConfig{ + Enabled: settings.Enabled, + Broker: settings.Broker, + Port: settings.Port, + Username: settings.Username, + Password: settings.Password, + BaseTopic: settings.BaseTopic, + UseTLS: settings.UseTLS, + TLSInsecure: settings.TLSInsecure, + EnableHADiscovery: settings.EnableHADiscovery, + } + + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + // Reconnect MQTT + restartMQTT() + + return nil +} + +func rpcGetMqttStatus() (MQTTStatusResponse, error) { + connected := false + if mqttManager != nil { + connected = mqttManager.IsConnected() + } + return MQTTStatusResponse{Connected: connected}, nil +} + +// restartMQTT stops the existing MQTT connection and starts a new one if enabled. +func restartMQTT() { + if mqttManager != nil { + mqttManager.Close() + mqttManager = nil + } + startMQTT() + mqttLogger.Info().Msg("MQTT restarted successfully") +} + +func startMQTT() { + if config.MqttConfig == nil || !config.MqttConfig.Enabled { + mqttLogger.Info().Msg("MQTT is disabled") + return + } + + var err error + mqttManager, err = NewMQTTManager(config.MqttConfig, GetDeviceID()) + if err != nil { + mqttLogger.Warn().Err(err).Msg("failed to start MQTT") + return + } + + mqttManager.StartPeriodicStatusUpdates(30 * time.Second) +} + +// initMQTT initializes MQTT if enabled in config. Called from main.go. +func initMQTT() { + startMQTT() + mqttLogger.Info().Msg("MQTT initialized successfully") +} diff --git a/serial.go b/serial.go index 177e3b2e4..4fc4565ad 100644 --- a/serial.go +++ b/serial.go @@ -57,11 +57,17 @@ func runATXControl() { newBtnRSTState := line[2] == '1' newBtnPWRState := line[3] == '1' + atxState := ATXState{ + Power: newLedPWRState, + HDD: newLedHDDState, + } + if currentSession != nil { - writeJSONRPCEvent("atxState", ATXState{ - Power: newLedPWRState, - HDD: newLedHDDState, - }, currentSession) + writeJSONRPCEvent("atxState", atxState, currentSession) + } + + if mqttManager != nil { + mqttManager.PublishATXState(atxState) } if newLedHDDState != ledHDDState || @@ -213,6 +219,10 @@ func runDCControl() { if currentSession != nil { writeJSONRPCEvent("dcState", dcState, currentSession) } + + if mqttManager != nil { + mqttManager.PublishDCState(dcState) + } } } diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 265da5893..eae9a3672 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -614,6 +614,25 @@ "mouse_scroll_throttling_title": "Scroll-Drosselung", "mouse_scroll_very_high": "Sehr hoch", "mouse_title": "Maus", + "mqtt_base_topic_label": "Basis-Topic", + "mqtt_broker_label": "Broker-Adresse", + "mqtt_description": "MQTT-Broker-Verbindung für Home Assistant Integration konfigurieren", + "mqtt_enable_description": "Mit einem MQTT-Broker verbinden, um Gerätestatus und Steuerungen bereitzustellen", + "mqtt_enable_title": "MQTT aktivieren", + "mqtt_ha_discovery_description": "Geräte-Entitäten automatisch über MQTT Discovery in Home Assistant registrieren", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_label": "Passwort", + "mqtt_port_label": "Port", + "mqtt_save_button": "Speichern & Verbinden", + "mqtt_saved_error": "MQTT-Einstellungen konnten nicht gespeichert werden: {error}", + "mqtt_saved_success": "MQTT-Einstellungen erfolgreich gespeichert", + "mqtt_status_connected": "Verbunden", + "mqtt_status_disconnected": "Getrennt", + "mqtt_tls_insecure_description": "Verbindungen mit selbstsignierten oder ungültigen Zertifikaten erlauben", + "mqtt_tls_insecure_title": "Zertifikatsprüfung überspringen", + "mqtt_use_tls_description": "TLS-Verschlüsselung für MQTT-Verbindung aktivieren", + "mqtt_use_tls_title": "TLS verwenden", + "mqtt_username_label": "Benutzername", "network_custom_domain": "Benutzerdefinierte Domäne", "network_description": "Konfigurieren Sie Ihre Netzwerkeinstellungen", "network_dhcp_client_description": "Konfigurieren Sie, welcher DHCP-Client verwendet werden soll", @@ -775,6 +794,7 @@ "settings_keyboard": "Tastatur", "settings_keyboard_macros": "Tastaturmakros", "settings_mouse": "Maus", + "settings_mqtt": "MQTT", "settings_network": "Netzwerk", "settings_video": "Video", "something_went_wrong": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an den Support.", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index e5d44fc66..c1ff7b1fa 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -619,6 +619,25 @@ "mouse_scroll_throttling_title": "Scroll Throttling", "mouse_scroll_very_high": "Very High", "mouse_title": "Mouse", + "mqtt_base_topic_label": "Base Topic", + "mqtt_broker_label": "Broker Address", + "mqtt_description": "Configure MQTT broker connection for Home Assistant integration", + "mqtt_enable_description": "Connect to an MQTT broker to expose device state and controls", + "mqtt_enable_title": "Enable MQTT", + "mqtt_ha_discovery_description": "Automatically register device entities in Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_label": "Password", + "mqtt_port_label": "Port", + "mqtt_save_button": "Save & Connect", + "mqtt_saved_error": "Failed to save MQTT settings: {error}", + "mqtt_saved_success": "MQTT settings saved successfully", + "mqtt_status_connected": "Connected", + "mqtt_status_disconnected": "Disconnected", + "mqtt_tls_insecure_description": "Allow connections with self-signed or invalid certificates", + "mqtt_tls_insecure_title": "Skip Certificate Verification", + "mqtt_use_tls_description": "Enable TLS encryption for MQTT connection", + "mqtt_use_tls_title": "Use TLS", + "mqtt_username_label": "Username", "network_custom_domain": "Custom Domain", "network_description": "Configure your network settings", "network_dhcp_client_description": "Configure which DHCP client to use", @@ -780,6 +799,7 @@ "settings_keyboard": "Keyboard", "settings_keyboard_macros": "Keyboard Macros", "settings_mouse": "Mouse", + "settings_mqtt": "MQTT", "settings_network": "Network", "settings_video": "Video", "something_went_wrong": "Something went wrong. Please try again later or contact support", diff --git a/ui/src/main.tsx b/ui/src/main.tsx index f616fd7be..66f8edcbf 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -55,6 +55,7 @@ const SettingsNetworkRoute = lazy(() => import("@routes/devices.$id.settings.net const SecurityAccessLocalAuthRoute = lazy( () => import("@routes/devices.$id.settings.access.local-auth"), ); +const SettingsMqttRoute = lazy(() => import("@routes/devices.$id.settings.mqtt")); const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros")); const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add")); const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit")); @@ -161,6 +162,10 @@ const getDeviceRoute = (r: Omit): RouteObject path: "network", element: , }, + { + path: "mqtt", + element: , + }, { path: "access", children: [ diff --git a/ui/src/routes/devices.$id.settings.mqtt.tsx b/ui/src/routes/devices.$id.settings.mqtt.tsx new file mode 100644 index 000000000..7da50bbb3 --- /dev/null +++ b/ui/src/routes/devices.$id.settings.mqtt.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from "react"; +import { useJsonRpc } from "@hooks/useJsonRpc"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { SettingsItem } from "@components/SettingsItem"; +import InputField from "@components/InputField"; +import { Checkbox } from "@components/Checkbox"; +import { Button } from "@components/Button"; +import notifications from "@/notifications"; +import { m } from "@localizations/messages"; + +interface MQTTSettings { + enabled: boolean; + broker: string; + port: number; + username: string; + password: string; + base_topic: string; + use_tls: boolean; + tls_insecure: boolean; + enable_ha_discovery: boolean; +} + +interface MQTTStatus { + connected: boolean; +} + +export default function SettingsMqttRoute() { + const { send } = useJsonRpc(); + + const [settings, setSettings] = useState({ + enabled: false, + broker: "", + port: 1883, + username: "", + password: "", + base_topic: "jetkvm", + use_tls: false, + tls_insecure: false, + enable_ha_discovery: true, + }); + + const [status, setStatus] = useState({ connected: false }); + const [saving, setSaving] = useState(false); + + // Fetch current settings + useEffect(() => { + send("getMqttSettings", {}, resp => { + if ("error" in resp) return; + setSettings(resp.result as MQTTSettings); + }); + send("getMqttStatus", {}, resp => { + if ("error" in resp) return; + setStatus(resp.result as MQTTStatus); + }); + }, [send]); + + // Poll connection status + useEffect(() => { + const interval = setInterval(() => { + send("getMqttStatus", {}, resp => { + if ("error" in resp) return; + setStatus(resp.result as MQTTStatus); + }); + }, 5000); + return () => clearInterval(interval); + }, [send]); + + const handleSave = useCallback(() => { + setSaving(true); + send("setMqttSettings", { settings }, resp => { + setSaving(false); + if ("error" in resp) { + notifications.error(m.mqtt_saved_error({ error: resp.error.message || "Unknown error" })); + return; + } + notifications.success(m.mqtt_saved_success()); + // Refresh status after save + setTimeout(() => { + send("getMqttStatus", {}, statusResp => { + if ("error" in statusResp) return; + setStatus(statusResp.result as MQTTStatus); + }); + }, 2000); + }); + }, [send, settings]); + + const updateField = (field: K, value: MQTTSettings[K]) => { + setSettings(prev => ({ ...prev, [field]: value })); + }; + + return ( +
+ + +
+ + updateField("enabled", e.target.checked)} + /> + + + {settings.enabled && ( + <> +
+ + + {status.connected ? m.mqtt_status_connected() : m.mqtt_status_disconnected()} + +
+ + + updateField("broker", e.target.value)} + /> + + + + updateField("port", parseInt(e.target.value) || 1883)} + /> + + + + updateField("username", e.target.value)} + /> + + + + updateField("password", e.target.value)} + /> + + + + updateField("base_topic", e.target.value)} + /> + + + + updateField("use_tls", e.target.checked)} + /> + + + {settings.use_tls && ( + + updateField("tls_insecure", e.target.checked)} + /> + + )} + + + updateField("enable_ha_discovery", e.target.checked)} + /> + + + )} + +
+
+
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index f395284f6..6a0447deb 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -13,6 +13,7 @@ import { LuPalette, LuCommand, LuNetwork, + LuRadio, } from "react-icons/lu"; import { cx } from "@/cva.config"; @@ -224,6 +225,14 @@ export default function SettingsRoute() { +
+ (isActive ? "active" : "")}> +
+ +

{m.settings_mqtt()}

+
+
+
(isActive ? "active" : "")}>
diff --git a/video.go b/video.go index 7ce342a7d..35827f20d 100644 --- a/video.go +++ b/video.go @@ -23,6 +23,11 @@ func triggerVideoStateUpdate() { writeJSONRPCEvent("videoInputState", lastVideoState, currentSession) }() + // Publish video state to MQTT + if mqttManager != nil { + mqttManager.PublishVideoState() + } + nativeLogger.Info().Interface("state", lastVideoState).Msg("video state updated") } From 61f7a7aa3faf1a9a63d61cfe8f6ed077483f1c69 Mon Sep 17 00:00:00 2001 From: Patrick Hofmann Date: Fri, 6 Feb 2026 01:44:25 +0100 Subject: [PATCH 2/6] lint fix --- jsonrpc.go | 2 +- mqtt.go | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index 7aa78d9b7..dccd3f348 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1217,5 +1217,5 @@ var rpcHandlers = map[string]RPCHandler{ "checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses}, "getMqttSettings": {Func: rpcGetMqttSettings}, "setMqttSettings": {Func: rpcSetMqttSettings, Params: []string{"settings"}}, - "getMqttStatus": {Func: rpcGetMqttStatus}, + "getMqttStatus": {Func: rpcGetMqttStatus}, } diff --git a/mqtt.go b/mqtt.go index 2f99a41c1..f0e47d8c8 100644 --- a/mqtt.go +++ b/mqtt.go @@ -439,16 +439,14 @@ func (m *MQTTManager) publishHADiscovery() { activeExtension := config.ActiveExtension - if activeExtension == "atx-power" { + switch activeExtension { + case "atx-power": m.publishATXDiscovery(device, availTopic, availTemplate) - // Explicitly remove DC entities m.removeDCDiscovery() - } else if activeExtension == "dc-power" { + case "dc-power": m.publishDCDiscovery(device, availTopic, availTemplate) - // Explicitly remove ATX entities m.removeATXDiscovery() - } else { - // No power extension active (e.g. serial or none) - remove both + default: m.removeATXDiscovery() m.removeDCDiscovery() } From 9534c7d686dde4a943a8abfd92f1a493aabec651 Mon Sep 17 00:00:00 2001 From: Patrick Hofmann Date: Sat, 7 Feb 2026 01:56:07 +0100 Subject: [PATCH 3/6] - Add EnableActions config toggle to control whether MQTT commands can be executed. When disabled, controllable entities (switches, buttons, select) are replaced with read-only sensors/binary sensors. The firmware update entity remains visible but without install capability. - Fix update entity progress feedback by removing value_template which prevented HA from parsing in_progress/update_percentage fields. Add updateRequested flag and lastKnownLatestVersion cache to bridge the gap between MQTT command and OTA state transitions. - Add system metrics sensors: CPU Load (with state_class measurement for graphs), SoC Temperature, Memory Used, Storage Used, Storage Free. - Add Virtual Media select entity with dynamic options list, mount/unmount support, source attribute (url/storage) --- config.go | 1 + mqtt.go | 780 +++++++++++++++++--- ota.go | 12 + ui/localization/messages/de.json | 2 + ui/localization/messages/en.json | 2 + ui/src/routes/devices.$id.settings.mqtt.tsx | 12 + 6 files changed, 700 insertions(+), 109 deletions(-) diff --git a/config.go b/config.go index 5c4110da9..d2db3f8fc 100644 --- a/config.go +++ b/config.go @@ -204,6 +204,7 @@ func getDefaultConfig() Config { Port: 1883, BaseTopic: "jetkvm", EnableHADiscovery: false, + EnableActions: true, }, } } diff --git a/mqtt.go b/mqtt.go index f0e47d8c8..9d1085d82 100644 --- a/mqtt.go +++ b/mqtt.go @@ -4,11 +4,19 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strconv" "strings" + "syscall" "time" "github.com/gwatts/rootcerts" "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/ota" mqtt "github.com/eclipse/paho.mqtt.golang" ) @@ -25,15 +33,17 @@ type MQTTConfig struct { UseTLS bool `json:"use_tls"` TLSInsecure bool `json:"tls_insecure"` EnableHADiscovery bool `json:"enable_ha_discovery"` + EnableActions bool `json:"enable_actions"` } var mqttManager *MQTTManager type MQTTManager struct { - client mqtt.Client - deviceID string - baseTopic string - connected bool + client mqtt.Client + deviceID string + baseTopic string + connected bool + updateRequested bool // set when an update is triggered via MQTT, cleared when OTA finishes } type mqttStatusPayload struct { @@ -202,6 +212,10 @@ type haDiscoveryPayload struct { ObjectID string `json:"object_id,omitempty"` EnabledByDefault *bool `json:"enabled_by_default,omitempty"` + // Attributes + JsonAttributesTopic string `json:"json_attributes_topic,omitempty"` + JsonAttributesTemplate string `json:"json_attributes_template,omitempty"` + // Select-specific Options []string `json:"options,omitempty"` @@ -236,7 +250,7 @@ func (m *MQTTManager) haDeviceInfo() *haDevice { sysVer, appVer, err := GetLocalVersion() if err == nil { if appVer != nil && sysVer != nil { - swVersion = fmt.Sprintf("App %s / System %s", appVer.String(), sysVer.String()) + swVersion = fmt.Sprintf("App: %s | Sys: %s", appVer.String(), sysVer.String()) } else if appVer != nil { swVersion = fmt.Sprintf("App %s", appVer.String()) } @@ -388,63 +402,217 @@ func (m *MQTTManager) publishHADiscovery() { Device: device, }) - // Switch: Mouse Jiggler - m.publishDiscovery("switch", "jiggler", haDiscoveryPayload{ - Name: "Mouse Jiggler", - UniqueID: fmt.Sprintf("jetkvm_%s_jiggler", m.deviceID), - StateTopic: m.topic("jiggler", "state"), - CommandTopic: m.topic("jiggler", "set"), - ValueTemplate: "{{ 'ON' if value_json.enabled else 'OFF' }}", - PayloadOn: "ON", - PayloadOff: "OFF", - Icon: "mdi:mouse", + // Sensor: CPU Load (diagnostic, disabled by default) + m.publishDiscovery("sensor", "cpu_load", haDiscoveryPayload{ + Name: "CPU Load", + UniqueID: fmt.Sprintf("jetkvm_%s_cpu_load", m.deviceID), + StateTopic: m.topic("system", "state"), + ValueTemplate: "{{ value_json.cpu_load | round(2) }}", + StateClass: "measurement", + Icon: "mdi:chip", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, AvailabilityTopic: availTopic, AvailTemplate: availTemplate, Device: device, }) - // Button: Reboot - m.publishDiscovery("button", "reboot", haDiscoveryPayload{ - Name: "Reboot", - UniqueID: fmt.Sprintf("jetkvm_%s_reboot", m.deviceID), - CommandTopic: m.topic("reboot", "set"), - PayloadPress: "PRESS", - DeviceClass: "restart", - Icon: "mdi:restart", - EntityCategory: "config", + // Sensor: Temperature + m.publishDiscovery("sensor", "temperature", haDiscoveryPayload{ + Name: "Temperature", + UniqueID: fmt.Sprintf("jetkvm_%s_temperature", m.deviceID), + StateTopic: m.topic("system", "state"), + ValueTemplate: "{{ value_json.temperature | round(1) }}", + DeviceClass: "temperature", + UnitOfMeasurement: "°C", + StateClass: "measurement", + EntityCategory: "diagnostic", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Memory Usage (diagnostic, disabled by default) + m.publishDiscovery("sensor", "memory_used", haDiscoveryPayload{ + Name: "Memory Used", + UniqueID: fmt.Sprintf("jetkvm_%s_memory_used", m.deviceID), + StateTopic: m.topic("system", "state"), + ValueTemplate: "{{ (value_json.memory_used / 1048576) | round(1) }}", + UnitOfMeasurement: "MB", + Icon: "mdi:memory", + StateClass: "measurement", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + // Sensor: Storage Used (diagnostic, disabled by default) + m.publishDiscovery("sensor", "storage_used", haDiscoveryPayload{ + Name: "Storage Used", + UniqueID: fmt.Sprintf("jetkvm_%s_storage_used", m.deviceID), + StateTopic: m.topic("system", "state"), + ValueTemplate: "{{ (value_json.storage_used / 1048576) | round(1) }}", + DeviceClass: "data_size", + UnitOfMeasurement: "MB", + StateClass: "measurement", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, AvailabilityTopic: availTopic, AvailTemplate: availTemplate, Device: device, }) - // Update: Firmware Update - m.publishDiscovery("update", "firmware", haDiscoveryPayload{ - Name: "Firmware", - UniqueID: fmt.Sprintf("jetkvm_%s_firmware", m.deviceID), - StateTopic: m.topic("update", "state"), - LatestVersionTopic: m.topic("update", "state"), - LatestVersionTemplate: "{{ value_json.latest_version }}", - ValueTemplate: "{{ value_json.installed_version }}", - CommandTopic: m.topic("update", "install"), - PayloadInstall: "INSTALL", - DeviceClass: "firmware", - EntityCategory: "config", - ReleaseURL: "https://github.com/jetkvm/kvm/releases", - AvailabilityTopic: availTopic, - AvailTemplate: availTemplate, - Device: device, + // Sensor: Storage Free (diagnostic, disabled by default) + m.publishDiscovery("sensor", "storage_free", haDiscoveryPayload{ + Name: "Storage Free", + UniqueID: fmt.Sprintf("jetkvm_%s_storage_free", m.deviceID), + StateTopic: m.topic("system", "state"), + ValueTemplate: "{{ (value_json.storage_free / 1048576) | round(1) }}", + DeviceClass: "data_size", + UnitOfMeasurement: "MB", + StateClass: "measurement", + EntityCategory: "diagnostic", + EnabledByDefault: disabledByDefault, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, }) + // Virtual Media: Select (actions enabled) or Sensor (actions disabled) + actionsEnabled := config.MqttConfig != nil && config.MqttConfig.EnableActions + vmAttrsTopic := m.topic("virtual_media", "state") + vmAttrsTemplate := "{{ {'source': value_json.source} | tojson }}" + if actionsEnabled { + // Build options list, including currently mounted URL image if applicable + vmOptions := getAvailableImages() + virtualMediaStateMutex.RLock() + if currentVirtualMediaState != nil && currentVirtualMediaState.Source == HTTP { + imageName := currentVirtualMediaState.URL + if parsed, err := url.Parse(currentVirtualMediaState.URL); err == nil { + base := path.Base(parsed.Path) + if base != "" && base != "." && base != "/" { + imageName = base + } + } + vmOptions = append(vmOptions, imageName) + } + virtualMediaStateMutex.RUnlock() + + // Remove read-only sensor variant if it exists, then publish select + m.removeDiscovery("sensor", "virtual_media") + m.publishDiscovery("select", "virtual_media", haDiscoveryPayload{ + Name: "Virtual Media", + UniqueID: fmt.Sprintf("jetkvm_%s_virtual_media", m.deviceID), + StateTopic: m.topic("virtual_media", "state"), + CommandTopic: m.topic("virtual_media", "set"), + ValueTemplate: "{{ value_json.mounted_image }}", + Options: vmOptions, + Icon: "mdi:disc", + JsonAttributesTopic: vmAttrsTopic, + JsonAttributesTemplate: vmAttrsTemplate, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + // Remove select variant, then publish read-only sensor + m.removeDiscovery("select", "virtual_media") + m.publishDiscovery("sensor", "virtual_media", haDiscoveryPayload{ + Name: "Virtual Media", + UniqueID: fmt.Sprintf("jetkvm_%s_virtual_media", m.deviceID), + StateTopic: m.topic("virtual_media", "state"), + ValueTemplate: "{{ value_json.mounted_image }}", + Icon: "mdi:disc", + JsonAttributesTopic: vmAttrsTopic, + JsonAttributesTemplate: vmAttrsTemplate, + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } + + // Mouse Jiggler: Switch (actions enabled) or Binary Sensor (actions disabled) + if actionsEnabled { + // Remove read-only variant if it exists, then publish switch + m.removeDiscovery("binary_sensor", "jiggler") + m.publishDiscovery("switch", "jiggler", haDiscoveryPayload{ + Name: "Mouse Jiggler", + UniqueID: fmt.Sprintf("jetkvm_%s_jiggler", m.deviceID), + StateTopic: m.topic("jiggler", "state"), + CommandTopic: m.topic("jiggler", "set"), + ValueTemplate: "{{ 'ON' if value_json.enabled else 'OFF' }}", + PayloadOn: "ON", + PayloadOff: "OFF", + Icon: "mdi:mouse", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + // Remove switch variant, then publish read-only binary sensor + m.removeDiscovery("switch", "jiggler") + m.publishDiscovery("binary_sensor", "jiggler", haDiscoveryPayload{ + Name: "Mouse Jiggler", + UniqueID: fmt.Sprintf("jetkvm_%s_jiggler", m.deviceID), + StateTopic: m.topic("jiggler", "state"), + ValueTemplate: "{{ 'ON' if value_json.enabled else 'OFF' }}", + Icon: "mdi:mouse", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } + + // Reboot Button: only when actions enabled + if actionsEnabled { + m.publishDiscovery("button", "reboot", haDiscoveryPayload{ + Name: "Reboot", + UniqueID: fmt.Sprintf("jetkvm_%s_reboot", m.deviceID), + CommandTopic: m.topic("reboot", "set"), + PayloadPress: "PRESS", + DeviceClass: "restart", + Icon: "mdi:restart", + EntityCategory: "config", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + m.removeDiscovery("button", "reboot") + } + + // Firmware Update: always published, but command_topic only when actions enabled. + // NOTE: Do NOT use value_template/latest_version_template here — HA needs to parse + // the full JSON directly to recognize in_progress and update_percentage fields. + firmwarePayload := haDiscoveryPayload{ + Name: "Firmware", + UniqueID: fmt.Sprintf("jetkvm_%s_firmware", m.deviceID), + StateTopic: m.topic("update", "state"), + DeviceClass: "firmware", + EntityCategory: "config", + ReleaseURL: "https://github.com/jetkvm/kvm/releases", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + } + if actionsEnabled { + firmwarePayload.CommandTopic = m.topic("update", "install") + firmwarePayload.PayloadInstall = "INSTALL" + } + m.publishDiscovery("update", "firmware", firmwarePayload) + // --- Extension-dependent entities --- activeExtension := config.ActiveExtension switch activeExtension { case "atx-power": - m.publishATXDiscovery(device, availTopic, availTemplate) + m.publishATXDiscovery(device, availTopic, availTemplate, actionsEnabled) m.removeDCDiscovery() case "dc-power": - m.publishDCDiscovery(device, availTopic, availTemplate) + m.publishDCDiscovery(device, availTopic, availTemplate, actionsEnabled) m.removeATXDiscovery() default: m.removeATXDiscovery() @@ -454,8 +622,8 @@ func (m *MQTTManager) publishHADiscovery() { mqttLogger.Info().Str("extension", activeExtension).Msg("published Home Assistant discovery configs") } -func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTemplate string) { - // Binary sensor: ATX Power LED +func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTemplate string, actionsEnabled bool) { + // Binary sensor: ATX Power LED (always published as read-only) m.publishDiscovery("binary_sensor", "power_led", haDiscoveryPayload{ Name: "ATX Power LED", UniqueID: fmt.Sprintf("jetkvm_%s_power_led", m.deviceID), @@ -468,7 +636,7 @@ func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTem Device: device, }) - // Binary sensor: ATX HDD LED + // Binary sensor: ATX HDD LED (always published as read-only) m.publishDiscovery("binary_sensor", "hdd_led", haDiscoveryPayload{ Name: "ATX HDD LED", UniqueID: fmt.Sprintf("jetkvm_%s_hdd_led", m.deviceID), @@ -480,45 +648,49 @@ func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTem Device: device, }) - // Button: ATX Power Short Press - m.publishDiscovery("button", "atx_power_short", haDiscoveryPayload{ - Name: "ATX Power (Short Press)", - UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_short", m.deviceID), - CommandTopic: m.topic("atx_power_short", "set"), - PayloadPress: "PRESS", - Icon: "mdi:power", - AvailabilityTopic: availTopic, - AvailTemplate: availTemplate, - Device: device, - }) - - // Button: ATX Power Long Press - m.publishDiscovery("button", "atx_power_long", haDiscoveryPayload{ - Name: "ATX Power (Long Press)", - UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_long", m.deviceID), - CommandTopic: m.topic("atx_power_long", "set"), - PayloadPress: "PRESS", - Icon: "mdi:power", - AvailabilityTopic: availTopic, - AvailTemplate: availTemplate, - Device: device, - }) - - // Button: ATX Reset - m.publishDiscovery("button", "atx_reset", haDiscoveryPayload{ - Name: "ATX Reset", - UniqueID: fmt.Sprintf("jetkvm_%s_atx_reset", m.deviceID), - CommandTopic: m.topic("atx_reset", "set"), - PayloadPress: "PRESS", - Icon: "mdi:restart", - AvailabilityTopic: availTopic, - AvailTemplate: availTemplate, - Device: device, - }) + // ATX Buttons: only when actions enabled + if actionsEnabled { + m.publishDiscovery("button", "atx_power_short", haDiscoveryPayload{ + Name: "ATX Power (Short Press)", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_short", m.deviceID), + CommandTopic: m.topic("atx_power_short", "set"), + PayloadPress: "PRESS", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + m.publishDiscovery("button", "atx_power_long", haDiscoveryPayload{ + Name: "ATX Power (Long Press)", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_power_long", m.deviceID), + CommandTopic: m.topic("atx_power_long", "set"), + PayloadPress: "PRESS", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + + m.publishDiscovery("button", "atx_reset", haDiscoveryPayload{ + Name: "ATX Reset", + UniqueID: fmt.Sprintf("jetkvm_%s_atx_reset", m.deviceID), + CommandTopic: m.topic("atx_reset", "set"), + PayloadPress: "PRESS", + Icon: "mdi:restart", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + m.removeDiscovery("button", "atx_power_short") + m.removeDiscovery("button", "atx_power_long") + m.removeDiscovery("button", "atx_reset") + } } -func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemplate string) { - // Sensor: DC Voltage +func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemplate string, actionsEnabled bool) { + // Sensor: DC Voltage (always read-only) m.publishDiscovery("sensor", "voltage", haDiscoveryPayload{ Name: "DC Voltage", UniqueID: fmt.Sprintf("jetkvm_%s_voltage", m.deviceID), @@ -532,7 +704,7 @@ func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemp Device: device, }) - // Sensor: DC Current + // Sensor: DC Current (always read-only) m.publishDiscovery("sensor", "current", haDiscoveryPayload{ Name: "DC Current", UniqueID: fmt.Sprintf("jetkvm_%s_current", m.deviceID), @@ -546,7 +718,7 @@ func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemp Device: device, }) - // Sensor: DC Power + // Sensor: DC Power (always read-only) m.publishDiscovery("sensor", "power", haDiscoveryPayload{ Name: "DC Power", UniqueID: fmt.Sprintf("jetkvm_%s_power", m.deviceID), @@ -560,21 +732,39 @@ func (m *MQTTManager) publishDCDiscovery(device *haDevice, availTopic, availTemp Device: device, }) - // Switch: DC Power - m.publishDiscovery("switch", "dc_power", haDiscoveryPayload{ - Name: "DC Power", - UniqueID: fmt.Sprintf("jetkvm_%s_dc_power", m.deviceID), - StateTopic: m.topic("dc", "state"), - CommandTopic: m.topic("dc_power", "set"), - ValueTemplate: "{{ 'ON' if value_json.isOn else 'OFF' }}", - PayloadOn: "ON", - PayloadOff: "OFF", - DeviceClass: "switch", - Icon: "mdi:power", - AvailabilityTopic: availTopic, - AvailTemplate: availTemplate, - Device: device, - }) + // DC Power: Switch (actions enabled) or Binary Sensor (actions disabled) + if actionsEnabled { + // Remove read-only variant if it exists, then publish switch + m.removeDiscovery("binary_sensor", "dc_power") + m.publishDiscovery("switch", "dc_power", haDiscoveryPayload{ + Name: "DC Power", + UniqueID: fmt.Sprintf("jetkvm_%s_dc_power", m.deviceID), + StateTopic: m.topic("dc", "state"), + CommandTopic: m.topic("dc_power", "set"), + ValueTemplate: "{{ 'ON' if value_json.isOn else 'OFF' }}", + PayloadOn: "ON", + PayloadOff: "OFF", + DeviceClass: "switch", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } else { + // Remove switch variant, then publish read-only binary sensor + m.removeDiscovery("switch", "dc_power") + m.publishDiscovery("binary_sensor", "dc_power", haDiscoveryPayload{ + Name: "DC Power", + UniqueID: fmt.Sprintf("jetkvm_%s_dc_power", m.deviceID), + StateTopic: m.topic("dc", "state"), + ValueTemplate: "{{ 'ON' if value_json.isOn else 'OFF' }}", + DeviceClass: "power", + Icon: "mdi:power", + AvailabilityTopic: availTopic, + AvailTemplate: availTemplate, + Device: device, + }) + } } func (m *MQTTManager) publishDiscovery(component, objectID string, payload haDiscoveryPayload) { @@ -618,13 +808,26 @@ func (m *MQTTManager) removeAllDiscovery() { m.removeDiscovery("binary_sensor", "usb_state") m.removeDiscovery("sensor", "ip_address") m.removeDiscovery("sensor", "hostname") + // System metrics + m.removeDiscovery("sensor", "cpu_load") + m.removeDiscovery("sensor", "temperature") + m.removeDiscovery("sensor", "memory_used") + m.removeDiscovery("sensor", "storage_used") + m.removeDiscovery("sensor", "storage_free") + // Virtual media can be select or sensor depending on actions setting + m.removeDiscovery("select", "virtual_media") + m.removeDiscovery("sensor", "virtual_media") + // Jiggler can be switch or binary_sensor depending on actions setting m.removeDiscovery("switch", "jiggler") + m.removeDiscovery("binary_sensor", "jiggler") m.removeDiscovery("button", "reboot") m.removeDiscovery("update", "firmware") - // Extension-specific entities + // Extension-specific entities (both switch and binary_sensor variants) m.removeATXDiscovery() m.removeDCDiscovery() + // Also remove read-only DC power variant + m.removeDiscovery("binary_sensor", "dc_power") mqttLogger.Info().Msg("removed all HA discovery entries") } @@ -643,6 +846,8 @@ func (m *MQTTManager) cleanupAllTopics() { m.topic("usb", "state"), m.topic("jiggler", "state"), m.topic("network", "state"), + m.topic("system", "state"), + m.topic("virtual_media", "state"), m.topic("update", "state"), m.topic("atx", "state"), m.topic("dc", "state"), @@ -670,6 +875,11 @@ func (m *MQTTManager) RepublishHADiscovery() { mqttLogger.Info().Str("extension", config.ActiveExtension).Msg("republished HA discovery after extension change") } +// actionsAllowed checks if MQTT actions are enabled in the config. +func (m *MQTTManager) actionsAllowed() bool { + return config.MqttConfig != nil && config.MqttConfig.EnableActions +} + // --- Command Subscriptions --- func (m *MQTTManager) subscribeCommands() { @@ -681,6 +891,7 @@ func (m *MQTTManager) subscribeCommands() { m.topic("jiggler", "set"): m.handleJigglerCommand, m.topic("reboot", "set"): m.handleRebootCommand, m.topic("update", "install"): m.handleUpdateInstallCommand, + m.topic("virtual_media", "set"): m.handleVirtualMediaCommand, } for topic, handler := range commands { @@ -693,6 +904,10 @@ func (m *MQTTManager) subscribeCommands() { } func (m *MQTTManager) handleDCPowerCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("DC power command rejected: actions are disabled") + return + } payload := strings.TrimSpace(string(msg.Payload())) mqttLogger.Info().Str("payload", payload).Msg("received DC power command") @@ -711,6 +926,10 @@ func (m *MQTTManager) handleDCPowerCommand(client mqtt.Client, msg mqtt.Message) } func (m *MQTTManager) handleATXPowerShortCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("ATX power short command rejected: actions are disabled") + return + } mqttLogger.Info().Msg("received ATX power short press command") if err := pressATXPowerButton(500 * time.Millisecond); err != nil { mqttLogger.Error().Err(err).Msg("failed to press ATX power button (short)") @@ -718,6 +937,10 @@ func (m *MQTTManager) handleATXPowerShortCommand(client mqtt.Client, msg mqtt.Me } func (m *MQTTManager) handleATXPowerLongCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("ATX power long command rejected: actions are disabled") + return + } mqttLogger.Info().Msg("received ATX power long press command") if err := pressATXPowerButton(5 * time.Second); err != nil { mqttLogger.Error().Err(err).Msg("failed to press ATX power button (long)") @@ -725,6 +948,10 @@ func (m *MQTTManager) handleATXPowerLongCommand(client mqtt.Client, msg mqtt.Mes } func (m *MQTTManager) handleATXResetCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("ATX reset command rejected: actions are disabled") + return + } mqttLogger.Info().Msg("received ATX reset command") if err := pressATXResetButton(500 * time.Millisecond); err != nil { mqttLogger.Error().Err(err).Msg("failed to press ATX reset button") @@ -732,6 +959,10 @@ func (m *MQTTManager) handleATXResetCommand(client mqtt.Client, msg mqtt.Message } func (m *MQTTManager) handleJigglerCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("jiggler command rejected: actions are disabled") + return + } payload := strings.TrimSpace(string(msg.Payload())) mqttLogger.Info().Str("payload", payload).Msg("received jiggler command") @@ -753,6 +984,10 @@ func (m *MQTTManager) handleJigglerCommand(client mqtt.Client, msg mqtt.Message) } func (m *MQTTManager) handleRebootCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("reboot command rejected: actions are disabled") + return + } mqttLogger.Info().Msg("received reboot command via MQTT") if err := rpcReboot(false); err != nil { mqttLogger.Error().Err(err).Msg("failed to reboot") @@ -760,12 +995,80 @@ func (m *MQTTManager) handleRebootCommand(client mqtt.Client, msg mqtt.Message) } func (m *MQTTManager) handleUpdateInstallCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("update install command rejected: actions are disabled") + return + } mqttLogger.Info().Msg("received update install command via MQTT") + + // Set flag to keep in_progress state until OTA state confirms updating + m.updateRequested = true + + // Determine latest version for the in_progress message + latestVer := lastKnownLatestVersion + if latestVer == "" { + latestVer = m.getInstalledVersion() + } + + // Immediately publish in_progress state so HA shows the update dialog + var zero float32 + m.publish(m.topic("update", "state"), mqttUpdateState{ + InstalledVersion: m.getInstalledVersion(), + LatestVersion: latestVer, + InProgress: true, + UpdatePercentage: &zero, + }, true) + if err := rpcTryUpdate(); err != nil { mqttLogger.Error().Err(err).Msg("failed to start update") + m.updateRequested = false + // Reset in_progress on failure + m.publishUpdateState() } } +func (m *MQTTManager) handleVirtualMediaCommand(client mqtt.Client, msg mqtt.Message) { + if !m.actionsAllowed() { + mqttLogger.Warn().Msg("virtual media command rejected: actions are disabled") + return + } + payload := strings.TrimSpace(string(msg.Payload())) + mqttLogger.Info().Str("payload", payload).Msg("received virtual media command") + + if payload == "none" { + // Unmount current image + if err := rpcUnmountImage(); err != nil { + mqttLogger.Error().Err(err).Msg("failed to unmount image") + } + } else { + // Check if something is already mounted, unmount first + virtualMediaStateMutex.RLock() + mounted := currentVirtualMediaState != nil + virtualMediaStateMutex.RUnlock() + if mounted { + if err := rpcUnmountImage(); err != nil { + mqttLogger.Error().Err(err).Msg("failed to unmount current image before mounting new one") + return + } + } + + // Verify the file exists + fullPath := filepath.Join(imagesFolder, payload) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + mqttLogger.Error().Str("filename", payload).Msg("image file not found") + return + } + + // Mount the image as Disk mode (default) + if err := rpcMountWithStorage(payload, Disk); err != nil { + mqttLogger.Error().Err(err).Msg("failed to mount image") + } + } + + // Publish updated state immediately + m.publishVirtualMediaState() +} + // --- State Publishing --- // PublishATXState publishes the current ATX state to MQTT. @@ -816,8 +1119,24 @@ type mqttNetworkState struct { } type mqttUpdateState struct { - InstalledVersion string `json:"installed_version"` - LatestVersion string `json:"latest_version"` + InstalledVersion string `json:"installed_version"` + LatestVersion string `json:"latest_version"` + InProgress bool `json:"in_progress"` + UpdatePercentage *float32 `json:"update_percentage"` +} + +type mqttSystemState struct { + CPULoad float64 `json:"cpu_load"` + Temperature float64 `json:"temperature"` + MemoryUsed uint64 `json:"memory_used"` + MemoryTotal uint64 `json:"memory_total"` + StorageUsed int64 `json:"storage_used"` + StorageFree int64 `json:"storage_free"` +} + +type mqttVirtualMediaState struct { + MountedImage string `json:"mounted_image"` + Source string `json:"source"` } // PublishVideoState publishes the current video state to MQTT. @@ -863,12 +1182,178 @@ func (m *MQTTManager) publishNetworkState() { m.publish(m.topic("network", "state"), netState, true) } +// publishSystemState publishes CPU load, temperature, memory and storage metrics. +func (m *MQTTManager) publishSystemState() { + if !m.IsConnected() { + return + } + + state := mqttSystemState{} + + // CPU load average (1 min) from /proc/loadavg + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if load, err := strconv.ParseFloat(fields[0], 64); err == nil { + state.CPULoad = load + } + } + } + + // SoC temperature from thermal zone + if data, err := os.ReadFile("/sys/class/thermal/thermal_zone0/temp"); err == nil { + if temp, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil { + state.Temperature = temp / 1000.0 // millidegrees to degrees + } + } + + // Memory from runtime + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + state.MemoryUsed = memStats.Sys + state.MemoryTotal = memStats.Sys // Go runtime Sys is total obtained from OS + + // Also try /proc/meminfo for actual system memory + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + switch fields[0] { + case "MemTotal:": + state.MemoryTotal = val * 1024 // kB to bytes + case "MemAvailable:": + state.MemoryUsed = state.MemoryTotal - (val * 1024) + } + } + } + + // Storage space + var stat syscall.Statfs_t + if err := syscall.Statfs(imagesFolder, &stat); err == nil { + totalSpace := stat.Blocks * uint64(stat.Bsize) + freeSpace := stat.Bfree * uint64(stat.Bsize) + state.StorageUsed = int64(totalSpace - freeSpace) + state.StorageFree = int64(freeSpace) + } + + m.publish(m.topic("system", "state"), state, true) +} + +// publishVirtualMediaState publishes the currently mounted disk image. +func (m *MQTTManager) publishVirtualMediaState() { + if !m.IsConnected() { + return + } + + state := mqttVirtualMediaState{ + MountedImage: "none", + Source: "none", + } + + virtualMediaStateMutex.RLock() + if currentVirtualMediaState != nil { + switch currentVirtualMediaState.Source { + case Storage: + if currentVirtualMediaState.Filename != "" { + state.MountedImage = currentVirtualMediaState.Filename + state.Source = "storage" + } + case HTTP: + state.Source = "url" + // Extract just the filename from the URL path + imageName := currentVirtualMediaState.URL + if parsed, err := url.Parse(currentVirtualMediaState.URL); err == nil { + base := path.Base(parsed.Path) + if base != "" && base != "." && base != "/" { + imageName = base + } + } + state.MountedImage = imageName + } + } + virtualMediaStateMutex.RUnlock() + + m.publish(m.topic("virtual_media", "state"), state, true) + + // Re-publish discovery to update select options (e.g. when URL image is mounted/unmounted) + if config.MqttConfig != nil && config.MqttConfig.EnableHADiscovery && config.MqttConfig.EnableActions { + vmOptions := getAvailableImages() + if state.Source == "url" { + vmOptions = append(vmOptions, state.MountedImage) + } + m.publishDiscovery("select", "virtual_media", haDiscoveryPayload{ + Name: "Virtual Media", + UniqueID: fmt.Sprintf("jetkvm_%s_virtual_media", m.deviceID), + StateTopic: m.topic("virtual_media", "state"), + CommandTopic: m.topic("virtual_media", "set"), + ValueTemplate: "{{ value_json.mounted_image }}", + Options: vmOptions, + Icon: "mdi:disc", + JsonAttributesTopic: m.topic("virtual_media", "state"), + JsonAttributesTemplate: "{{ {'source': value_json.source} | tojson }}", + AvailabilityTopic: m.topic("status"), + AvailTemplate: "{{ 'online' if value_json.online else 'offline' }}", + Device: m.haDeviceInfo(), + }) + } +} + +// getAvailableImages returns a list of filenames available for mounting. +func getAvailableImages() []string { + options := []string{"none"} + files, err := os.ReadDir(imagesFolder) + if err != nil { + return options + } + for _, file := range files { + if file.IsDir() { + continue + } + options = append(options, file.Name()) + } + return options +} + +// lastKnownLatestVersion stores the latest version to avoid losing it during OTA. +var lastKnownLatestVersion string + // publishUpdateState publishes the current update state. func (m *MQTTManager) publishUpdateState() { if !m.IsConnected() { return } + // Check if an update is currently in progress via OTA state + otaUpdating := false + var otaRPCState *ota.RPCState + if otaState != nil { + otaRPCState = otaState.ToRPCState() + if otaRPCState != nil && otaRPCState.Updating { + otaUpdating = true + } + } + + // Determine effective updating state: + // - OTA says updating → definitely updating + // - We requested update via MQTT but OTA hasn't started yet → still updating (bridge the gap) + // - OTA finished (was requested, OTA no longer updating) → clear the flag + if m.updateRequested && !otaUpdating { + // Check if OTA has actually run and finished (error field set or metadata fetched after request) + if otaRPCState != nil && otaRPCState.Error != "" { + // OTA encountered an error, clear the flag + m.updateRequested = false + } + // Otherwise keep updateRequested=true to bridge the gap + } + updating := otaUpdating || m.updateRequested + updatePayload := mqttUpdateState{} // Get installed version @@ -878,20 +1363,88 @@ func (m *MQTTManager) publishUpdateState() { updatePayload.LatestVersion = appVer.String() // Default: no update available } - // Check for available update - updateStatus, err := getUpdateStatus(config.IncludePreRelease) - if err == nil && updateStatus != nil { - if updateStatus.Local != nil { - updatePayload.InstalledVersion = updateStatus.Local.AppVersion + if updating { + // During an active update, do NOT call getUpdateStatus (it may reset versions). + // Use the last known latest version and only update progress. + if lastKnownLatestVersion != "" { + updatePayload.LatestVersion = lastKnownLatestVersion + } + updatePayload.InProgress = true + if otaState != nil { + rpcState := otaState.ToRPCState() + if rpcState != nil { + progress := calculateOTAProgress(rpcState) + updatePayload.UpdatePercentage = &progress + } } - if updateStatus.Remote != nil && updateStatus.AppUpdateAvailable { - updatePayload.LatestVersion = updateStatus.Remote.AppVersion + } else { + // Not updating: safe to query the update API for version info + updateStatus, statusErr := getUpdateStatus(config.IncludePreRelease) + if statusErr == nil && updateStatus != nil { + if updateStatus.Local != nil { + updatePayload.InstalledVersion = updateStatus.Local.AppVersion + } + if updateStatus.Remote != nil && updateStatus.AppUpdateAvailable { + updatePayload.LatestVersion = updateStatus.Remote.AppVersion + // Remember the latest version for when an update starts + lastKnownLatestVersion = updateStatus.Remote.AppVersion + } } + // Reset progress fields when not updating + updatePayload.InProgress = false + updatePayload.UpdatePercentage = nil } m.publish(m.topic("update", "state"), updatePayload, true) } +// getInstalledVersion returns the current installed app version as string. +func (m *MQTTManager) getInstalledVersion() string { + _, appVer, err := GetLocalVersion() + if err == nil && appVer != nil { + return appVer.String() + } + return "unknown" +} + +// calculateOTAProgress computes an overall update percentage (0-100) from the OTA state. +func calculateOTAProgress(state *ota.RPCState) float32 { + // Weight: download 40%, verification 20%, install 40% + var total float32 + var components float32 + + for _, prefix := range []struct { + download *float32 + verification *float32 + update *float32 + }{ + {state.AppDownloadProgress, state.AppVerificationProgress, state.AppUpdateProgress}, + {state.SystemDownloadProgress, state.SystemVerificationProgress, state.SystemUpdateProgress}, + } { + hasAny := prefix.download != nil || prefix.verification != nil || prefix.update != nil + if !hasAny { + continue + } + components++ + var dl, ver, upd float32 + if prefix.download != nil { + dl = *prefix.download + } + if prefix.verification != nil { + ver = *prefix.verification + } + if prefix.update != nil { + upd = *prefix.update + } + total += dl*40 + ver*20 + upd*40 + } + + if components == 0 { + return 0 + } + return total / components +} + // publishExtendedStates publishes all extended metric states. func (m *MQTTManager) publishExtendedStates() { // Video state @@ -921,6 +1474,12 @@ func (m *MQTTManager) publishExtendedStates() { // Network state m.publishNetworkState() + // System state (CPU, temp, memory, storage) + m.publishSystemState() + + // Virtual media state + m.publishVirtualMediaState() + // Update state m.publishUpdateState() } @@ -966,6 +1525,7 @@ type MQTTSettingsResponse struct { UseTLS bool `json:"use_tls"` TLSInsecure bool `json:"tls_insecure"` EnableHADiscovery bool `json:"enable_ha_discovery"` + EnableActions bool `json:"enable_actions"` } type MQTTStatusResponse struct { @@ -987,6 +1547,7 @@ func rpcGetMqttSettings() (MQTTSettingsResponse, error) { UseTLS: cfg.UseTLS, TLSInsecure: cfg.TLSInsecure, EnableHADiscovery: cfg.EnableHADiscovery, + EnableActions: cfg.EnableActions, }, nil } @@ -1027,6 +1588,7 @@ func rpcSetMqttSettings(settings MQTTSettingsResponse) error { UseTLS: settings.UseTLS, TLSInsecure: settings.TLSInsecure, EnableHADiscovery: settings.EnableHADiscovery, + EnableActions: settings.EnableActions, } if err := SaveConfig(); err != nil { diff --git a/ota.go b/ota.go index ef7f9c21a..779a158d1 100644 --- a/ota.go +++ b/ota.go @@ -35,9 +35,21 @@ func initOta() { SetAutoUpdate: rpcSetAutoUpdateState, OnStateUpdate: func(state *ota.RPCState) { triggerOTAStateUpdate(state) + // Also update MQTT update state for HA progress feedback + if mqttManager != nil { + // Clear updateRequested flag when OTA transitions to not-updating + if state != nil && !state.Updating && mqttManager.updateRequested { + mqttManager.updateRequested = false + } + mqttManager.publishUpdateState() + } }, OnProgressUpdate: func(progress float32) { writeJSONRPCEvent("otaProgress", progress, currentSession) + // Also update MQTT update state for HA progress feedback + if mqttManager != nil { + mqttManager.publishUpdateState() + } }, }) } diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index eae9a3672..31352a133 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -617,6 +617,8 @@ "mqtt_base_topic_label": "Basis-Topic", "mqtt_broker_label": "Broker-Adresse", "mqtt_description": "MQTT-Broker-Verbindung für Home Assistant Integration konfigurieren", + "mqtt_enable_actions_description": "Ferngesteuerte Aktionen (ATX/DC-Steuerung, Neustart, Jiggler, Updates) per MQTT erlauben", + "mqtt_enable_actions_title": "Aktionen erlauben", "mqtt_enable_description": "Mit einem MQTT-Broker verbinden, um Gerätestatus und Steuerungen bereitzustellen", "mqtt_enable_title": "MQTT aktivieren", "mqtt_ha_discovery_description": "Geräte-Entitäten automatisch über MQTT Discovery in Home Assistant registrieren", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index c1ff7b1fa..e13b7907d 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -622,6 +622,8 @@ "mqtt_base_topic_label": "Base Topic", "mqtt_broker_label": "Broker Address", "mqtt_description": "Configure MQTT broker connection for Home Assistant integration", + "mqtt_enable_actions_description": "Allow remote actions (ATX/DC power, reboot, jiggler, updates) to be triggered via MQTT", + "mqtt_enable_actions_title": "Allow Actions", "mqtt_enable_description": "Connect to an MQTT broker to expose device state and controls", "mqtt_enable_title": "Enable MQTT", "mqtt_ha_discovery_description": "Automatically register device entities in Home Assistant via MQTT Discovery", diff --git a/ui/src/routes/devices.$id.settings.mqtt.tsx b/ui/src/routes/devices.$id.settings.mqtt.tsx index 7da50bbb3..fb0f38a8a 100644 --- a/ui/src/routes/devices.$id.settings.mqtt.tsx +++ b/ui/src/routes/devices.$id.settings.mqtt.tsx @@ -18,6 +18,7 @@ interface MQTTSettings { use_tls: boolean; tls_insecure: boolean; enable_ha_discovery: boolean; + enable_actions: boolean; } interface MQTTStatus { @@ -37,6 +38,7 @@ export default function SettingsMqttRoute() { use_tls: false, tls_insecure: false, enable_ha_discovery: true, + enable_actions: true, }); const [status, setStatus] = useState({ connected: false }); @@ -203,6 +205,16 @@ export default function SettingsMqttRoute() { onChange={e => updateField("enable_ha_discovery", e.target.checked)} /> + + + updateField("enable_actions", e.target.checked)} + /> + )} From d0472fa7ee022ed4b88980edd7bb62aced894a95 Mon Sep 17 00:00:00 2001 From: Patrick Hofmann Date: Sat, 7 Feb 2026 14:16:04 +0100 Subject: [PATCH 4/6] localization --- ui/localization/messages/da.json | 27 +++++++++++++++++++++ ui/localization/messages/de.json | 5 ++++ ui/localization/messages/en.json | 5 ++++ ui/localization/messages/es.json | 27 +++++++++++++++++++++ ui/localization/messages/fr.json | 27 +++++++++++++++++++++ ui/localization/messages/it.json | 27 +++++++++++++++++++++ ui/localization/messages/ja.json | 27 +++++++++++++++++++++ ui/localization/messages/nb.json | 27 +++++++++++++++++++++ ui/localization/messages/pt.json | 27 +++++++++++++++++++++ ui/localization/messages/sv.json | 27 +++++++++++++++++++++ ui/localization/messages/zh-tw.json | 27 +++++++++++++++++++++ ui/localization/messages/zh.json | 27 +++++++++++++++++++++ ui/src/routes/devices.$id.settings.mqtt.tsx | 10 ++++---- 13 files changed, 285 insertions(+), 5 deletions(-) diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index 1100cac74..2f0a9ba45 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Rulningsbegrænsning", "mouse_scroll_very_high": "Meget høj", "mouse_title": "Mus", + "mqtt_base_topic_description": "MQTT-basis-topic-præfiks (enheds-ID tilføjes automatisk)", + "mqtt_base_topic_label": "Basis-topic", + "mqtt_broker_description": "IP-adresse eller værtsnavn på MQTT-brokeren", + "mqtt_broker_label": "Broker-adresse", + "mqtt_description": "Konfigurer MQTT-broker-forbindelse til Home Assistant-integration", + "mqtt_enable_actions_description": "Tillad fjernhandlinger (ATX/DC-strøm, genstart, jiggler, opdateringer) via MQTT", + "mqtt_enable_actions_title": "Tillad handlinger", + "mqtt_enable_description": "Opret forbindelse til en MQTT-broker for at eksponere enhedsstatus og kontroller", + "mqtt_enable_title": "Aktiver MQTT", + "mqtt_ha_discovery_description": "Registrer automatisk enhedsenheder i Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Adgangskode til MQTT-broker-godkendelse", + "mqtt_password_label": "Adgangskode", + "mqtt_port_description": "MQTT-broker-port (standard: 1883, TLS: 8883)", + "mqtt_port_label": "Port", + "mqtt_save_button": "Gem og forbind", + "mqtt_saved_error": "Kunne ikke gemme MQTT-indstillinger: {error}", + "mqtt_saved_success": "MQTT-indstillinger gemt", + "mqtt_status_connected": "Forbundet", + "mqtt_status_disconnected": "Afbrudt", + "mqtt_tls_insecure_description": "Tillad forbindelser med selvsignerede eller ugyldige certifikater", + "mqtt_tls_insecure_title": "Spring certifikatbekræftelse over", + "mqtt_use_tls_description": "Aktiver TLS-kryptering for MQTT-forbindelsen", + "mqtt_use_tls_title": "Brug TLS", + "mqtt_username_description": "Brugernavn til MQTT-broker-godkendelse", + "mqtt_username_label": "Brugernavn", "network_custom_domain": "Brugerdefineret domæne", "network_description": "Konfigurer dine netværksindstillinger", "network_dhcp_client_description": "Konfigurer hvilken DHCP-klient der skal bruges", @@ -775,6 +801,7 @@ "settings_keyboard": "Tastatur", "settings_keyboard_macros": "Tastaturmakroer", "settings_mouse": "Mus", + "settings_mqtt": "MQTT", "settings_network": "Netværk", "settings_video": "Video", "something_went_wrong": "Noget gik galt. Prøv igen senere, eller kontakt support.", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 31352a133..ac247eb75 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -614,7 +614,9 @@ "mouse_scroll_throttling_title": "Scroll-Drosselung", "mouse_scroll_very_high": "Sehr hoch", "mouse_title": "Maus", + "mqtt_base_topic_description": "MQTT-Topic-Präfix (Geräte-ID wird automatisch angehängt)", "mqtt_base_topic_label": "Basis-Topic", + "mqtt_broker_description": "IP-Adresse oder Hostname des MQTT-Brokers", "mqtt_broker_label": "Broker-Adresse", "mqtt_description": "MQTT-Broker-Verbindung für Home Assistant Integration konfigurieren", "mqtt_enable_actions_description": "Ferngesteuerte Aktionen (ATX/DC-Steuerung, Neustart, Jiggler, Updates) per MQTT erlauben", @@ -623,7 +625,9 @@ "mqtt_enable_title": "MQTT aktivieren", "mqtt_ha_discovery_description": "Geräte-Entitäten automatisch über MQTT Discovery in Home Assistant registrieren", "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Passwort für die MQTT-Broker-Authentifizierung", "mqtt_password_label": "Passwort", + "mqtt_port_description": "MQTT-Broker-Port (Standard: 1883, TLS: 8883)", "mqtt_port_label": "Port", "mqtt_save_button": "Speichern & Verbinden", "mqtt_saved_error": "MQTT-Einstellungen konnten nicht gespeichert werden: {error}", @@ -634,6 +638,7 @@ "mqtt_tls_insecure_title": "Zertifikatsprüfung überspringen", "mqtt_use_tls_description": "TLS-Verschlüsselung für MQTT-Verbindung aktivieren", "mqtt_use_tls_title": "TLS verwenden", + "mqtt_username_description": "Benutzername für die MQTT-Broker-Authentifizierung", "mqtt_username_label": "Benutzername", "network_custom_domain": "Benutzerdefinierte Domäne", "network_description": "Konfigurieren Sie Ihre Netzwerkeinstellungen", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index e13b7907d..bfbaeb2c6 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -619,7 +619,9 @@ "mouse_scroll_throttling_title": "Scroll Throttling", "mouse_scroll_very_high": "Very High", "mouse_title": "Mouse", + "mqtt_base_topic_description": "Base MQTT topic prefix (device ID will be appended automatically)", "mqtt_base_topic_label": "Base Topic", + "mqtt_broker_description": "IP address or hostname of the MQTT broker", "mqtt_broker_label": "Broker Address", "mqtt_description": "Configure MQTT broker connection for Home Assistant integration", "mqtt_enable_actions_description": "Allow remote actions (ATX/DC power, reboot, jiggler, updates) to be triggered via MQTT", @@ -628,7 +630,9 @@ "mqtt_enable_title": "Enable MQTT", "mqtt_ha_discovery_description": "Automatically register device entities in Home Assistant via MQTT Discovery", "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Password for MQTT broker authentication", "mqtt_password_label": "Password", + "mqtt_port_description": "MQTT broker port (default: 1883, TLS: 8883)", "mqtt_port_label": "Port", "mqtt_save_button": "Save & Connect", "mqtt_saved_error": "Failed to save MQTT settings: {error}", @@ -639,6 +643,7 @@ "mqtt_tls_insecure_title": "Skip Certificate Verification", "mqtt_use_tls_description": "Enable TLS encryption for MQTT connection", "mqtt_use_tls_title": "Use TLS", + "mqtt_username_description": "Username for MQTT broker authentication", "mqtt_username_label": "Username", "network_custom_domain": "Custom Domain", "network_description": "Configure your network settings", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index 7f0f47a3e..7c8749f86 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Regulación del desplazamiento", "mouse_scroll_very_high": "Muy alto", "mouse_title": "Ratón", + "mqtt_base_topic_description": "Prefijo del topic MQTT base (el ID del dispositivo se añadirá automáticamente)", + "mqtt_base_topic_label": "Topic base", + "mqtt_broker_description": "Dirección IP o nombre de host del broker MQTT", + "mqtt_broker_label": "Dirección del broker", + "mqtt_description": "Configurar la conexión al broker MQTT para la integración con Home Assistant", + "mqtt_enable_actions_description": "Permitir acciones remotas (alimentación ATX/DC, reinicio, jiggler, actualizaciones) a través de MQTT", + "mqtt_enable_actions_title": "Permitir acciones", + "mqtt_enable_description": "Conectar a un broker MQTT para exponer el estado y los controles del dispositivo", + "mqtt_enable_title": "Activar MQTT", + "mqtt_ha_discovery_description": "Registrar automáticamente las entidades del dispositivo en Home Assistant mediante MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Contraseña para la autenticación del broker MQTT", + "mqtt_password_label": "Contraseña", + "mqtt_port_description": "Puerto del broker MQTT (predeterminado: 1883, TLS: 8883)", + "mqtt_port_label": "Puerto", + "mqtt_save_button": "Guardar y conectar", + "mqtt_saved_error": "Error al guardar la configuración MQTT: {error}", + "mqtt_saved_success": "Configuración MQTT guardada correctamente", + "mqtt_status_connected": "Conectado", + "mqtt_status_disconnected": "Desconectado", + "mqtt_tls_insecure_description": "Permitir conexiones con certificados autofirmados o no válidos", + "mqtt_tls_insecure_title": "Omitir verificación de certificado", + "mqtt_use_tls_description": "Activar el cifrado TLS para la conexión MQTT", + "mqtt_use_tls_title": "Usar TLS", + "mqtt_username_description": "Nombre de usuario para la autenticación del broker MQTT", + "mqtt_username_label": "Nombre de usuario", "network_custom_domain": "Dominio personalizado", "network_description": "Configure los ajustes de red", "network_dhcp_client_description": "Configurar qué cliente DHCP utilizar", @@ -775,6 +801,7 @@ "settings_keyboard": "Teclado", "settings_keyboard_macros": "Macros del teclado", "settings_mouse": "Ratón", + "settings_mqtt": "MQTT", "settings_network": "Red", "settings_video": "Video", "something_went_wrong": "Algo salió mal. Inténtalo de nuevo más tarde o contacta con el servicio de asistencia.", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index 052af1a4c..60e2f9446 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Ralentissement du défilement", "mouse_scroll_very_high": "Très élevé", "mouse_title": "Souris", + "mqtt_base_topic_description": "Préfixe du topic MQTT de base (l'identifiant de l'appareil sera ajouté automatiquement)", + "mqtt_base_topic_label": "Topic de base", + "mqtt_broker_description": "Adresse IP ou nom d'hôte du broker MQTT", + "mqtt_broker_label": "Adresse du broker", + "mqtt_description": "Configurer la connexion au broker MQTT pour l'intégration Home Assistant", + "mqtt_enable_actions_description": "Autoriser les actions à distance (alimentation ATX/DC, redémarrage, jiggler, mises à jour) via MQTT", + "mqtt_enable_actions_title": "Autoriser les actions", + "mqtt_enable_description": "Se connecter à un broker MQTT pour exposer l'état et les contrôles de l'appareil", + "mqtt_enable_title": "Activer MQTT", + "mqtt_ha_discovery_description": "Enregistrer automatiquement les entités de l'appareil dans Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Mot de passe pour l'authentification au broker MQTT", + "mqtt_password_label": "Mot de passe", + "mqtt_port_description": "Port du broker MQTT (par défaut : 1883, TLS : 8883)", + "mqtt_port_label": "Port", + "mqtt_save_button": "Enregistrer et connecter", + "mqtt_saved_error": "Échec de l'enregistrement des paramètres MQTT : {error}", + "mqtt_saved_success": "Paramètres MQTT enregistrés avec succès", + "mqtt_status_connected": "Connecté", + "mqtt_status_disconnected": "Déconnecté", + "mqtt_tls_insecure_description": "Autoriser les connexions avec des certificats auto-signés ou invalides", + "mqtt_tls_insecure_title": "Ignorer la vérification du certificat", + "mqtt_use_tls_description": "Activer le chiffrement TLS pour la connexion MQTT", + "mqtt_use_tls_title": "Utiliser TLS", + "mqtt_username_description": "Nom d'utilisateur pour l'authentification au broker MQTT", + "mqtt_username_label": "Nom d'utilisateur", "network_custom_domain": "Domaine personnalisé", "network_description": "Configurez vos paramètres réseau", "network_dhcp_client_description": "Configurer le client DHCP à utiliser", @@ -775,6 +801,7 @@ "settings_keyboard": "Clavier", "settings_keyboard_macros": "Macros de clavier", "settings_mouse": "Souris", + "settings_mqtt": "MQTT", "settings_network": "Réseau", "settings_video": "Vidéo", "something_went_wrong": "Une erreur s'est produite. Veuillez réessayer ultérieurement ou contacter le support.", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 6112af67d..5078d0d58 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Limitazione dello scorrimento", "mouse_scroll_very_high": "Molto alto", "mouse_title": "Mouse", + "mqtt_base_topic_description": "Prefisso del topic MQTT base (l'ID del dispositivo verrà aggiunto automaticamente)", + "mqtt_base_topic_label": "Topic base", + "mqtt_broker_description": "Indirizzo IP o hostname del broker MQTT", + "mqtt_broker_label": "Indirizzo del broker", + "mqtt_description": "Configura la connessione al broker MQTT per l'integrazione con Home Assistant", + "mqtt_enable_actions_description": "Consenti azioni remote (alimentazione ATX/DC, riavvio, jiggler, aggiornamenti) tramite MQTT", + "mqtt_enable_actions_title": "Consenti azioni", + "mqtt_enable_description": "Connetti a un broker MQTT per esporre lo stato e i controlli del dispositivo", + "mqtt_enable_title": "Abilita MQTT", + "mqtt_ha_discovery_description": "Registra automaticamente le entità del dispositivo in Home Assistant tramite MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Password per l'autenticazione al broker MQTT", + "mqtt_password_label": "Password", + "mqtt_port_description": "Porta del broker MQTT (predefinita: 1883, TLS: 8883)", + "mqtt_port_label": "Porta", + "mqtt_save_button": "Salva e connetti", + "mqtt_saved_error": "Impossibile salvare le impostazioni MQTT: {error}", + "mqtt_saved_success": "Impostazioni MQTT salvate con successo", + "mqtt_status_connected": "Connesso", + "mqtt_status_disconnected": "Disconnesso", + "mqtt_tls_insecure_description": "Consenti connessioni con certificati autofirmati o non validi", + "mqtt_tls_insecure_title": "Ignora verifica certificato", + "mqtt_use_tls_description": "Abilita la crittografia TLS per la connessione MQTT", + "mqtt_use_tls_title": "Usa TLS", + "mqtt_username_description": "Nome utente per l'autenticazione al broker MQTT", + "mqtt_username_label": "Nome utente", "network_custom_domain": "Dominio personalizzato", "network_description": "Configura le impostazioni di rete", "network_dhcp_client_description": "Configurare quale client DHCP utilizzare", @@ -775,6 +801,7 @@ "settings_keyboard": "Tastiera", "settings_keyboard_macros": "Macro tastiera", "settings_mouse": "Mouse", + "settings_mqtt": "MQTT", "settings_network": "Rete", "settings_video": "Video", "something_went_wrong": "Qualcosa è andato storto. Riprova più tardi o contatta l'assistenza.", diff --git a/ui/localization/messages/ja.json b/ui/localization/messages/ja.json index 3ae0b73ee..39ba45dfe 100644 --- a/ui/localization/messages/ja.json +++ b/ui/localization/messages/ja.json @@ -619,6 +619,32 @@ "mouse_scroll_throttling_title": "スクロール抑制", "mouse_scroll_very_high": "最高", "mouse_title": "マウス", + "mqtt_base_topic_description": "MQTTベーストピックのプレフィックス(デバイスIDが自動的に付加されます)", + "mqtt_base_topic_label": "ベーストピック", + "mqtt_broker_description": "MQTTブローカーのIPアドレスまたはホスト名", + "mqtt_broker_label": "ブローカーアドレス", + "mqtt_description": "Home Assistant統合のためのMQTTブローカー接続を設定", + "mqtt_enable_actions_description": "MQTTを介したリモートアクション(ATX/DC電源、再起動、ジグラー、アップデート)を許可", + "mqtt_enable_actions_title": "アクションを許可", + "mqtt_enable_description": "MQTTブローカーに接続してデバイスの状態とコントロールを公開", + "mqtt_enable_title": "MQTTを有効にする", + "mqtt_ha_discovery_description": "MQTT Discoveryを使用してHome Assistantにデバイスエンティティを自動登録", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "MQTTブローカー認証用パスワード", + "mqtt_password_label": "パスワード", + "mqtt_port_description": "MQTTブローカーポート(デフォルト: 1883、TLS: 8883)", + "mqtt_port_label": "ポート", + "mqtt_save_button": "保存して接続", + "mqtt_saved_error": "MQTT設定の保存に失敗しました:{error}", + "mqtt_saved_success": "MQTT設定が正常に保存されました", + "mqtt_status_connected": "接続済み", + "mqtt_status_disconnected": "切断", + "mqtt_tls_insecure_description": "自己署名または無効な証明書での接続を許可", + "mqtt_tls_insecure_title": "証明書の検証をスキップ", + "mqtt_use_tls_description": "MQTT接続のTLS暗号化を有効にする", + "mqtt_use_tls_title": "TLSを使用", + "mqtt_username_description": "MQTTブローカー認証用ユーザー名", + "mqtt_username_label": "ユーザー名", "network_custom_domain": "カスタムドメイン", "network_description": "ネットワーク設定を構成します", "network_dhcp_client_description": "使用するDHCPクライアントを設定します", @@ -780,6 +806,7 @@ "settings_keyboard": "キーボード", "settings_keyboard_macros": "キーボードマクロ", "settings_mouse": "マウス", + "settings_mqtt": "MQTT", "settings_network": "ネットワーク", "settings_video": "ビデオ", "something_went_wrong": "問題が発生しました。後でもう一度試すか、サポートにお問い合わせください", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 2583197e2..02d8156f7 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Rullebegrensning", "mouse_scroll_very_high": "Svært høy", "mouse_title": "Mus", + "mqtt_base_topic_description": "MQTT-basis-topic-prefiks (enhets-ID legges til automatisk)", + "mqtt_base_topic_label": "Basis-topic", + "mqtt_broker_description": "IP-adresse eller vertsnavn til MQTT-brokeren", + "mqtt_broker_label": "Broker-adresse", + "mqtt_description": "Konfigurer MQTT-broker-tilkobling for Home Assistant-integrasjon", + "mqtt_enable_actions_description": "Tillat fjernhandlinger (ATX/DC-strøm, omstart, jiggler, oppdateringer) via MQTT", + "mqtt_enable_actions_title": "Tillat handlinger", + "mqtt_enable_description": "Koble til en MQTT-broker for å eksponere enhetsstatus og kontroller", + "mqtt_enable_title": "Aktiver MQTT", + "mqtt_ha_discovery_description": "Registrer enhetsenheter automatisk i Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Passord for MQTT-broker-autentisering", + "mqtt_password_label": "Passord", + "mqtt_port_description": "MQTT-broker-port (standard: 1883, TLS: 8883)", + "mqtt_port_label": "Port", + "mqtt_save_button": "Lagre og koble til", + "mqtt_saved_error": "Kunne ikke lagre MQTT-innstillinger: {error}", + "mqtt_saved_success": "MQTT-innstillinger lagret", + "mqtt_status_connected": "Tilkoblet", + "mqtt_status_disconnected": "Frakoblet", + "mqtt_tls_insecure_description": "Tillat tilkoblinger med selvsignerte eller ugyldige sertifikater", + "mqtt_tls_insecure_title": "Hopp over sertifikatverifisering", + "mqtt_use_tls_description": "Aktiver TLS-kryptering for MQTT-tilkoblingen", + "mqtt_use_tls_title": "Bruk TLS", + "mqtt_username_description": "Brukernavn for MQTT-broker-autentisering", + "mqtt_username_label": "Brukernavn", "network_custom_domain": "Tilpasset domene", "network_description": "Konfigurer nettverksinnstillingene dine", "network_dhcp_client_description": "Konfigurer hvilken DHCP-klient som skal brukes", @@ -775,6 +801,7 @@ "settings_keyboard": "Tastatur", "settings_keyboard_macros": "Tastaturmakroer", "settings_mouse": "Mus", + "settings_mqtt": "MQTT", "settings_network": "Nettverk", "settings_video": "Video", "something_went_wrong": "Noe gikk galt. Prøv igjen senere eller kontakt kundestøtte.", diff --git a/ui/localization/messages/pt.json b/ui/localization/messages/pt.json index f59d2b27e..a197d113a 100644 --- a/ui/localization/messages/pt.json +++ b/ui/localization/messages/pt.json @@ -619,6 +619,32 @@ "mouse_scroll_throttling_title": "Limitação de Rolagem", "mouse_scroll_very_high": "Muito Alto", "mouse_title": "Mouse", + "mqtt_base_topic_description": "Prefixo do tópico MQTT base (o ID do dispositivo será adicionado automaticamente)", + "mqtt_base_topic_label": "Tópico Base", + "mqtt_broker_description": "Endereço IP ou nome de host do broker MQTT", + "mqtt_broker_label": "Endereço do Broker", + "mqtt_description": "Configurar conexão com broker MQTT para integração com Home Assistant", + "mqtt_enable_actions_description": "Permitir ações remotas (energia ATX/DC, reinício, jiggler, atualizações) via MQTT", + "mqtt_enable_actions_title": "Permitir Ações", + "mqtt_enable_description": "Conectar a um broker MQTT para expor o estado e controles do dispositivo", + "mqtt_enable_title": "Ativar MQTT", + "mqtt_ha_discovery_description": "Registrar automaticamente entidades do dispositivo no Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Senha para autenticação no broker MQTT", + "mqtt_password_label": "Senha", + "mqtt_port_description": "Porta do broker MQTT (padrão: 1883, TLS: 8883)", + "mqtt_port_label": "Porta", + "mqtt_save_button": "Salvar e Conectar", + "mqtt_saved_error": "Falha ao salvar configurações MQTT: {error}", + "mqtt_saved_success": "Configurações MQTT salvas com sucesso", + "mqtt_status_connected": "Conectado", + "mqtt_status_disconnected": "Desconectado", + "mqtt_tls_insecure_description": "Permitir conexões com certificados autoassinados ou inválidos", + "mqtt_tls_insecure_title": "Ignorar Verificação de Certificado", + "mqtt_use_tls_description": "Ativar criptografia TLS para a conexão MQTT", + "mqtt_use_tls_title": "Usar TLS", + "mqtt_username_description": "Nome de usuário para autenticação no broker MQTT", + "mqtt_username_label": "Nome de Usuário", "network_custom_domain": "Domínio Personalizado", "network_description": "Configure suas definições de rede", "network_dhcp_client_description": "Configure qual cliente DHCP usar", @@ -780,6 +806,7 @@ "settings_keyboard": "Teclado", "settings_keyboard_macros": "Macros de Teclado", "settings_mouse": "Mouse", + "settings_mqtt": "MQTT", "settings_network": "Rede", "settings_video": "Vídeo", "something_went_wrong": "Algo deu errado. Tente novamente mais tarde ou entre em contato com o suporte", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index 74813f893..00b96ba43 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "Scrollbegränsning", "mouse_scroll_very_high": "Mycket hög", "mouse_title": "Mus", + "mqtt_base_topic_description": "MQTT-bas-topic-prefix (enhets-ID läggs till automatiskt)", + "mqtt_base_topic_label": "Bas-topic", + "mqtt_broker_description": "IP-adress eller värdnamn för MQTT-brokern", + "mqtt_broker_label": "Broker-adress", + "mqtt_description": "Konfigurera MQTT-broker-anslutning för Home Assistant-integration", + "mqtt_enable_actions_description": "Tillåt fjärrstyrning (ATX/DC-ström, omstart, jiggler, uppdateringar) via MQTT", + "mqtt_enable_actions_title": "Tillåt åtgärder", + "mqtt_enable_description": "Anslut till en MQTT-broker för att exponera enhetsstatus och kontroller", + "mqtt_enable_title": "Aktivera MQTT", + "mqtt_ha_discovery_description": "Registrera enhetsenheter automatiskt i Home Assistant via MQTT Discovery", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "Lösenord för MQTT-broker-autentisering", + "mqtt_password_label": "Lösenord", + "mqtt_port_description": "MQTT-broker-port (standard: 1883, TLS: 8883)", + "mqtt_port_label": "Port", + "mqtt_save_button": "Spara och anslut", + "mqtt_saved_error": "Kunde inte spara MQTT-inställningar: {error}", + "mqtt_saved_success": "MQTT-inställningar sparade", + "mqtt_status_connected": "Ansluten", + "mqtt_status_disconnected": "Frånkopplad", + "mqtt_tls_insecure_description": "Tillåt anslutningar med självsignerade eller ogiltiga certifikat", + "mqtt_tls_insecure_title": "Hoppa över certifikatverifiering", + "mqtt_use_tls_description": "Aktivera TLS-kryptering för MQTT-anslutningen", + "mqtt_use_tls_title": "Använd TLS", + "mqtt_username_description": "Användarnamn för MQTT-broker-autentisering", + "mqtt_username_label": "Användarnamn", "network_custom_domain": "Anpassad domän", "network_description": "Konfigurera nätverksinställningar", "network_dhcp_client_description": "Konfigurera vilken DHCP-klient som ska användas", @@ -775,6 +801,7 @@ "settings_keyboard": "Tangentbord", "settings_keyboard_macros": "Tangentbordsmakron", "settings_mouse": "Mus", + "settings_mqtt": "MQTT", "settings_network": "Nätverk", "settings_video": "Video", "something_went_wrong": "Något gick fel. Försök igen senare eller kontakta support.", diff --git a/ui/localization/messages/zh-tw.json b/ui/localization/messages/zh-tw.json index 8fd1f5ca8..468d8d82d 100644 --- a/ui/localization/messages/zh-tw.json +++ b/ui/localization/messages/zh-tw.json @@ -619,6 +619,32 @@ "mouse_scroll_throttling_title": "捲動節流", "mouse_scroll_very_high": "非常高", "mouse_title": "滑鼠", + "mqtt_base_topic_description": "MQTT 基礎主題前綴(裝置 ID 將自動附加)", + "mqtt_base_topic_label": "基礎主題", + "mqtt_broker_description": "MQTT Broker 的 IP 位址或主機名稱", + "mqtt_broker_label": "Broker 位址", + "mqtt_description": "設定 MQTT Broker 連線以整合 Home Assistant", + "mqtt_enable_actions_description": "允許透過 MQTT 觸發遠端操作(ATX/DC 電源、重新啟動、滑鼠抖動、更新)", + "mqtt_enable_actions_title": "允許操作", + "mqtt_enable_description": "連接到 MQTT Broker 以公開裝置狀態和控制", + "mqtt_enable_title": "啟用 MQTT", + "mqtt_ha_discovery_description": "透過 MQTT Discovery 自動在 Home Assistant 中註冊裝置實體", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "MQTT Broker 驗證密碼", + "mqtt_password_label": "密碼", + "mqtt_port_description": "MQTT Broker 連接埠(預設:1883,TLS:8883)", + "mqtt_port_label": "連接埠", + "mqtt_save_button": "儲存並連線", + "mqtt_saved_error": "儲存 MQTT 設定失敗:{error}", + "mqtt_saved_success": "MQTT 設定已成功儲存", + "mqtt_status_connected": "已連線", + "mqtt_status_disconnected": "已中斷", + "mqtt_tls_insecure_description": "允許使用自簽名或無效憑證的連線", + "mqtt_tls_insecure_title": "跳過憑證驗證", + "mqtt_use_tls_description": "為 MQTT 連線啟用 TLS 加密", + "mqtt_use_tls_title": "使用 TLS", + "mqtt_username_description": "MQTT Broker 驗證使用者名稱", + "mqtt_username_label": "使用者名稱", "network_custom_domain": "自訂網域", "network_description": "設定您的網路設定", "network_dhcp_client_description": "設定要使用的 DHCP 用戶端", @@ -780,6 +806,7 @@ "settings_keyboard": "鍵盤", "settings_keyboard_macros": "鍵盤巨集", "settings_mouse": "滑鼠", + "settings_mqtt": "MQTT", "settings_network": "網路", "settings_video": "視訊", "something_went_wrong": "發生錯誤。請稍後再試或聯絡支援", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 2102275b5..c1ded9072 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -614,6 +614,32 @@ "mouse_scroll_throttling_title": "滚动节流", "mouse_scroll_very_high": "非常高", "mouse_title": "鼠标", + "mqtt_base_topic_description": "MQTT 基础主题前缀(设备 ID 将自动附加)", + "mqtt_base_topic_label": "基础主题", + "mqtt_broker_description": "MQTT Broker 的 IP 地址或主机名", + "mqtt_broker_label": "Broker 地址", + "mqtt_description": "配置 MQTT Broker 连接以集成 Home Assistant", + "mqtt_enable_actions_description": "允许通过 MQTT 触发远程操作(ATX/DC 电源、重启、鼠标抖动、更新)", + "mqtt_enable_actions_title": "允许操作", + "mqtt_enable_description": "连接到 MQTT Broker 以公开设备状态和控制", + "mqtt_enable_title": "启用 MQTT", + "mqtt_ha_discovery_description": "通过 MQTT Discovery 自动在 Home Assistant 中注册设备实体", + "mqtt_ha_discovery_title": "Home Assistant Discovery", + "mqtt_password_description": "MQTT Broker 认证密码", + "mqtt_password_label": "密码", + "mqtt_port_description": "MQTT Broker 端口(默认:1883,TLS:8883)", + "mqtt_port_label": "端口", + "mqtt_save_button": "保存并连接", + "mqtt_saved_error": "保存 MQTT 设置失败:{error}", + "mqtt_saved_success": "MQTT 设置已成功保存", + "mqtt_status_connected": "已连接", + "mqtt_status_disconnected": "已断开", + "mqtt_tls_insecure_description": "允许使用自签名或无效证书的连接", + "mqtt_tls_insecure_title": "跳过证书验证", + "mqtt_use_tls_description": "为 MQTT 连接启用 TLS 加密", + "mqtt_use_tls_title": "使用 TLS", + "mqtt_username_description": "MQTT Broker 认证用户名", + "mqtt_username_label": "用户名", "network_custom_domain": "自定义域名", "network_description": "配置您的网络设置。", "network_dhcp_client_description": "选择要使用的 DHCP 客户端。", @@ -775,6 +801,7 @@ "settings_keyboard": "键盘", "settings_keyboard_macros": "键盘宏", "settings_mouse": "鼠标", + "settings_mqtt": "MQTT", "settings_network": "网络", "settings_video": "视频", "something_went_wrong": "出错了。请稍后重试或联系技术支持。", diff --git a/ui/src/routes/devices.$id.settings.mqtt.tsx b/ui/src/routes/devices.$id.settings.mqtt.tsx index fb0f38a8a..6ae7743c4 100644 --- a/ui/src/routes/devices.$id.settings.mqtt.tsx +++ b/ui/src/routes/devices.$id.settings.mqtt.tsx @@ -117,7 +117,7 @@ export default function SettingsMqttRoute() { Date: Fri, 13 Feb 2026 17:00:34 +0100 Subject: [PATCH 5/6] - Adds a debounce delay for HDD led with a default auf 500ms. Prevents too flaky states in HA. - Changes on entities like mouse jiggler, virtual media, or active sessions are now published to MQTT instantly. Previously, these changes only appeared in Home Assistant after the next periodic update cycle. Additionally, all current states (ATX/DC, jiggler, virtual media, sessions, network, system, video, etc.) are now published immediately when the MQTT connection is first established, so Home Assistant knows the full device state right away on startup or reconnect. - Also fixes slow session disconnect detection: ICE 'Disconnected' state now triggers an immediate connection close instead of waiting for the ICE timeout (5-30s) before transitioning through 'Failed' to 'Closed'. --- config.go | 1 + jiggler.go | 3 + mqtt.go | 135 ++++++++++++++++++-- ui/localization/messages/da.json | 2 + ui/localization/messages/de.json | 2 + ui/localization/messages/en.json | 2 + ui/localization/messages/es.json | 2 + ui/localization/messages/fr.json | 2 + ui/localization/messages/it.json | 2 + ui/localization/messages/ja.json | 2 + ui/localization/messages/nb.json | 2 + ui/localization/messages/pt.json | 2 + ui/localization/messages/sv.json | 2 + ui/localization/messages/zh-tw.json | 2 + ui/localization/messages/zh.json | 2 + ui/src/routes/devices.$id.settings.mqtt.tsx | 15 +++ usb_mass_storage.go | 17 ++- webrtc.go | 11 +- 18 files changed, 193 insertions(+), 13 deletions(-) diff --git a/config.go b/config.go index d2db3f8fc..e4683687e 100644 --- a/config.go +++ b/config.go @@ -205,6 +205,7 @@ func getDefaultConfig() Config { BaseTopic: "jetkvm", EnableHADiscovery: false, EnableActions: true, + DebounceMs: 500, }, } } diff --git a/jiggler.go b/jiggler.go index b2463e0ab..1fe6e880b 100644 --- a/jiggler.go +++ b/jiggler.go @@ -26,6 +26,9 @@ func rpcSetJigglerState(enabled bool) error { if err != nil { return fmt.Errorf("failed to save config: %w", err) } + if mqttManager != nil { + mqttManager.publishJigglerState() + } return nil } diff --git a/mqtt.go b/mqtt.go index 9d1085d82..7099f538c 100644 --- a/mqtt.go +++ b/mqtt.go @@ -17,6 +17,7 @@ import ( "github.com/gwatts/rootcerts" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/ota" + "github.com/jetkvm/kvm/internal/sync" mqtt "github.com/eclipse/paho.mqtt.golang" ) @@ -34,6 +35,7 @@ type MQTTConfig struct { TLSInsecure bool `json:"tls_insecure"` EnableHADiscovery bool `json:"enable_ha_discovery"` EnableActions bool `json:"enable_actions"` + DebounceMs int `json:"debounce_ms"` } var mqttManager *MQTTManager @@ -44,6 +46,15 @@ type MQTTManager struct { baseTopic string connected bool updateRequested bool // set when an update is triggered via MQTT, cleared when OTA finishes + debounceMs int + + // Debounce state for ATX HDD LED OFF transitions. + // When the HDD LED turns off, publishing is delayed by debounceMs. + // If it turns back on within that window, the OFF is suppressed, + // keeping the published state as ON during rapid flickering. + atxDebounceMu sync.Mutex + atxDebounceTimer *time.Timer + atxLastPublished *ATXState } type mqttStatusPayload struct { @@ -70,8 +81,9 @@ func NewMQTTManager(cfg *MQTTConfig, deviceID string) (*MQTTManager, error) { } m := &MQTTManager{ - deviceID: deviceID, - baseTopic: baseTopic, + deviceID: deviceID, + baseTopic: baseTopic, + debounceMs: cfg.DebounceMs, } scheme := "tcp" @@ -133,6 +145,19 @@ func (m *MQTTManager) onConnect(client mqtt.Client) { // Subscribe to command topics m.subscribeCommands() + + // Immediately publish all current states so Home Assistant knows + // the current state of all switches and sensors right away. + if config.ActiveExtension == "atx-power" { + m.PublishATXState(ATXState{ + Power: ledPWRState, + HDD: ledHDDState, + }) + } + if config.ActiveExtension == "dc-power" { + m.PublishDCState(dcState) + } + m.publishExtendedStates() } func (m *MQTTManager) onConnectionLost(client mqtt.Client, err error) { @@ -147,6 +172,10 @@ func (m *MQTTManager) IsConnected() bool { // Close disconnects from the MQTT broker gracefully. func (m *MQTTManager) Close() { + m.atxDebounceMu.Lock() + m.cancelATXDebounceTimerLocked() + m.atxDebounceMu.Unlock() + if m.client != nil && m.client.IsConnected() { m.publish(m.topic("status"), mqttStatusPayload{Online: false}, true) m.client.Disconnect(500) @@ -629,7 +658,6 @@ func (m *MQTTManager) publishATXDiscovery(device *haDevice, availTopic, availTem UniqueID: fmt.Sprintf("jetkvm_%s_power_led", m.deviceID), StateTopic: m.topic("atx", "state"), ValueTemplate: "{{ 'ON' if value_json.power else 'OFF' }}", - DeviceClass: "power", Icon: "mdi:led-on", AvailabilityTopic: availTopic, AvailTemplate: availTemplate, @@ -1071,14 +1099,89 @@ func (m *MQTTManager) handleVirtualMediaCommand(client mqtt.Client, msg mqtt.Mes // --- State Publishing --- -// PublishATXState publishes the current ATX state to MQTT. +// PublishATXState publishes the current ATX state to MQTT with debounce logic. +// When debouncing is enabled (debounceMs > 0), OFF transitions for the HDD LED +// are delayed: if the LED turns back ON within the debounce window, the OFF is +// suppressed so that rapid flickering (e.g. during heavy disk I/O) keeps the +// published state as ON. ON transitions and Power LED changes are published +// immediately (but only once per transition). func (m *MQTTManager) PublishATXState(state ATXState) { if !m.IsConnected() { return } + + // No debounce configured: publish immediately. + if m.debounceMs <= 0 { + m.publish(m.topic("atx", "state"), state, true) + return + } + + m.atxDebounceMu.Lock() + defer m.atxDebounceMu.Unlock() + + lastState := m.atxLastPublished + + // First frame ever: publish immediately. + if lastState == nil { + m.publishATXStateLocked(state) + return + } + + // Power LED changed: always publish immediately and reset debounce. + if state.Power != lastState.Power { + m.cancelATXDebounceTimerLocked() + m.publishATXStateLocked(state) + return + } + + // HDD LED turned ON (or stayed ON): + if state.HDD { + // Cancel any pending OFF timer – the LED is active. + if m.atxDebounceTimer != nil { + m.cancelATXDebounceTimerLocked() + } + // Only publish if last published state was OFF (i.e. the ON transition). + if !lastState.HDD { + m.publishATXStateLocked(state) + } + return + } + + // HDD LED is OFF: + // If already published as OFF, nothing to do. + if !lastState.HDD { + return + } + + // HDD LED just turned OFF: delay publishing. + // If a timer is already running, let it continue. + if m.atxDebounceTimer != nil { + return + } + + debounceState := state // capture for closure + m.atxDebounceTimer = time.AfterFunc(time.Duration(m.debounceMs)*time.Millisecond, func() { + m.atxDebounceMu.Lock() + defer m.atxDebounceMu.Unlock() + m.atxDebounceTimer = nil + m.publishATXStateLocked(debounceState) + }) +} + +// publishATXStateLocked publishes the ATX state and records it. Must be called with atxDebounceMu held. +func (m *MQTTManager) publishATXStateLocked(state ATXState) { + m.atxLastPublished = &state m.publish(m.topic("atx", "state"), state, true) } +// cancelATXDebounceTimerLocked stops a pending debounce timer. Must be called with atxDebounceMu held. +func (m *MQTTManager) cancelATXDebounceTimerLocked() { + if m.atxDebounceTimer != nil { + m.atxDebounceTimer.Stop() + m.atxDebounceTimer = nil + } +} + // PublishDCState publishes the current DC power state to MQTT. func (m *MQTTManager) PublishDCState(state DCPowerState) { if !m.IsConnected() { @@ -1164,6 +1267,16 @@ func (m *MQTTManager) publishJigglerState() { }, true) } +// publishSessionsState publishes the current active sessions count. +func (m *MQTTManager) publishSessionsState() { + if !m.IsConnected() { + return + } + m.publish(m.topic("sessions", "state"), mqttSessionsState{ + ActiveSessions: getActiveSessions(), + }, true) +} + // publishNetworkState publishes the current network state. func (m *MQTTManager) publishNetworkState() { if !m.IsConnected() || networkManager == nil { @@ -1463,10 +1576,7 @@ func (m *MQTTManager) publishExtendedStates() { m.publish(m.topic("cloud", "state"), cloudPayload, true) // Active sessions - sessionsPayload := mqttSessionsState{ - ActiveSessions: getActiveSessions(), - } - m.publish(m.topic("sessions", "state"), sessionsPayload, true) + m.publishSessionsState() // Jiggler state m.publishJigglerState() @@ -1526,6 +1636,7 @@ type MQTTSettingsResponse struct { TLSInsecure bool `json:"tls_insecure"` EnableHADiscovery bool `json:"enable_ha_discovery"` EnableActions bool `json:"enable_actions"` + DebounceMs int `json:"debounce_ms"` } type MQTTStatusResponse struct { @@ -1548,6 +1659,7 @@ func rpcGetMqttSettings() (MQTTSettingsResponse, error) { TLSInsecure: cfg.TLSInsecure, EnableHADiscovery: cfg.EnableHADiscovery, EnableActions: cfg.EnableActions, + DebounceMs: cfg.DebounceMs, }, nil } @@ -1578,6 +1690,10 @@ func rpcSetMqttSettings(settings MQTTSettingsResponse) error { } } + if settings.DebounceMs < 0 { + settings.DebounceMs = 0 + } + config.MqttConfig = &MQTTConfig{ Enabled: settings.Enabled, Broker: settings.Broker, @@ -1589,6 +1705,7 @@ func rpcSetMqttSettings(settings MQTTSettingsResponse) error { TLSInsecure: settings.TLSInsecure, EnableHADiscovery: settings.EnableHADiscovery, EnableActions: settings.EnableActions, + DebounceMs: settings.DebounceMs, } if err := SaveConfig(); err != nil { @@ -1632,7 +1749,7 @@ func startMQTT() { return } - mqttManager.StartPeriodicStatusUpdates(30 * time.Second) + mqttManager.StartPeriodicStatusUpdates(15 * time.Second) } // initMQTT initializes MQTT if enabled in config. Called from main.go. diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index 2f0a9ba45..dd5dcdf46 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "IP-adresse eller værtsnavn på MQTT-brokeren", "mqtt_broker_label": "Broker-adresse", "mqtt_description": "Konfigurer MQTT-broker-forbindelse til Home Assistant-integration", + "mqtt_debounce_description": "Afprelning kun for HDD-LED (i millisekunder). Når HDD-LED blinker hurtigt under diskaktivitet, rapporteres den som TÆNDT indtil den er inaktiv i denne varighed. Sæt til 0 for at deaktivere", + "mqtt_debounce_title": "HDD-LED afprelning (ms)", "mqtt_enable_actions_description": "Tillad fjernhandlinger (ATX/DC-strøm, genstart, jiggler, opdateringer) via MQTT", "mqtt_enable_actions_title": "Tillad handlinger", "mqtt_enable_description": "Opret forbindelse til en MQTT-broker for at eksponere enhedsstatus og kontroller", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index ac247eb75..2e37010d4 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "IP-Adresse oder Hostname des MQTT-Brokers", "mqtt_broker_label": "Broker-Adresse", "mqtt_description": "MQTT-Broker-Verbindung für Home Assistant Integration konfigurieren", + "mqtt_debounce_description": "Entprellzeit nur für die HDD-LED (in Millisekunden). Wenn die HDD-LED bei Festplattenaktivität schnell flackert, wird sie als AN gemeldet, bis sie für diese Dauer inaktiv ist. Auf 0 setzen zum Deaktivieren", + "mqtt_debounce_title": "HDD-LED Entprellung (ms)", "mqtt_enable_actions_description": "Ferngesteuerte Aktionen (ATX/DC-Steuerung, Neustart, Jiggler, Updates) per MQTT erlauben", "mqtt_enable_actions_title": "Aktionen erlauben", "mqtt_enable_description": "Mit einem MQTT-Broker verbinden, um Gerätestatus und Steuerungen bereitzustellen", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index bfbaeb2c6..39080ddaa 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -624,6 +624,8 @@ "mqtt_broker_description": "IP address or hostname of the MQTT broker", "mqtt_broker_label": "Broker Address", "mqtt_description": "Configure MQTT broker connection for Home Assistant integration", + "mqtt_debounce_description": "Debounce delay for the HDD LED only (in milliseconds). When the HDD LED flickers rapidly during disk activity, it stays reported as ON until idle for this duration. Set to 0 to disable", + "mqtt_debounce_title": "HDD LED Debounce (ms)", "mqtt_enable_actions_description": "Allow remote actions (ATX/DC power, reboot, jiggler, updates) to be triggered via MQTT", "mqtt_enable_actions_title": "Allow Actions", "mqtt_enable_description": "Connect to an MQTT broker to expose device state and controls", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index 7c8749f86..677a5afb4 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "Dirección IP o nombre de host del broker MQTT", "mqtt_broker_label": "Dirección del broker", "mqtt_description": "Configurar la conexión al broker MQTT para la integración con Home Assistant", + "mqtt_debounce_description": "Antirrebote solo para el LED del HDD (en milisegundos). Cuando el LED del HDD parpadea rápidamente durante la actividad del disco, se reporta como ENCENDIDO hasta que esté inactivo durante esta duración. Establecer en 0 para desactivar", + "mqtt_debounce_title": "Antirrebote LED HDD (ms)", "mqtt_enable_actions_description": "Permitir acciones remotas (alimentación ATX/DC, reinicio, jiggler, actualizaciones) a través de MQTT", "mqtt_enable_actions_title": "Permitir acciones", "mqtt_enable_description": "Conectar a un broker MQTT para exponer el estado y los controles del dispositivo", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index 60e2f9446..b47964bbd 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "Adresse IP ou nom d'hôte du broker MQTT", "mqtt_broker_label": "Adresse du broker", "mqtt_description": "Configurer la connexion au broker MQTT pour l'intégration Home Assistant", + "mqtt_debounce_description": "Anti-rebond uniquement pour la LED HDD (en millisecondes). Lorsque la LED HDD clignote rapidement pendant l'activité disque, elle reste signalée comme ALLUMÉE jusqu'à inactivité pendant cette durée. Mettre à 0 pour désactiver", + "mqtt_debounce_title": "Anti-rebond LED HDD (ms)", "mqtt_enable_actions_description": "Autoriser les actions à distance (alimentation ATX/DC, redémarrage, jiggler, mises à jour) via MQTT", "mqtt_enable_actions_title": "Autoriser les actions", "mqtt_enable_description": "Se connecter à un broker MQTT pour exposer l'état et les contrôles de l'appareil", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 5078d0d58..fc00959a6 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "Indirizzo IP o hostname del broker MQTT", "mqtt_broker_label": "Indirizzo del broker", "mqtt_description": "Configura la connessione al broker MQTT per l'integrazione con Home Assistant", + "mqtt_debounce_description": "Antirimbalzo solo per il LED HDD (in millisecondi). Quando il LED HDD lampeggia rapidamente durante l'attività del disco, viene segnalato come ACCESO fino a inattività per questa durata. Impostare a 0 per disattivare", + "mqtt_debounce_title": "Antirimbalzo LED HDD (ms)", "mqtt_enable_actions_description": "Consenti azioni remote (alimentazione ATX/DC, riavvio, jiggler, aggiornamenti) tramite MQTT", "mqtt_enable_actions_title": "Consenti azioni", "mqtt_enable_description": "Connetti a un broker MQTT per esporre lo stato e i controlli del dispositivo", diff --git a/ui/localization/messages/ja.json b/ui/localization/messages/ja.json index 39ba45dfe..4fe68b8a2 100644 --- a/ui/localization/messages/ja.json +++ b/ui/localization/messages/ja.json @@ -624,6 +624,8 @@ "mqtt_broker_description": "MQTTブローカーのIPアドレスまたはホスト名", "mqtt_broker_label": "ブローカーアドレス", "mqtt_description": "Home Assistant統合のためのMQTTブローカー接続を設定", + "mqtt_debounce_description": "HDD LED専用のデバウンス(ミリ秒)。ディスクアクティビティ中にHDD LEDが高速点滅する場合、この時間アイドルになるまでONとして報告されます。0に設定すると無効になります", + "mqtt_debounce_title": "HDD LED デバウンス (ms)", "mqtt_enable_actions_description": "MQTTを介したリモートアクション(ATX/DC電源、再起動、ジグラー、アップデート)を許可", "mqtt_enable_actions_title": "アクションを許可", "mqtt_enable_description": "MQTTブローカーに接続してデバイスの状態とコントロールを公開", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 02d8156f7..26d76d737 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "IP-adresse eller vertsnavn til MQTT-brokeren", "mqtt_broker_label": "Broker-adresse", "mqtt_description": "Konfigurer MQTT-broker-tilkobling for Home Assistant-integrasjon", + "mqtt_debounce_description": "Avprelling kun for HDD-LED (i millisekunder). Når HDD-LED blinker raskt under diskaktivitet, rapporteres den som PÅ til den er inaktiv i denne varigheten. Sett til 0 for å deaktivere", + "mqtt_debounce_title": "HDD-LED avprelling (ms)", "mqtt_enable_actions_description": "Tillat fjernhandlinger (ATX/DC-strøm, omstart, jiggler, oppdateringer) via MQTT", "mqtt_enable_actions_title": "Tillat handlinger", "mqtt_enable_description": "Koble til en MQTT-broker for å eksponere enhetsstatus og kontroller", diff --git a/ui/localization/messages/pt.json b/ui/localization/messages/pt.json index a197d113a..5f7226056 100644 --- a/ui/localization/messages/pt.json +++ b/ui/localization/messages/pt.json @@ -624,6 +624,8 @@ "mqtt_broker_description": "Endereço IP ou nome de host do broker MQTT", "mqtt_broker_label": "Endereço do Broker", "mqtt_description": "Configurar conexão com broker MQTT para integração com Home Assistant", + "mqtt_debounce_description": "Debounce apenas para o LED do HDD (em milissegundos). Quando o LED do HDD pisca rapidamente durante atividade de disco, ele é reportado como LIGADO até ficar inativo por esta duração. Definir como 0 para desativar", + "mqtt_debounce_title": "Debounce LED HDD (ms)", "mqtt_enable_actions_description": "Permitir ações remotas (energia ATX/DC, reinício, jiggler, atualizações) via MQTT", "mqtt_enable_actions_title": "Permitir Ações", "mqtt_enable_description": "Conectar a um broker MQTT para expor o estado e controles do dispositivo", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index 00b96ba43..408339b39 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "IP-adress eller värdnamn för MQTT-brokern", "mqtt_broker_label": "Broker-adress", "mqtt_description": "Konfigurera MQTT-broker-anslutning för Home Assistant-integration", + "mqtt_debounce_description": "Avrusning enbart för HDD-LED (i millisekunder). När HDD-LED blinkar snabbt under diskaktivitet rapporteras den som PÅ tills den är inaktiv under denna tid. Sätt till 0 för att inaktivera", + "mqtt_debounce_title": "HDD-LED avrusning (ms)", "mqtt_enable_actions_description": "Tillåt fjärrstyrning (ATX/DC-ström, omstart, jiggler, uppdateringar) via MQTT", "mqtt_enable_actions_title": "Tillåt åtgärder", "mqtt_enable_description": "Anslut till en MQTT-broker för att exponera enhetsstatus och kontroller", diff --git a/ui/localization/messages/zh-tw.json b/ui/localization/messages/zh-tw.json index 468d8d82d..5e4e1d9d7 100644 --- a/ui/localization/messages/zh-tw.json +++ b/ui/localization/messages/zh-tw.json @@ -624,6 +624,8 @@ "mqtt_broker_description": "MQTT Broker 的 IP 位址或主機名稱", "mqtt_broker_label": "Broker 位址", "mqtt_description": "設定 MQTT Broker 連線以整合 Home Assistant", + "mqtt_debounce_description": "僅針對HDD LED的消抖延遲(毫秒)。當HDD LED在磁碟活動期間快速閃爍時,在閒置達到此時間之前將保持報告為開啟狀態。設定為0可停用", + "mqtt_debounce_title": "HDD LED 消抖 (ms)", "mqtt_enable_actions_description": "允許透過 MQTT 觸發遠端操作(ATX/DC 電源、重新啟動、滑鼠抖動、更新)", "mqtt_enable_actions_title": "允許操作", "mqtt_enable_description": "連接到 MQTT Broker 以公開裝置狀態和控制", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index c1ded9072..fc01abadf 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -619,6 +619,8 @@ "mqtt_broker_description": "MQTT Broker 的 IP 地址或主机名", "mqtt_broker_label": "Broker 地址", "mqtt_description": "配置 MQTT Broker 连接以集成 Home Assistant", + "mqtt_debounce_description": "仅针对HDD LED的消抖延迟(毫秒)。当HDD LED在磁盘活动期间快速闪烁时,在空闲达到此时间之前将保持报告为开启状态。设置为0可禁用", + "mqtt_debounce_title": "HDD LED 消抖 (ms)", "mqtt_enable_actions_description": "允许通过 MQTT 触发远程操作(ATX/DC 电源、重启、鼠标抖动、更新)", "mqtt_enable_actions_title": "允许操作", "mqtt_enable_description": "连接到 MQTT Broker 以公开设备状态和控制", diff --git a/ui/src/routes/devices.$id.settings.mqtt.tsx b/ui/src/routes/devices.$id.settings.mqtt.tsx index 6ae7743c4..dfd748d78 100644 --- a/ui/src/routes/devices.$id.settings.mqtt.tsx +++ b/ui/src/routes/devices.$id.settings.mqtt.tsx @@ -19,6 +19,7 @@ interface MQTTSettings { tls_insecure: boolean; enable_ha_discovery: boolean; enable_actions: boolean; + debounce_ms: number; } interface MQTTStatus { @@ -39,6 +40,7 @@ export default function SettingsMqttRoute() { tls_insecure: false, enable_ha_discovery: true, enable_actions: true, + debounce_ms: 500, }); const [status, setStatus] = useState({ connected: false }); @@ -215,6 +217,19 @@ export default function SettingsMqttRoute() { onChange={e => updateField("enable_actions", e.target.checked)} /> + + + updateField("debounce_ms", parseInt(e.target.value) || 0)} + /> + )} diff --git a/usb_mass_storage.go b/usb_mass_storage.go index 5f444debe..b4fa43568 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -191,7 +191,6 @@ func rpcGetVirtualMediaState() (*VirtualMediaState, error) { func rpcUnmountImage() error { virtualMediaStateMutex.Lock() - defer virtualMediaStateMutex.Unlock() err := setMassStorageImage("\n") if err != nil { logger.Warn().Err(err).Msg("Remove Mass Storage Image Error") @@ -203,6 +202,10 @@ func rpcUnmountImage() error { nbdDevice = nil } currentVirtualMediaState = nil + virtualMediaStateMutex.Unlock() + if mqttManager != nil { + mqttManager.publishVirtualMediaState() + } return nil } @@ -303,6 +306,9 @@ func rpcMountWithHTTP(url string, mode VirtualMediaMode) error { return err } logger.Info().Msg("usb mass storage mounted") + if mqttManager != nil { + mqttManager.publishVirtualMediaState() + } return nil } @@ -313,23 +319,26 @@ func rpcMountWithStorage(filename string, mode VirtualMediaMode) error { } virtualMediaStateMutex.Lock() - defer virtualMediaStateMutex.Unlock() if currentVirtualMediaState != nil { + virtualMediaStateMutex.Unlock() return fmt.Errorf("another virtual media is already mounted") } fullPath := filepath.Join(imagesFolder, filename) fileInfo, err := os.Stat(fullPath) if err != nil { + virtualMediaStateMutex.Unlock() return fmt.Errorf("failed to get file info: %w", err) } if err := setMassStorageMode(mode == CDROM); err != nil { + virtualMediaStateMutex.Unlock() return fmt.Errorf("failed to set mass storage mode: %w", err) } err = setMassStorageImage(fullPath) if err != nil { + virtualMediaStateMutex.Unlock() return fmt.Errorf("failed to set mass storage image: %w", err) } currentVirtualMediaState = &VirtualMediaState{ @@ -338,6 +347,10 @@ func rpcMountWithStorage(filename string, mode VirtualMediaMode) error { Filename: filename, Size: fileInfo.Size(), } + virtualMediaStateMutex.Unlock() + if mqttManager != nil { + mqttManager.publishVirtualMediaState() + } return nil } diff --git a/webrtc.go b/webrtc.go index 697a5bc49..127e9e00e 100644 --- a/webrtc.go +++ b/webrtc.go @@ -416,11 +416,15 @@ func newSession(config SessionConfig) (*Session, error) { if incrActiveSessions() == 1 { onFirstSessionConnected() } + if mqttManager != nil { + mqttManager.publishSessionsState() + } } } //state changes on closing browser tab disconnected->failed, we need to manually close it - if connectionState == webrtc.ICEConnectionStateFailed { - scopedLogger.Debug().Msg("ICE Connection State is failed, closing peerConnection") + if connectionState == webrtc.ICEConnectionStateDisconnected || + connectionState == webrtc.ICEConnectionStateFailed { + scopedLogger.Debug().Str("state", connectionState.String()).Msg("ICE connection lost, closing peerConnection") _ = peerConnection.Close() } if connectionState == webrtc.ICEConnectionStateClosed { @@ -457,6 +461,9 @@ func newSession(config SessionConfig) (*Session, error) { scopedLogger.Info().Msg("last session disconnected, stopping video stream") onLastSessionDisconnected() } + if mqttManager != nil { + mqttManager.publishSessionsState() + } } } }) From 7e659afae9cd9d47f246e914da591c2c9a2bfe00 Mon Sep 17 00:00:00 2001 From: Patrick Hofmann Date: Fri, 13 Feb 2026 17:08:28 +0100 Subject: [PATCH 6/6] lint adjustments --- ui/src/routes/devices.$id.settings.mqtt.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.mqtt.tsx b/ui/src/routes/devices.$id.settings.mqtt.tsx index dfd748d78..6121982db 100644 --- a/ui/src/routes/devices.$id.settings.mqtt.tsx +++ b/ui/src/routes/devices.$id.settings.mqtt.tsx @@ -117,10 +117,7 @@ export default function SettingsMqttRoute() {
- + - +