1 Commits
Author SHA1 Message Date
gronod 7bed99cc4c fix: keep robot listeners if health port is busy (v0.1.2)
ServeHealth bind failures (common on HAOS when OTBR owns 8080) are
logged and ignored. Lookup, firmware, and XMPP stay up. Log each
successful listen address so add-on logs show the sockets.
2026-09-24 16:17:30 +00:00
6 changed files with 23 additions and 5 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ You need:
- An already provisioned Deebot N95 using the `wukong` / class `155` protocol captured for this project. Other Ecovacs models are not established as compatible. - An already provisioned Deebot N95 using the `wukong` / class `155` protocol captured for this project. Other Ecovacs models are not established as compatible.
- A Docker host (or a machine running the Go binary) with a **stable LAN IPv4 address** reachable from the robot. Reserve its address in DHCP. - A Docker host (or a machine running the Go binary) with a **stable LAN IPv4 address** reachable from the robot. Reserve its address in DHCP.
- Control of the DNS resolver supplied to the robot by DHCP. It must answer `lbo.ecouser.net` with that LAN address. A direct A record is sufficient. The bridge does not provide DNS. The XMPP JID domain `155.ecorobot.net` does not need a DNS override for this captured firmware. - Control of the DNS resolver supplied to the robot by DHCP. It must answer `lbo.ecouser.net` with that LAN address. A direct A record is sufficient. The bridge does not provide DNS. The XMPP JID domain `155.ecorobot.net` does not need a DNS override for this captured firmware.
- TCP **8007**, **8005**, and **5223** reachable from the robot at that address. TCP **8080** is the optional health endpoint; it need not be open to the robot. - TCP **8007**, **8005**, and **5223** reachable from the robot at that address. TCP **8080** is the optional health endpoint; it need not be open to the robot. If that port is already taken (for example OpenThread Border Router on Home Assistant OS), the process logs the bind error and keeps the robot listeners running. Set `HEALTH_PORT` to a free port if you still want `/healthz`.
- An MQTT broker reachable from the bridge and Home Assistant configured to use that broker, with MQTT discovery enabled. Its discovery prefix must match `HA_DISCOVERY_PREFIX` (normally `homeassistant`). - An MQTT broker reachable from the bridge and Home Assistant configured to use that broker, with MQTT discovery enabled. Its discovery prefix must match `HA_DISCOVERY_PREFIX` (normally `homeassistant`).
- A correct host clock and local timezone. The bridge sends the robot the current time and the process's local UTC offset on each session. - A correct host clock and local timezone. The bridge sends the robot the current time and the process's local UTC offset on each session.
@@ -67,7 +67,7 @@ Only `ADVERTISE_IP` and `MQTT_HOST` are required. An absent variable uses its de
| `PORT_LOOKUP` | `8007` | HTTP `POST /lookup.do` for `EcoMsgNew` and `EcoUpdate`. | | `PORT_LOOKUP` | `8007` | HTTP `POST /lookup.do` for `EcoMsgNew` and `EcoUpdate`. |
| `PORT_FIRMWARE` | `8005` | HTTP firmware check; returns the expected 404. | | `PORT_FIRMWARE` | `8005` | HTTP firmware check; returns the expected 404. |
| `PORT_XMPP` | `5223` | Plaintext robot XMPP listener; advertised for `EcoMsgNew`. | | `PORT_XMPP` | `5223` | Plaintext robot XMPP listener; advertised for `EcoMsgNew`. |
| `HEALTH_PORT` | `8080` | HTTP `GET /healthz` and the `healthcheck` subcommand. | | `HEALTH_PORT` | `8080` | HTTP `GET /healthz` and the `healthcheck` subcommand. Bind failure is non-fatal; robot listeners keep running. |
| `MQTT_HOST` | required | Broker DNS name or address, without a scheme or port. Also used as the TLS server name. | | `MQTT_HOST` | required | Broker DNS name or address, without a scheme or port. Also used as the TLS server name. |
| `MQTT_PORT` | `1883` or `8883` | Broker port; defaults to 8883 when `MQTT_TLS=true`, otherwise 1883. | | `MQTT_PORT` | `1883` or `8883` | Broker port; defaults to 8883 when `MQTT_TLS=true`, otherwise 1883. |
| `MQTT_TLS` | `false` | Enable TLS from the start of the MQTT TCP connection. Uses the system CA pool and verifies the broker hostname. | | `MQTT_TLS` | `false` | Enable TLS from the start of the MQTT TCP connection. Uses the system CA pool and verifies the broker hostname. |
+15 -3
View File
@@ -99,15 +99,26 @@ func run(ctx context.Context, environ []string) error {
shutdownXMPP = xmppServer.Shutdown shutdownXMPP = xmppServer.Shutdown
} }
errCh := make(chan error, 4) // Robot-facing listeners. A failure here stops the process.
const robotListeners = 3
errCh := make(chan error, robotListeners)
go func() { errCh <- httpx.ServeLookup(groupCtx, cfg) }() go func() { errCh <- httpx.ServeLookup(groupCtx, cfg) }()
go func() { errCh <- httpx.ServeFirmware(groupCtx, cfg) }() go func() { errCh <- httpx.ServeFirmware(groupCtx, cfg) }()
go func() { errCh <- httpx.ServeHealth(groupCtx, cfg) }()
go func() { errCh <- xmppServer.Serve(groupCtx) }() go func() { errCh <- xmppServer.Serve(groupCtx) }()
// Health is optional. Occupied 8080 (OTBR and other HA add-ons) must
// not tear down lookup/XMPP.
go func() {
if err := httpx.ServeHealth(groupCtx, cfg); err != nil && groupCtx.Err() == nil {
slog.Error("health listener failed; robot listeners continue", "err", err)
}
}()
var runErr error var runErr error
received := 0
select { select {
case runErr = <-errCh: case runErr = <-errCh:
received++
cancel() cancel()
case <-ctx.Done(): case <-ctx.Done():
} }
@@ -122,10 +133,11 @@ func run(ctx context.Context, environ []string) error {
} }
cancel() cancel()
for i := 0; i < 4; i++ { for received < robotListeners {
if err := <-errCh; err != nil && runErr == nil { if err := <-errCh; err != nil && runErr == nil {
runErr = err runErr = err
} }
received++
} }
if shutdownCtx.Err() != nil { if shutdownCtx.Err() != nil {
+2
View File
@@ -3,6 +3,7 @@ package httpx
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"time" "time"
@@ -21,6 +22,7 @@ func ServeFirmware(ctx context.Context, cfg config.Config) error {
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", addr, err) return fmt.Errorf("%s: %w", addr, err)
} }
slog.Info("firmware listening", "addr", addr)
srv := &http.Server{ srv := &http.Server{
Handler: mux, Handler: mux,
+2
View File
@@ -3,6 +3,7 @@ package httpx
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"time" "time"
@@ -23,6 +24,7 @@ func ServeHealth(ctx context.Context, cfg config.Config) error {
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", addr, err) return fmt.Errorf("%s: %w", addr, err)
} }
slog.Info("health listening", "addr", addr)
srv := &http.Server{ srv := &http.Server{
Handler: mux, Handler: mux,
+1
View File
@@ -22,6 +22,7 @@ func ServeLookup(ctx context.Context, cfg config.Config) error {
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", addr, err) return fmt.Errorf("%s: %w", addr, err)
} }
slog.Info("lookup listening", "addr", addr)
srv := &http.Server{ srv := &http.Server{
Handler: mux, Handler: mux,
+1
View File
@@ -56,6 +56,7 @@ func (s *Server) Serve(ctx context.Context) error {
return fmt.Errorf("%s: %w", addr, err) return fmt.Errorf("%s: %w", addr, err)
} }
s.ln = ln s.ln = ln
slog.Info("xmpp listening", "addr", addr)
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {