Author SHA1 Message Date
gronod 7bcf5322e1 feat: emby-mcp 1.0.16 player owner filter and HA links
Expose session/last-used user and device IP on player rows. Filter
retrieve_player_list by users, merge GET /Devices when include_offline
is set, and resolve spoken names to HA media_player entities via
player_links (device_id or IP). PlayNow still requires a live session.
2026-09-25 19:58:58 +00:00
gronod e12292f316 fix: n95-mqtt-bridge 0.1.2 builds upstream v0.1.2
Pin ha-n95-local-control tag v0.1.2
(7bed99cc4c3222bad648efcddcdfed95652277f7). Health bind failure no
longer takes down 8007/8005/5223 when OTBR owns 8080.
2026-09-24 16:18:51 +00:00
gronod 97c0e4dc04 fix: expose N95 listener ports on HAOS firewall (0.1.1)
Declare 8007, 8005, 5223, and 8080 in the add-on ports map so Supervisor
opens inbound host firewall holes. Keep host_network; Docker does not
remap the ports. Bump the add-on to 0.1.1 so Home Assistant picks it up.
2026-09-24 15:40:50 +00:00
gronod 1a2a97babc feat: add Deebot N95 Local Control add-on 2026-09-24 16:03:33 +01:00
32 changed files with 1284 additions and 15 deletions
+1
View File
@@ -8,6 +8,7 @@ Home Assistant custom add-on repository maintained by Gordon Bolton.
| Add-on | Description | Documentation |
|---|---|---|
| **Deebot N95 Local Control** | Local MQTT discovery and control for an already-provisioned Ecovacs Deebot N95 — replace its legacy cloud bootstrap and XMPP endpoint on a trusted LAN | [README](n95-mqtt-bridge/README.md) · [DOCS](n95-mqtt-bridge/DOCS.md) |
| **Emby MCP** | Emby Model Context Protocol server with a REST bridge — search and control an Emby media library from MCP clients and Home Assistant conversation agents | [README](emby-mcp/README.md) · [DOCS](emby-mcp/DOCS.md) |
| **OpenAI Codex Proxy** | OpenAI-compatible local endpoint backed by a ChatGPT account (Codex OAuth) — use ChatGPT Plus/Pro models from OpenAI-compatible integrations | [README](openai-codex-proxy/README.md) · [DOCS](openai-codex-proxy/DOCS.md) |
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## 1.0.16
- Player rows include `user_id`, `user_name`, `online`, and keep
`device_ip_address` (Emby session `RemoteEndPoint`, host only)
- `retrieve_player_list` accepts `users` (comma-separated include-list)
and `include_offline` (merge `GET /Devices` last-used owners)
- Add-on options `player_users` and `player_links` feed a default
include-list and HA entity map (`ha_entity`, `ha_source`, `device_ip`,
`emby_device_id`)
- New tool `resolve_media_player` for "play on the lounge": returns
HA entity/source plus `session_id` when live, or `wake_required`
- Conversation Functions YAML and system prompt updated in DOCS.md
### Potential breaking change
- Player JSON grows `user_id`, `user_name`, and `online`. Re-paste the
DOCS.md Functions block to pick up `emby_list_players` parameters and
`emby_resolve_player`. Existing PlayNow calls are unchanged.
- Empty `player_users` means no default filter (same listing as 1.0.15,
plus owner fields). Non-empty `player_users` hides other accounts even
when the model omits `users`.
## 1.0.15
- Restore `premiere_date` on item-shaped results (search, episode/season
+33 -3
View File
@@ -34,6 +34,8 @@ The add-on runs the Go Emby.MCP server in one container and exposes:
| `mcp_listen_addr` | `0.0.0.0:8085` | Bind address |
| `mcp_session_timeout` | `30m` | MCP session lifetime |
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` |
| `player_users` | `[]` | Default include-list of Emby usernames/ids applied when a tool call omits `users` |
| `player_links` | `[]` | Maps spoken names to HA `media_player` + Emby device. `names` is comma-separated. Set `device_ip` when the TV's LAN IP is known (Emby `device_ip_address` on a live session). WebOS often does **not** put that IP on the HA entity — copy it from the router or from an Emby session while the app is open. `emby_device_id` is the most stable key. |
| `debug.rest` | _(optional)_ | Shown under optional options. At DEBUG, log REST bodies |
| `debug.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` bodies |
| `debug.emby` | _(optional)_ | Shown under optional options. At DEBUG, log Emby API bodies |
@@ -101,7 +103,7 @@ These steps assume the add-on has Emby credentials configured and
Add this line to the conversation agent's instructions:
```text
- Media & Emby: Never use execute_services for Emby. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_list_players` to find active playback sessions, and `emby_playback_control` to send playback commands (PlayNow, Pause, Unpause, Stop, NextTrack). Use `emby_next_episode` when asked what episode to watch next.
- Media & Emby: Never use execute_services for Emby search or playback. Use `emby_search` to find films, TV series, episodes, or music tracks. Use `emby_episode_list` to list or count a series' episodes (seasons via `emby_season_list`). Each episode item includes `premiere_date` (YYYY-MM-DD first air date) when Emby has that metadata; use it for airdate questions. Use `emby_resolve_player` when the user names a room or TV ("play on the lounge"). If `wake_required` is true, use Home Assistant `media_player.turn_on` on `ha_entity` and `media_player.select_source` with `ha_source` (Emby), wait, then resolve again. Only then use `emby_playback_control` PlayNow with that `session_id`. Use `emby_list_players` with `users` set to the household include-list to hide other people's clients. Use `emby_next_episode` when asked what episode to watch next.
```
### Functions
@@ -146,19 +148,47 @@ Paste the following into the conversation agent's **Functions** list
- spec:
name: emby_list_players
description: List active Emby media players, active clients, and their session IDs.
description: List Emby media players. Each row includes user_name, user_id, device_ip_address, and online. Pass users as a comma-separated include-list of Emby usernames so other household accounts are omitted. Set include_offline true to include last-used devices with no live session.
parameters:
type: object
properties:
media_type:
type: string
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
users:
type: string
description: Comma-separated Emby usernames or user ids to include (household include-list).
include_offline:
type: boolean
description: Also list known Emby devices that have no live session (last-used user).
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
method: POST
payload_template: >-
{{ { "media_type": media_type | default("") } | to_json }}
{{ { "media_type": media_type | default(""), "users": users | default(""), "include_offline": include_offline | default(false) } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
name: emby_resolve_player
description: Resolve a room name, person, HA entity, Emby device name, device id, or IP to ha_entity/ha_source plus a live session_id when the Emby client is running. If wake_required is true, turn on the HA media_player and launch Emby before PlayNow.
parameters:
type: object
properties:
query:
type: string
description: Spoken name (lounge), media_player entity_id, Emby device name, device id, or IP.
users:
type: string
description: Comma-separated Emby usernames or user ids to include.
required:
- query
function:
type: rest
resource_template: "http://8e663231-emby-mcp:8085/call/resolve_media_player"
method: POST
payload_template: >-
{{ { "query": query, "users": users | default("") } | to_json }}
value_template: "{{ value_json.result }}"
- spec:
+1 -1
View File
@@ -20,7 +20,7 @@ LABEL \
io.hass.name="Emby MCP" \
io.hass.description="Emby Model Context Protocol server with REST bridge" \
io.hass.type="addon" \
io.hass.version="1.0.15" \
io.hass.version="1.0.16" \
org.opencontainers.image.title="emby-mcp" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
CMD [ "/run.sh" ]
+1 -1
View File
@@ -1 +1 @@
1.0.15
1.0.16
+2
View File
@@ -122,6 +122,8 @@ func runStdio(ctx context.Context, cfg *config.Config, checkOnly bool) {
logf("Startup checks have completed.\n\nRunning HA Emby MCP in standalone mode, press CTRL-C to exit.")
st := state.New(client, userID, cfg.MaxChunkSize)
st.PlayerUsers = cfg.PlayerUsers
st.PlayerLinks = cfg.PlayerLinks
srv := server.New(st)
runErr := srv.Run(ctx, &mcp.StdioTransport{})
+13 -1
View File
@@ -2,7 +2,7 @@ name: "Emby MCP"
description: >-
Emby Model Context Protocol server with a Home Assistant REST bridge.
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
version: "1.0.15"
version: "1.0.16"
slug: "emby_mcp"
init: false
startup: application
@@ -31,6 +31,8 @@ options:
mcp_listen_addr: "0.0.0.0:8085"
mcp_session_timeout: "30m"
log_level: INFO
player_users: []
player_links: []
schema:
emby_server_url: str
emby_username: str
@@ -44,6 +46,16 @@ schema:
mcp_listen_addr: str
mcp_session_timeout: str
log_level: list(DEBUG|INFO|WARN)
player_users:
- str
player_links:
- names: str
emby_device_id: str?
emby_device_name: str?
device_ip: str?
ha_entity: str
ha_source: str?
users: str?
debug:
rest: bool?
mcp: bool?
+23
View File
@@ -35,6 +35,19 @@ type Config struct {
DebugREST bool // DEBUG_REST
DebugMCP bool // DEBUG_MCP
DebugEmby bool // DEBUG_EMBY
PlayerUsers []string // PLAYER_USERS include-list (names or ids)
PlayerLinks []PlayerLink // PLAYER_LINKS JSON mappings to HA entities
}
// PlayerLink maps spoken names / Emby device identity onto a Home Assistant media_player.
type PlayerLink struct {
Names []string `json:"names"`
EmbyDeviceID string `json:"emby_device_id"`
EmbyDeviceName string `json:"emby_device_name"`
DeviceIP string `json:"device_ip"`
HAEntity string `json:"ha_entity"`
HASource string `json:"ha_source"`
Users []string `json:"users"`
}
// Load reads the .env file at path (if it exists), lets real environment
@@ -58,6 +71,7 @@ func Load(path string) (*Config, error) {
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
"MCP_RESTRICT_LOCALHOST",
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
"PLAYER_USERS", "PLAYER_LINKS",
} {
if v, ok := os.LookupEnv(k); ok && v != "" {
vals[k] = v
@@ -102,6 +116,15 @@ func Load(path string) (*Config, error) {
cfg.SessionTimeout = d
}
cfg.PlayerUsers = SplitCSV(vals["PLAYER_USERS"])
if s := strings.TrimSpace(vals["PLAYER_LINKS"]); s != "" {
links, err := ParsePlayerLinks(s)
if err != nil {
return nil, fmt.Errorf("invalid PLAYER_LINKS: %w", err)
}
cfg.PlayerLinks = links
}
if cfg.ServerURL == "" {
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
}
+23
View File
@@ -15,6 +15,7 @@ func clearEnv(t *testing.T) {
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
"PLAYER_USERS", "PLAYER_LINKS",
} {
t.Setenv(k, "")
}
@@ -130,3 +131,25 @@ MCP_TRANSPORT=grpc`)
t.Fatal("expected error for invalid transport")
}
}
func TestLoadPlayerPolicy(t *testing.T) {
clearEnv(t)
p := writeEnv(t, `EMBY_SERVER_URL="http://x"
EMBY_USERNAME=u
EMBY_PASSWORD=p
PLAYER_USERS=Gordon, Alice
PLAYER_LINKS=[{"names":"lounge,living room","ha_entity":"media_player.lounge","device_ip":"192.168.0.20","ha_source":"Emby"}]`)
cfg, err := Load(p)
if err != nil {
t.Fatal(err)
}
if len(cfg.PlayerUsers) != 2 || cfg.PlayerUsers[0] != "Gordon" {
t.Fatalf("users = %#v", cfg.PlayerUsers)
}
if len(cfg.PlayerLinks) != 1 || cfg.PlayerLinks[0].HAEntity != "media_player.lounge" {
t.Fatalf("links = %#v", cfg.PlayerLinks)
}
if len(cfg.PlayerLinks[0].Names) != 2 {
t.Fatalf("names = %#v", cfg.PlayerLinks[0].Names)
}
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"encoding/json"
"fmt"
"strings"
)
// SplitCSV splits a comma-separated list, trimming blanks.
func SplitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if strings.HasPrefix(s, "[") {
var arr []string
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return compactStrings(arr)
}
}
parts := strings.Split(s, ",")
return compactStrings(parts)
}
func compactStrings(in []string) []string {
var out []string
for _, p := range in {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
type playerLinkWire struct {
Names flexStrings `json:"names"`
EmbyDeviceID string `json:"emby_device_id"`
EmbyDeviceName string `json:"emby_device_name"`
DeviceIP string `json:"device_ip"`
HAEntity string `json:"ha_entity"`
HASource string `json:"ha_source"`
Users flexStrings `json:"users"`
}
type flexStrings []string
func (f *flexStrings) UnmarshalJSON(b []byte) error {
b = bytesTrim(b)
if len(b) == 0 || string(b) == "null" {
return nil
}
if b[0] == '[' {
var a []string
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*f = compactStrings(a)
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*f = SplitCSV(s)
return nil
}
func bytesTrim(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
// ParsePlayerLinks accepts a JSON array of PlayerLink objects.
func ParsePlayerLinks(s string) ([]PlayerLink, error) {
s = strings.TrimSpace(s)
if s == "" || s == "null" || s == "[]" {
return nil, nil
}
var wires []playerLinkWire
if err := json.Unmarshal([]byte(s), &wires); err != nil {
return nil, fmt.Errorf("expected JSON array: %w", err)
}
out := make([]PlayerLink, 0, len(wires))
for _, w := range wires {
out = append(out, PlayerLink{
Names: []string(w.Names),
EmbyDeviceID: strings.TrimSpace(w.EmbyDeviceID),
EmbyDeviceName: strings.TrimSpace(w.EmbyDeviceName),
DeviceIP: strings.TrimSpace(w.DeviceIP),
HAEntity: strings.TrimSpace(w.HAEntity),
HASource: strings.TrimSpace(w.HASource),
Users: []string(w.Users),
})
}
return out, nil
}
+94
View File
@@ -0,0 +1,94 @@
package emby
import (
"context"
"encoding/json"
"net"
"strings"
)
// DeviceInfo is a subset of GET /Devices.
type DeviceInfo struct {
ID string `json:"Id"`
Name string `json:"Name"`
AppName string `json:"AppName"`
LastUserName string `json:"LastUserName"`
LastUserID string `json:"LastUserId"`
DateLastActivity string `json:"DateLastActivity"`
IPAddress string `json:"IpAddress"`
}
// GetDevices lists known Emby client devices (last user, last activity).
func (c *Client) GetDevices(ctx context.Context) ([]DeviceInfo, error) {
var raw json.RawMessage
if err := c.Get(ctx, "/Devices", nil, &raw); err != nil {
return nil, err
}
raw = json.RawMessage(strings.TrimSpace(string(raw)))
if len(raw) == 0 {
return nil, nil
}
if raw[0] == '[' {
var items []DeviceInfo
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
return items, nil
}
var wrap struct {
Items []DeviceInfo `json:"Items"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, err
}
return wrap.Items, nil
}
// MergeOfflineDevices appends Devices that have no live session, using last-used
// user as owner. Session rows win when DeviceID matches.
func MergeOfflineDevices(live []PlayerSession, devices []DeviceInfo) []PlayerSession {
seen := map[string]struct{}{}
for _, p := range live {
if p.DeviceID != "" {
seen[p.DeviceID] = struct{}{}
}
}
out := append([]PlayerSession(nil), live...)
for _, d := range devices {
id := d.ID
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
out = append(out, PlayerSession{
ClientName: d.AppName,
DeviceID: id,
DeviceName: d.Name,
DeviceIPAddress: HostOf(d.IPAddress),
UserID: d.LastUserID,
UserName: d.LastUserName,
Online: false,
})
}
return out
}
// HostOf returns the host part of an address (strips port). IPv6 brackets are removed.
func HostOf(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
if strings.HasPrefix(addr, "[") {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return strings.Trim(addr, "[]")
}
if host, port, err := net.SplitHostPort(addr); err == nil && port != "" {
return host
}
return addr
}
+53
View File
@@ -0,0 +1,53 @@
package emby
import (
"context"
"encoding/json"
"net/http"
"testing"
)
func TestHostOf(t *testing.T) {
if HostOf("192.168.0.20:8096") != "192.168.0.20" {
t.Fatal(HostOf("192.168.0.20:8096"))
}
if HostOf("192.168.0.20") != "192.168.0.20" {
t.Fatal(HostOf("192.168.0.20"))
}
}
func TestGetDevicesWrapped(t *testing.T) {
c, srv := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/Devices" {
t.Errorf("path %s", r.URL.Path)
}
json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{
{"Id": "d9", "Name": "Lounge", "AppName": "Emby for Android", "LastUserName": "Gordon", "LastUserId": "u1", "IpAddress": "192.168.0.20"},
},
})
})
defer srv.Close()
devs, err := c.GetDevices(context.Background())
if err != nil {
t.Fatal(err)
}
if len(devs) != 1 || devs[0].LastUserName != "Gordon" {
t.Fatalf("%+v", devs)
}
}
func TestMergeOfflineDevices(t *testing.T) {
live := []PlayerSession{{DeviceID: "d1", SessionID: "s1", Online: true, UserName: "Gordon"}}
devs := []DeviceInfo{
{ID: "d1", Name: "dup"},
{ID: "d2", Name: "Bedroom", LastUserName: "Alice", IPAddress: "192.168.0.21"},
}
got := MergeOfflineDevices(live, devs)
if len(got) != 2 {
t.Fatalf("%+v", got)
}
if got[1].Online || got[1].UserName != "Alice" {
t.Fatalf("%+v", got[1])
}
}
+56 -5
View File
@@ -11,12 +11,15 @@ import (
// now_playing_* fields are omitted when the player is idle.
type PlayerSession struct {
ClientName string `json:"client_name"`
SessionID string `json:"session_id"`
SessionID string `json:"session_id,omitempty"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceIPAddress string `json:"device_ip_address"`
DeviceIPAddress string `json:"device_ip_address,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Online bool `json:"online"`
LocalToMediaServer bool `json:"local_to_media_server"`
MediaTypes []string `json:"media_types"`
MediaTypes []string `json:"media_types,omitempty"`
NowPlayingTitle string `json:"now_playing_title,omitempty"`
NowPlayingArtists []string `json:"now_playing_artists,omitempty"`
NowPlayingAlbum string `json:"now_playing_album,omitempty"`
@@ -62,9 +65,12 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
SessionID: s.ID,
DeviceID: s.DeviceID,
DeviceName: s.DeviceName,
DeviceIPAddress: s.RemoteEndPoint,
DeviceIPAddress: HostOf(s.RemoteEndPoint),
UserID: s.UserID,
UserName: s.UserName,
Online: true,
MediaTypes: s.PlayableMediaTypes,
LocalToMediaServer: s.RemoteEndPoint == "::1" || s.RemoteEndPoint == "127.0.0.1",
LocalToMediaServer: isLoopback(s.RemoteEndPoint),
}
if np := s.NowPlayingItem; np != nil {
ps.NowPlayingTitle = np.Name
@@ -85,6 +91,51 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
return out, nil
}
// FilterPlayersByUsers keeps sessions whose UserName or UserId is in include.
// An empty include list means no extra filter.
func FilterPlayersByUsers(players []PlayerSession, include []string) []PlayerSession {
want := normalizeUserList(include)
if len(want) == 0 {
return players
}
out := make([]PlayerSession, 0, len(players))
for _, p := range players {
if userAllowed(p.UserID, p.UserName, want) {
out = append(out, p)
}
}
return out
}
func normalizeUserList(in []string) []string {
var out []string
for _, u := range in {
for _, part := range strings.Split(u, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, strings.ToLower(part))
}
}
}
return out
}
func userAllowed(userID, userName string, want []string) bool {
id := strings.ToLower(strings.TrimSpace(userID))
name := strings.ToLower(strings.TrimSpace(userName))
for _, w := range want {
if w == id || w == name {
return true
}
}
return false
}
func isLoopback(addr string) bool {
h := HostOf(addr)
return h == "127.0.0.1" || h == "::1" || h == "localhost"
}
// PlayQueueItem is the output shape for play queue entries.
type PlayQueueItem struct {
Title string `json:"title"`
+18
View File
@@ -17,6 +17,7 @@ func TestGetPlayerSessions(t *testing.T) {
{
"Client": "Emby Web", "Id": "s1", "DeviceId": "d1",
"DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1",
"UserId": "u1", "UserName": "Gordon",
"PlayableMediaTypes": []string{"Audio", "Video"},
"NowPlayingItem": map[string]any{
"Name": "Track", "Id": "i1", "Artists": []string{"A"},
@@ -48,6 +49,23 @@ func TestGetPlayerSessions(t *testing.T) {
if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused {
t.Error("expected is_paused")
}
if s.UserName != "Gordon" || s.UserID != "u1" || !s.Online {
t.Errorf("owner = %+v", s)
}
}
func TestFilterPlayersByUsers(t *testing.T) {
in := []PlayerSession{
{SessionID: "a", UserName: "Gordon", UserID: "u1"},
{SessionID: "b", UserName: "Alice", UserID: "u2"},
}
got := FilterPlayersByUsers(in, []string{"gordon"})
if len(got) != 1 || got[0].SessionID != "a" {
t.Fatalf("got %+v", got)
}
if n := FilterPlayersByUsers(in, nil); len(n) != 2 {
t.Fatalf("unfiltered %+v", n)
}
}
func TestGetPlayerSessionsMediaTypeFilter(t *testing.T) {
+4 -1
View File
@@ -28,7 +28,10 @@ func NewHandler(cfg *config.Config, hostname string) http.Handler {
}
// Per-session state: library selection and search chunking are
// isolated between concurrent HTTP clients.
return server.New(state.New(client, userID, cfg.MaxChunkSize))
st := state.New(client, userID, cfg.MaxChunkSize)
st.PlayerUsers = cfg.PlayerUsers
st.PlayerLinks = cfg.PlayerLinks
return server.New(st)
}
mcpHandler := mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{
SessionTimeout: cfg.SessionTimeout,
+159
View File
@@ -0,0 +1,159 @@
package server
import (
"strings"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
)
// ResolvedPlayer is the output of resolve_media_player.
type ResolvedPlayer struct {
Query string `json:"query"`
MatchedName string `json:"matched_name,omitempty"`
HAEntity string `json:"ha_entity,omitempty"`
HASource string `json:"ha_source,omitempty"`
EmbyDeviceID string `json:"emby_device_id,omitempty"`
EmbyDeviceName string `json:"emby_device_name,omitempty"`
DeviceIP string `json:"device_ip,omitempty"`
SessionID string `json:"session_id,omitempty"`
Online bool `json:"online"`
UserName string `json:"user_name,omitempty"`
UserID string `json:"user_id,omitempty"`
WakeRequired bool `json:"wake_required"`
Note string `json:"note,omitempty"`
}
func resolvePlayer(query string, links []config.PlayerLink, players []emby.PlayerSession) (ResolvedPlayer, bool) {
q := strings.TrimSpace(query)
out := ResolvedPlayer{Query: q}
if q == "" {
out.Note = "ERROR: no query was supplied"
return out, false
}
ql := strings.ToLower(q)
if link, name := matchLink(ql, links); link != nil {
out.MatchedName = name
out.HAEntity = link.HAEntity
out.HASource = link.HASource
out.EmbyDeviceID = link.EmbyDeviceID
out.EmbyDeviceName = link.EmbyDeviceName
out.DeviceIP = emby.HostOf(link.DeviceIP)
if sess, ok := matchSession(link, players); ok {
fillFromSession(&out, sess)
} else {
out.WakeRequired = true
out.Note = "No live Emby session for this device. Turn on the Home Assistant media_player and launch the Emby client, then list or resolve again."
}
return out, true
}
for _, p := range players {
if sessionMatchesQuery(ql, p) {
out.MatchedName = firstNonEmpty(p.DeviceName, p.ClientName)
fillFromSession(&out, p)
if !p.Online {
out.WakeRequired = true
out.Note = "Device is known to Emby but has no live session. Turn on the Home Assistant media_player and launch the Emby client."
}
return out, true
}
}
out.Note = "ERROR: no player matched that name, device id, or IP"
return out, false
}
func matchLink(ql string, links []config.PlayerLink) (*config.PlayerLink, string) {
for i := range links {
l := &links[i]
for _, n := range l.Names {
if strings.ToLower(strings.TrimSpace(n)) == ql {
return l, n
}
}
if strings.ToLower(l.HAEntity) == ql {
return l, l.HAEntity
}
if l.EmbyDeviceID != "" && strings.ToLower(l.EmbyDeviceID) == ql {
return l, l.EmbyDeviceID
}
if l.EmbyDeviceName != "" && strings.ToLower(l.EmbyDeviceName) == ql {
return l, l.EmbyDeviceName
}
if ip := emby.HostOf(l.DeviceIP); ip != "" && strings.ToLower(ip) == ql {
return l, ip
}
}
return nil, ""
}
func matchSession(link *config.PlayerLink, players []emby.PlayerSession) (emby.PlayerSession, bool) {
linkIP := emby.HostOf(link.DeviceIP)
for _, p := range players {
if link.EmbyDeviceID != "" && p.DeviceID == link.EmbyDeviceID && p.Online {
return p, true
}
}
for _, p := range players {
if linkIP != "" && emby.HostOf(p.DeviceIPAddress) == linkIP && p.Online {
return p, true
}
if link.EmbyDeviceName != "" && strings.EqualFold(p.DeviceName, link.EmbyDeviceName) && p.Online {
return p, true
}
}
return emby.PlayerSession{}, false
}
func sessionMatchesQuery(ql string, p emby.PlayerSession) bool {
if strings.ToLower(p.DeviceID) == ql {
return true
}
if strings.ToLower(p.DeviceName) == ql {
return true
}
if strings.ToLower(p.ClientName) == ql {
return true
}
if ip := emby.HostOf(p.DeviceIPAddress); ip != "" && strings.ToLower(ip) == ql {
return true
}
if strings.ToLower(p.UserName) == ql {
return true
}
return false
}
func fillFromSession(out *ResolvedPlayer, p emby.PlayerSession) {
out.SessionID = p.SessionID
out.Online = p.Online
out.UserName = p.UserName
out.UserID = p.UserID
if out.EmbyDeviceID == "" {
out.EmbyDeviceID = p.DeviceID
}
if out.EmbyDeviceName == "" {
out.EmbyDeviceName = p.DeviceName
}
if out.DeviceIP == "" {
out.DeviceIP = emby.HostOf(p.DeviceIPAddress)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func mergeUserFilters(toolUsers string, configured []string) []string {
fromTool := config.SplitCSV(toolUsers)
if len(fromTool) > 0 {
return fromTool
}
return configured
}
@@ -0,0 +1,41 @@
package server
import (
"testing"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
)
func TestResolvePlayerByAliasAndIP(t *testing.T) {
links := []config.PlayerLink{{
Names: []string{"lounge", "living room"},
HAEntity: "media_player.lounge_tv",
HASource: "Emby",
DeviceIP: "192.168.0.20",
EmbyDeviceID: "dev-lounge",
}}
live := []emby.PlayerSession{{
SessionID: "sess-1",
DeviceID: "dev-lounge",
DeviceName: "Lounge Shield",
DeviceIPAddress: "192.168.0.20",
UserName: "Gordon",
Online: true,
}}
got, ok := resolvePlayer("lounge", links, live)
if !ok || got.SessionID != "sess-1" || got.HAEntity != "media_player.lounge_tv" || got.WakeRequired {
t.Fatalf("%+v ok=%v", got, ok)
}
offline, ok := resolvePlayer("living room", links, nil)
if !ok || !offline.WakeRequired || offline.SessionID != "" {
t.Fatalf("offline %+v ok=%v", offline, ok)
}
}
func TestResolvePlayerUnknown(t *testing.T) {
_, ok := resolvePlayer("attic", nil, nil)
if ok {
t.Fatal("expected miss")
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const (
Name = "HA Emby MCP"
Version = "1.0.15"
Version = "1.0.16"
Purpose = `These MCP tools allow you to control an Emby media server. Using them you can retrieve
a list of libraries, genres, playlists, audio & video items, and player sessions.
You can browse series, seasons and episodes, find the next episode to watch,
+48 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
"git.i3omb.com/gronod/emby-mcp/internal/state"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -14,23 +15,68 @@ func registerPlayerTools(s *mcp.Server, st *state.State) {
Description: `Retrieve a list of media players that we can use with the supplied media type in JSON format.
A human may use any JSON field to identify a player, but do not display the 'device_id' or 'session_id'
You must only supply the 'session_id' field to identify the player when using the control_media_player or retrieve_player_queue tools.
Each row includes user_name/user_id of the signed-in or last-used Emby user, device_ip_address when Emby reports it, and online=false for known devices with no live session.
When the user names a room or person, prefer resolve_media_player. Filter with users so other household accounts drop out.
Args:
media_type (str, optional): List only players of this media type (one of: 'Audio', 'Video', 'Photo') or an empty string to list all players
users (str, optional): Comma-separated Emby usernames or user ids to include. Combined with the add-on player_users include-list when empty.
include_offline (bool, optional): When true, also list GET /Devices entries that have no live session (last-used user).
Returns:
List of dicts as JSON with keys including client_name, session_id, device_id, device_name,
device_ip_address, local_to_media_server, media_types, and now_playing_* fields (times as hh:mm:ss).`,
device_ip_address, user_id, user_name, online, local_to_media_server, media_types, and now_playing_* fields (times as hh:mm:ss).`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
MediaType string `json:"media_type" jsonschema:"List only players of this media type (one of: 'Audio', 'Video', 'Photo') or empty for all"`
MediaType string `json:"media_type" jsonschema:"List only players of this media type (one of: 'Audio', 'Video', 'Photo') or empty for all"`
Users string `json:"users" jsonschema:"Comma-separated Emby usernames or user ids to include"`
IncludeOffline bool `json:"include_offline" jsonschema:"Also list known devices with no live session"`
}) (*mcp.CallToolResult, any, error) {
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType)
if err != nil {
return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, nil
}
if in.IncludeOffline {
devs, derr := st.Client.GetDevices(ctx)
if derr != nil {
return textResult(errf("ERROR: failed to retrieve device list because: %v", derr)), nil, nil
}
sessions = emby.MergeOfflineDevices(sessions, devs)
}
sessions = emby.FilterPlayersByUsers(sessions, mergeUserFilters(in.Users, st.PlayerUsers))
return jsonResult(sessions), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "resolve_media_player",
Description: `Resolve a spoken room, person, HA entity, Emby device name, device id, or IP to a Home Assistant media_player and an Emby session when one is live.
Use this before PlayNow when the user says "play on the lounge" or similar. If wake_required is true, use Home Assistant to turn on ha_entity and select ha_source (Emby), wait, then resolve again. Do not call PlayNow until session_id is present.
Args:
query (str): Room alias, person, media_player entity_id, Emby device name, device id, or IP address
users (str, optional): Comma-separated include-list of Emby usernames or user ids
Returns:
JSON object with ha_entity, ha_source, emby_device_id, device_ip, session_id, online, user_name, wake_required.`,
}, func(ctx context.Context, req *mcp.CallToolRequest, in struct {
Query string `json:"query" jsonschema:"Room alias, HA entity, Emby device name, device id, or IP"`
Users string `json:"users" jsonschema:"Comma-separated Emby usernames or user ids to include"`
}) (*mcp.CallToolResult, any, error) {
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, "")
if err != nil {
return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, nil
}
devs, derr := st.Client.GetDevices(ctx)
if derr == nil {
sessions = emby.MergeOfflineDevices(sessions, devs)
}
sessions = emby.FilterPlayersByUsers(sessions, mergeUserFilters(in.Users, st.PlayerUsers))
resolved, ok := resolvePlayer(in.Query, st.PlayerLinks, sessions)
if !ok {
return textResult(resolved.Note), nil, nil
}
return jsonResult(resolved), nil, nil
})
mcp.AddTool(s, &mcp.Tool{
Name: "retrieve_player_queue",
Description: `Retrieve a list of items in the play queue of a media player in JSON format.
+3
View File
@@ -6,6 +6,7 @@ package state
import (
"sync"
"git.i3omb.com/gronod/emby-mcp/internal/config"
"git.i3omb.com/gronod/emby-mcp/internal/emby"
)
@@ -25,6 +26,8 @@ type State struct {
Client *emby.Client
UserID string
MaxChunkSize int
PlayerUsers []string
PlayerLinks []config.PlayerLink
libraries []emby.Library
current *emby.Library
+5
View File
@@ -29,4 +29,9 @@ else
export MCP_RESTRICT_LOCALHOST="false"
fi
if [ -f /data/options.json ]; then
export PLAYER_USERS="$(jq -c '.player_users // []' /data/options.json)"
export PLAYER_LINKS="$(jq -c '.player_links // []' /data/options.json)"
fi
exec /emby-mcp
+31
View File
@@ -0,0 +1,31 @@
# Changelog
## 0.1.2
- Build upstream `ha-n95-local-control` **v0.1.2**
(`7bed99cc4c3222bad648efcddcdfed95652277f7`).
- Health bind failure is no longer fatal. If 8080 is taken (OpenThread
Border Router and other add-ons), lookup 8007, firmware 8005, and
XMPP 5223 stay up. Set optional `health_port` if you still want
`/healthz`.
- Upstream logs each successful listen address.
## 0.1.1
- Declare TCP 8007, 8005, 5223, and 8080 in the add-on `ports` map so
Home Assistant OS Supervisor opens those ports on the host firewall.
Host networking is unchanged; Docker does not remap the ports.
- Potential operational change: after update, the add-on Network tab
lists the four listeners. Rebuild/reinstall so HA picks up 0.1.1.
## 0.1.0
- First Home Assistant add-on release
- Build the upstream Deebot N95 bridge from the pinned `v0.1.0` tag
(`75354b35180f4cc5e92187b6149322b0b94533ba`)
- Bind robot-facing lookup, firmware, and XMPP listeners on the Home Assistant
host network
- Discover Supervisor MQTT connection details automatically with per-field
configuration overrides and external-broker support
- Expose the complete bridge configuration, with advanced and risky settings
hidden under optional configuration by default
+256
View File
@@ -0,0 +1,256 @@
# Deebot N95 Local Control — documentation
Full installation, network, configuration, verification, and troubleshooting
information for the **Deebot N95 Local Control** Home Assistant add-on.
## How it works
The add-on replaces the legacy Ecovacs bootstrap and XMPP destinations used by
an already-provisioned Deebot N95, then exposes the robot through Home
Assistant MQTT discovery.
```text
N95 ── DNS ──> your LAN resolver
N95 ── HTTP 8007 / 8005, XMPP 5223 ──> this add-on ──> MQTT broker ──> Home Assistant
```
The add-on does not provision a robot and does not run a DNS server. It supports
multiple robots; each robot receives its own MQTT client and topic tree after
it reaches XMPP READY state and reveals its serial number.
## Before you start
You need:
* An already-provisioned Deebot N95 using the captured `wukong` / class `155`
protocol. Other Ecovacs models are not established as compatible.
* A stable LAN IPv4 address for the Home Assistant host, reserved in DHCP.
* Control of the DNS resolver supplied to the robot by DHCP.
* TCP `8007`, `8005`, and `5223` available on the Home Assistant host and
reachable from the robot. TCP `8080` is used for health checks.
* A broker reachable from the Home Assistant host and Home Assistant MQTT
configured with discovery enabled.
* A correct host clock and IANA timezone.
The robot caches its XMPP address after boot. If the address or listener ports
change, update DNS/configuration and reboot the robot; an ordinary reconnect
continues to use the cached destination.
## Step 1 — reserve the host address and configure DNS
Reserve the Home Assistant host's LAN IPv4 address. Configure the resolver sent
to the robot by DHCP with this A record:
```text
lbo.ecouser.net A <HOME_ASSISTANT_LAN_IPV4>
```
Set the add-on's `advertise_ip` to the same literal IPv4 address. Do not use
`0.0.0.0`, a container address, or a hostname. The `155.ecorobot.net` XMPP JID
domain does not need a DNS override for the captured firmware.
## Step 2 — install and configure
1. Add this repository in Home Assistant
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
`https://git.i3omb.com/gronod/ha-gronod-addons`
2. Install **Deebot N95 Local Control**.
3. Enter the reserved host IPv4 under **Advertised IP address**.
4. Select the correct IANA timezone, for example `Europe/London`.
5. Leave advanced listener values unset unless their default ports conflict.
When a Supervisor MQTT service is available, the add-on automatically reads
its host, port, TLS flag, username, and password. Every manually supplied MQTT
option overrides only that individual discovered field. This permits, for
example, using discovered credentials with a manually supplied private CA.
For an external broker with no Supervisor service, set at least `mqtt_host` and
any required credentials/TLS options.
## Step 3 — start and redirect the robot
Start the add-on and inspect its log. Verify the DNS and HTTP endpoints using
the checks below, then reboot the robot. The robot should bootstrap through the
lookup endpoint, open XMPP to the add-on, and appear as an MQTT vacuum in Home
Assistant.
No MQTT connection is expected when the add-on first starts. The bridge creates
a broker client only after a robot reaches XMPP READY and identifies itself.
## Configuration
Normal setup shows `advertise_ip`, `timezone`, and `log_level`. Select **Show
unused optional configuration options** to reveal MQTT overrides and advanced
network/protocol controls.
| Option | Default | Environment | Description |
|---|---|---|---|
| `advertise_ip` | required | `ADVERTISE_IP` | Stable, nonzero host IPv4 returned in lookup responses |
| `timezone` | `Etc/UTC` | `TZ` | IANA timezone controlling the local offset sent to the robot |
| `log_level` | `info` | `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` |
| `bind_address` | `0.0.0.0` | `BIND_ADDRESS` | Advanced listener bind IP; normally leave unset |
| `port_lookup` | `8007` | `PORT_LOOKUP` | Advanced HTTP `POST /lookup.do` listener |
| `port_firmware` | `8005` | `PORT_FIRMWARE` | Advanced firmware-check listener; expected to return 404 |
| `port_xmpp` | `5223` | `PORT_XMPP` | Advanced plaintext robot XMPP listener |
| `health_port` | `8080` | `HEALTH_PORT` | Advanced HTTP `GET /healthz` listener. Bind failure is non-fatal from v0.1.2; robot ports stay up. Pick another port if OTBR already owns 8080. |
| `mqtt_host` | Supervisor/required | `MQTT_HOST` | Broker host without scheme or port; overrides discovery |
| `mqtt_port` | `1883`/`8883` | `MQTT_PORT` | Broker port; upstream chooses 8883 when TLS is enabled |
| `mqtt_tls` | `false` | `MQTT_TLS` | Start the MQTT connection with TLS and hostname verification |
| `mqtt_ca_file` | unset | `MQTT_CA_FILE` | Readable PEM CA bundle, normally `/ssl/<file>.pem` |
| `mqtt_username` | Supervisor/unset | `MQTT_USERNAME` | Broker username override |
| `mqtt_password` | Supervisor/unset | `MQTT_PASSWORD` | Broker password override; nonempty requires username |
| `mqtt_client_id` | `n95bridge-<hostname>` | `MQTT_CLIENT_ID` | Client ID prefix; `[A-Za-z0-9_-]{1,64}` |
| `mqtt_base` | `ecovacs` | `MQTT_BASE` | One MQTT topic level before `/<serial>` |
| `ha_discovery_prefix` | `homeassistant` | `HA_DISCOVERY_PREFIX` | Must match Home Assistant MQTT discovery prefix |
| `controller_jid` | `n95bridge@ecouser.net/homeassistant` | `CONTROLLER_JID` | Advanced virtual controller JID in `local@domain/resource` form |
| `raw_commands` | `false` | `RAW_COMMANDS` | Risky protocol-level raw command input; keep disabled normally |
Optional values are exported only when configured, preserving upstream
defaults. Boolean values are passed as lowercase `true` or `false`. All four
listener ports must be distinct and in the range 1–65535.
## Host networking and ports
The add-on uses host networking because the robot must connect to the exact
address and ports returned by `/lookup.do`. The same ports are declared in
`config.yaml` so Supervisor opens them on the Home Assistant OS host
firewall. Without that map, the process can listen on the host while LAN
clients (including the robot) time out.
| Port | Purpose | Robot access |
|---|---|---|
| `8007/tcp` | Bootstrap lookup (`EcoMsgNew`, `EcoUpdate`) | required |
| `8005/tcp` | Firmware check (expected 404 response) | required |
| `5223/tcp` | Plaintext XMPP | required |
| `8080/tcp` | Health endpoint | not required |
Changing a robot-facing port changes both the listener and the value advertised
to the robot. Also change the matching `ports:` entry so Supervisor still
opens the host firewall for that port. Check that another host service does
not already occupy the port. Host networking means Docker does not remap
these ports; the `ports` map is for Supervisor visibility and firewall
allowance only.
## MQTT and Home Assistant discovery
Supervisor MQTT values are loaded first; explicitly configured options then
replace individual fields. The final broker host must come from one of those
sources or the add-on exits with an actionable error.
For TLS with a private CA, place a readable PEM bundle in Home Assistant's
`ssl` directory and set `mqtt_ca_file` to its in-container path, such as
`/ssl/broker-ca.pem`. TLS hostname verification remains enabled, so
`mqtt_host` must match the broker certificate.
The bridge publishes discovery below
`<ha_discovery_prefix>/vacuum/ecovacs_<serial>/config` and robot data below
`<mqtt_base>/<serial>/...`. Broker ACLs must allow each generated client to
publish/subscribe under those trees. The default client prefix is based on the
add-on hostname and has the robot serial appended.
## Robot features and entities
The tagged upstream release publishes a native MQTT vacuum with availability,
state, battery, fan speed, error information, and start, stop, dock, spot, and
locate commands. It also supports extension movement commands, cleaning modes,
schedule CRUD, consumable lifespan state, and diagnostic topics. Pause, resume,
mapping, and segment cleaning are not advertised. Multiple connected robots remain isolated by
serial number.
`raw_commands` permits protocol-level `<ctl>` input intended for controlled
experimentation. It bypasses normal high-level command constraints and should
remain disabled for ordinary use.
## Check the installation
Replace the example addresses with your resolver and Home Assistant host:
```sh
nslookup lbo.ecouser.net 192.0.2.53
curl -sS -X POST -H 'Content-Type: application/json' \
--data '{"todo":"FindBest","service":"EcoMsgNew"}' \
http://192.0.2.10:8007/lookup.do
curl -sS -X POST -H 'Content-Type: application/json' \
--data '{"todo":"FindBest","service":"EcoUpdate"}' \
http://192.0.2.10:8007/lookup.do
curl -i http://192.0.2.10:8005/products/wukong/class/155/firmware/latest.json
curl -sS http://192.0.2.10:8080/healthz
```
The lookup responses must contain the configured advertised IPv4 and numeric
ports. The firmware request should return HTTP 404. The health endpoint should
report healthy listeners. After these pass, reboot the robot and check:
1. the add-on log for XMPP READY and the robot serial;
2. broker activity below `ecovacs/<serial>` and the discovery prefix;
3. a newly discovered MQTT vacuum entity in Home Assistant;
4. state refresh and a harmless command such as locating the robot.
## Security notes
* Robot XMPP, including SASL PLAIN credentials, is plaintext. Keep ports 8005,
8007, and 5223 restricted to the trusted robot LAN.
* MQTT authentication and TLS protect only the broker connection; they do not
encrypt robot traffic.
* Protect broker passwords and private CA files. The wrapper and upstream
configuration log do not print the MQTT password.
* Do not expose the robot-facing listeners to the internet.
## Troubleshooting
**The add-on exits with “No MQTT broker is available”**
No Supervisor MQTT service was found and `mqtt_host` is unset. Install/configure
a broker service or enter the external broker host.
**The robot still contacts the cloud**
Query the exact resolver supplied by DHCP and confirm `lbo.ecouser.net` returns
`advertise_ip`. Remove cached/secondary public DNS paths, then reboot the robot.
**LAN clients time out on 8007 even though the add-on log shows listeners**
On Home Assistant OS, Supervisor only opens inbound host ports listed under
`ports:` in `config.yaml`. Version 0.1.1 declares 8007, 8005, 5223, and 8080.
Rebuild/update the add-on so that version is installed, then confirm the
Network tab lists those ports. Loopback on the HAOS box can succeed while
the robot still times out if the firewall hole is missing.
**8007/8005/5223 never appear on the host while 8080 is owned by OTBR**
Before v0.1.2 a busy health port stopped every listener. From v0.1.2 the
process logs `health listener failed; robot listeners continue` and keeps
8007/8005/5223. Set optional `health_port` to a free port if you want
`/healthz`. Rebuild so the add-on is 0.1.2.
**The add-on reports an address-already-in-use error**
Another host service owns lookup, firmware, or XMPP. Stop that service or
set a distinct optional port. Reboot the robot after changing advertised ports.
**The robot does not reconnect after an address or port change**
The N95 caches XMPP details for its powered-on lifetime. Power-cycle/reboot it
to force a new bootstrap lookup.
**There is no MQTT connection immediately after startup**
This is expected until a robot reaches XMPP READY and provides its serial.
Investigate DNS, listener reachability, and XMPP logs first.
**The robot connects but no entity appears**
Confirm Home Assistant MQTT discovery is enabled and
`ha_discovery_prefix` matches its configured prefix. Check broker ACLs for both
the discovery and robot topic trees.
**MQTT authentication or TLS fails**
Check the effective host, port, username, and TLS override combination. For a
private CA, verify the `/ssl/...` path exists and is readable, and that the
broker certificate matches `mqtt_host`.
**Schedules run at the wrong time**
Set `timezone` to the correct IANA name and restart the add-on, then reconnect
the robot so it receives the updated time and UTC offset.
## Upstream and standalone usage
The add-on builds the immutable
[ha-n95-local-control v0.1.2 release](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2)
at commit `7bed99cc4c3222bad648efcddcdfed95652277f7`. See the
[tagged upstream README](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/README.md)
for Docker Compose and direct Go-binary operation outside Home Assistant. The
upstream project is under the
[Apache License 2.0](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/LICENCE.md).
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
ARG GO_VERSION=1.27.1
ARG BUILD_FROM=ghcr.io/home-assistant/base:3.22
FROM golang:${GO_VERSION}-alpine AS build
RUN apk add --no-cache ca-certificates git
WORKDIR /src
ARG UPSTREAM_REPOSITORY=https://git.i3omb.com/gronod/ha-n95-local-control.git
ARG UPSTREAM_REF=v0.1.2
ARG UPSTREAM_COMMIT=7bed99cc4c3222bad648efcddcdfed95652277f7
RUN git init . \
&& git remote add origin "${UPSTREAM_REPOSITORY}" \
&& git fetch --depth 1 origin "refs/tags/${UPSTREAM_REF}:refs/tags/${UPSTREAM_REF}" \
&& test "$(git rev-list -n 1 "${UPSTREAM_REF}^{commit}")" = "${UPSTREAM_COMMIT}" \
&& git checkout --detach "${UPSTREAM_REF}^{commit}"
RUN go mod download
ARG TARGETOS=linux
ARG TARGETARCH
ARG TARGETVARIANT
RUN case "${TARGETARCH}/${TARGETVARIANT}" in \
amd64/) goarch=amd64; goarm= ;; \
arm64/) goarch=arm64; goarm= ;; \
386/) goarch=386; goarm= ;; \
arm/v6) goarch=arm; goarm=6 ;; \
arm/v7) goarch=arm; goarm=7 ;; \
*) echo "Unsupported target: ${TARGETARCH}/${TARGETVARIANT}" >&2; exit 1 ;; \
esac \
&& CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${goarch}" GOARM="${goarm}" \
go build -trimpath -ldflags="-s -w" -o /out/n95bridge ./cmd/n95bridge
FROM ${BUILD_FROM}
COPY --from=build /out/n95bridge /n95bridge
COPY rootfs /
RUN chmod a+x /run.sh
ARG BUILD_VERSION=0.1.2
ARG BUILD_ARCH
ARG UPSTREAM_REF=v0.1.2
ARG UPSTREAM_COMMIT=7bed99cc4c3222bad648efcddcdfed95652277f7
LABEL \
io.hass.name="Deebot N95 Local Control" \
io.hass.description="Local MQTT control for an already-provisioned Deebot N95" \
io.hass.type="addon" \
io.hass.version="${BUILD_VERSION}" \
io.hass.arch="${BUILD_ARCH}" \
org.opencontainers.image.title="deebot-n95-local-control" \
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons" \
org.opencontainers.image.version="${BUILD_VERSION}" \
org.opencontainers.image.revision="${UPSTREAM_COMMIT}" \
com.gronod.upstream.ref="${UPSTREAM_REF}"
CMD [ "/run.sh" ]
+58
View File
@@ -0,0 +1,58 @@
# Deebot N95 Local Control
<img src="logo.png" alt="Deebot N95 Local Control" width="128" height="128">
Redirect an already-provisioned Ecovacs Deebot N95 from its legacy cloud
bootstrap and XMPP endpoint to a local bridge, packaged as a Home Assistant
add-on.
The bridge publishes each robot through MQTT discovery as a native Home
Assistant vacuum. Once the LAN DNS redirect is in place, robot control and
state stay on your local network.
## Features
* Local HTTP bootstrap and XMPP endpoint for compatible Deebot N95 robots
* Automatic Home Assistant MQTT vacuum discovery
* Multiple robots, with separate MQTT clients and topic trees by serial number
* Vacuum state and control, cleaning modes, movement, schedules, consumable
lifespan, and diagnostics
* Automatic Supervisor MQTT broker discovery with per-setting overrides
* Optional MQTT authentication, TLS, and private CA support
* Health endpoint for installation checks
## Requirements
* An already-provisioned Deebot N95 using the `wukong` / class `155` protocol
* Control of the DNS resolver supplied to the robot by DHCP
* A stable IPv4 address for the Home Assistant host
* TCP ports `8005`, `8007`, and `5223` reachable from the robot
* Home Assistant MQTT configured with discovery enabled, and a reachable broker
* The correct IANA timezone for the robot's clock and schedules
> The robot-facing XMPP connection, including SASL PLAIN authentication, is
> plaintext. Run this add-on only on a trusted LAN and restrict access to its
> listener ports. MQTT TLS protects the broker connection, not robot XMPP.
## Installation
1. Add this repository to Home Assistant
(**Settings → Add-ons → Add-on Store → ⋮ → Repositories**):
`https://git.i3omb.com/gronod/ha-gronod-addons`
2. Install **Deebot N95 Local Control**
3. Set **Advertised IP address** to the stable LAN IPv4 address of the Home
Assistant host and select the correct timezone
4. Configure LAN DNS so `lbo.ecouser.net` resolves to that address
5. Start the add-on, verify its health and lookup endpoints, then reboot the
robot so it performs bootstrap again
See [DOCS.md](DOCS.md) for the complete network setup, option reference,
verification procedure, MQTT behavior, security notes, and troubleshooting.
## Upstream and license
The add-on builds
[ha-n95-local-control v0.1.2](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2),
which also contains standalone Docker and Go instructions. The upstream
software is licensed under the
[Apache License 2.0](https://git.i3omb.com/gronod/ha-n95-local-control/src/tag/v0.1.2/LICENCE.md).
+1
View File
@@ -0,0 +1 @@
0.1.2
+11
View File
@@ -0,0 +1,11 @@
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
amd64: ghcr.io/home-assistant/amd64-base:3.22
armhf: ghcr.io/home-assistant/armhf-base:3.22
armv7: ghcr.io/home-assistant/armv7-base:3.22
i386: ghcr.io/home-assistant/i386-base:3.22
args:
GO_VERSION: "1.27.1"
UPSTREAM_REPOSITORY: "https://git.i3omb.com/gronod/ha-n95-local-control.git"
UPSTREAM_REF: "v0.1.2"
UPSTREAM_COMMIT: "7bed99cc4c3222bad648efcddcdfed95652277f7"
+56
View File
@@ -0,0 +1,56 @@
name: "Deebot N95 Local Control"
description: >-
Redirect an already-provisioned Deebot N95 to local MQTT discovery and
control in Home Assistant.
version: "0.1.2"
slug: "n95_mqtt_bridge"
url: "https://git.i3omb.com/gronod/ha-gronod-addons/src/branch/main/n95-mqtt-bridge"
init: false
startup: application
boot: auto
host_network: true
ports:
8007/tcp: 8007
8005/tcp: 8005
5223/tcp: 5223
8080/tcp: 8080
ports_description:
8007/tcp: Robot bootstrap lookup
8005/tcp: Firmware check
5223/tcp: Robot XMPP
8080/tcp: Health
arch:
- aarch64
- amd64
- armhf
- armv7
- i386
services:
- mqtt:want
map:
- ssl
options:
advertise_ip: null
timezone: "Etc/UTC"
log_level: info
schema:
advertise_ip: str
timezone: str
log_level: list(debug|info|warn|error)
bind_address: str?
port_lookup: int?
port_firmware: int?
port_xmpp: int?
health_port: int?
mqtt_host: str?
mqtt_port: int?
mqtt_tls: bool?
mqtt_ca_file: str?
mqtt_username: str?
mqtt_password: password?
mqtt_client_id: str?
mqtt_base: str?
ha_discovery_prefix: str?
controller_jid: str?
raw_commands: bool?
panel_icon: mdi:robot-vacuum
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/with-contenv bashio
set -euo pipefail
export ADVERTISE_IP="$(bashio::config 'advertise_ip')"
export TZ="$(bashio::config 'timezone')"
export LOG_LEVEL="$(bashio::config 'log_level')"
mqtt_source=""
if bashio::services.available "mqtt"; then
export MQTT_HOST="$(bashio::services mqtt 'host')"
export MQTT_PORT="$(bashio::services mqtt 'port')"
export MQTT_USERNAME="$(bashio::services mqtt 'username')"
export MQTT_PASSWORD="$(bashio::services mqtt 'password')"
export MQTT_TLS="$(bashio::services mqtt 'ssl')"
mqtt_source="Supervisor MQTT service"
fi
export_option() {
local option="$1"
local variable="$2"
if bashio::config.exists "${option}"; then
export "${variable}=$(bashio::config "${option}")"
if [[ "${option}" == mqtt_* ]]; then
if [[ -n "${mqtt_source}" ]]; then
mqtt_source="${mqtt_source} with option overrides"
else
mqtt_source="add-on options"
fi
fi
fi
}
export_option bind_address BIND_ADDRESS
export_option port_lookup PORT_LOOKUP
export_option port_firmware PORT_FIRMWARE
export_option port_xmpp PORT_XMPP
export_option health_port HEALTH_PORT
export_option mqtt_host MQTT_HOST
export_option mqtt_port MQTT_PORT
export_option mqtt_tls MQTT_TLS
export_option mqtt_ca_file MQTT_CA_FILE
export_option mqtt_username MQTT_USERNAME
export_option mqtt_password MQTT_PASSWORD
export_option mqtt_client_id MQTT_CLIENT_ID
export_option mqtt_base MQTT_BASE
export_option ha_discovery_prefix HA_DISCOVERY_PREFIX
export_option controller_jid CONTROLLER_JID
export_option raw_commands RAW_COMMANDS
if [[ -z "${MQTT_HOST:-}" ]]; then
bashio::log.fatal "No MQTT broker is available. Configure a Supervisor MQTT service or set mqtt_host."
exit 1
fi
bashio::log.info "Starting Deebot N95 Local Control"
bashio::log.info "Advertising ${ADVERTISE_IP}; MQTT settings from ${mqtt_source}"
if [[ "${RAW_COMMANDS:-false}" == "true" ]]; then
bashio::log.warning "Raw MQTT commands are enabled; use them only for protocol testing."
fi
exec /n95bridge
+58
View File
@@ -0,0 +1,58 @@
configuration:
advertise_ip:
name: Advertised IP address
description: Stable Home Assistant host IPv4 address returned to the robot. Configure lbo.ecouser.net to resolve to this address.
timezone:
name: Time zone
description: IANA time zone used for the local time and UTC offset sent to the robot, for example Europe/London.
log_level:
name: Log level
description: Bridge log verbosity.
bind_address:
name: Bind address
description: Advanced. Address for all listeners. Keep 0.0.0.0 unless you have a specific host-network requirement.
port_lookup:
name: Lookup port
description: Advanced. Robot bootstrap HTTP port; defaults to 8007.
port_firmware:
name: Firmware port
description: Advanced. Robot firmware-check HTTP port; defaults to 8005.
port_xmpp:
name: XMPP port
description: Advanced. Plaintext robot XMPP port; defaults to 5223.
health_port:
name: Health port
description: Advanced. HTTP health endpoint port; defaults to 8080.
mqtt_host:
name: MQTT host
description: Broker host override without a scheme or port. The Supervisor MQTT service is used when available.
mqtt_port:
name: MQTT port
description: Broker port override. Defaults to 1883, or 8883 when TLS is enabled.
mqtt_tls:
name: MQTT TLS
description: Override whether the broker connection uses TLS with hostname verification.
mqtt_ca_file:
name: MQTT CA file
description: Optional PEM CA bundle inside the add-on, normally a path under /ssl.
mqtt_username:
name: MQTT username
description: Broker username override.
mqtt_password:
name: MQTT password
description: Broker password override. A nonempty password requires a username.
mqtt_client_id:
name: MQTT client ID prefix
description: Advanced. Prefix used for each robot's broker client ID.
mqtt_base:
name: MQTT base topic
description: Advanced. Single topic level before each robot serial; defaults to ecovacs.
ha_discovery_prefix:
name: Discovery prefix
description: Advanced. Home Assistant MQTT discovery prefix; defaults to homeassistant.
controller_jid:
name: Controller JID
description: Advanced. Virtual XMPP sender address presented to the robot.
raw_commands:
name: Raw commands
description: Advanced and risky. Allow protocol-level raw ctl commands over MQTT.