Compare commits
6
Commits
c4095d0be7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3cac18d6 | ||
|
|
7bcf5322e1 | ||
|
|
2c4911e3c8 | ||
|
|
e12292f316 | ||
|
|
97c0e4dc04 | ||
|
|
1a2a97babc |
@@ -8,6 +8,7 @@ Home Assistant custom add-on repository maintained by Gordon Bolton.
|
|||||||
|
|
||||||
| Add-on | Description | Documentation |
|
| 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) |
|
| **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) |
|
| **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) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.15
|
||||||
|
|
||||||
- Restore `premiere_date` on item-shaped results (search, episode/season
|
- Restore `premiere_date` on item-shaped results (search, episode/season
|
||||||
|
|||||||
+33
-3
@@ -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_listen_addr` | `0.0.0.0:8085` | Bind address |
|
||||||
| `mcp_session_timeout` | `30m` | MCP session lifetime |
|
| `mcp_session_timeout` | `30m` | MCP session lifetime |
|
||||||
| `log_level` | `INFO` | `DEBUG`, `INFO`, or `WARN` |
|
| `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.rest` | _(optional)_ | Shown under optional options. At DEBUG, log REST bodies |
|
||||||
| `debug.mcp` | _(optional)_ | Shown under optional options. At DEBUG, log `/mcp` 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 |
|
| `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:
|
Add this line to the conversation agent's instructions:
|
||||||
|
|
||||||
```text
|
```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
|
### Functions
|
||||||
@@ -146,19 +148,47 @@ Paste the following into the conversation agent's **Functions** list
|
|||||||
|
|
||||||
- spec:
|
- spec:
|
||||||
name: emby_list_players
|
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:
|
parameters:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
media_type:
|
media_type:
|
||||||
type: string
|
type: string
|
||||||
description: Filter by player type ('Video', 'Audio', 'Photo'), or leave empty for all.
|
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:
|
function:
|
||||||
type: rest
|
type: rest
|
||||||
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
|
resource_template: "http://8e663231-emby-mcp:8085/call/retrieve_player_list"
|
||||||
method: POST
|
method: POST
|
||||||
payload_template: >-
|
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 }}"
|
value_template: "{{ value_json.result }}"
|
||||||
|
|
||||||
- spec:
|
- spec:
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ LABEL \
|
|||||||
io.hass.name="Emby MCP" \
|
io.hass.name="Emby MCP" \
|
||||||
io.hass.description="Emby Model Context Protocol server with REST bridge" \
|
io.hass.description="Emby Model Context Protocol server with REST bridge" \
|
||||||
io.hass.type="addon" \
|
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.title="emby-mcp" \
|
||||||
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
|
org.opencontainers.image.source="https://git.i3omb.com/gronod/ha-gronod-addons"
|
||||||
CMD [ "/run.sh" ]
|
CMD [ "/run.sh" ]
|
||||||
|
|||||||
+1
-1
@@ -45,5 +45,5 @@ setup, troubleshooting, and standalone (non-add-on) usage.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2026 Gordon Bolton. Based on [Emby.MCP](https://github.com/angelltek/Emby.MCP);
|
© 2026 Gordon Bolton. Based on [Emby.MCP](https://github.com/angeltek/Emby.MCP);
|
||||||
this is a complete rewrite. GPL v3 — see `LICENCE.md`.
|
this is a complete rewrite. GPL v3 — see `LICENCE.md`.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.15
|
1.0.16
|
||||||
|
|||||||
@@ -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.")
|
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 := state.New(client, userID, cfg.MaxChunkSize)
|
||||||
|
st.PlayerUsers = cfg.PlayerUsers
|
||||||
|
st.PlayerLinks = cfg.PlayerLinks
|
||||||
srv := server.New(st)
|
srv := server.New(st)
|
||||||
|
|
||||||
runErr := srv.Run(ctx, &mcp.StdioTransport{})
|
runErr := srv.Run(ctx, &mcp.StdioTransport{})
|
||||||
|
|||||||
+13
-1
@@ -2,7 +2,7 @@ name: "Emby MCP"
|
|||||||
description: >-
|
description: >-
|
||||||
Emby Model Context Protocol server with a Home Assistant REST bridge.
|
Emby Model Context Protocol server with a Home Assistant REST bridge.
|
||||||
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
|
Serves streamable MCP at /mcp and REST tool calls at /call/{tool}.
|
||||||
version: "1.0.15"
|
version: "1.0.16"
|
||||||
slug: "emby_mcp"
|
slug: "emby_mcp"
|
||||||
init: false
|
init: false
|
||||||
startup: application
|
startup: application
|
||||||
@@ -31,6 +31,8 @@ options:
|
|||||||
mcp_listen_addr: "0.0.0.0:8085"
|
mcp_listen_addr: "0.0.0.0:8085"
|
||||||
mcp_session_timeout: "30m"
|
mcp_session_timeout: "30m"
|
||||||
log_level: INFO
|
log_level: INFO
|
||||||
|
player_users: []
|
||||||
|
player_links: []
|
||||||
schema:
|
schema:
|
||||||
emby_server_url: str
|
emby_server_url: str
|
||||||
emby_username: str
|
emby_username: str
|
||||||
@@ -44,6 +46,16 @@ schema:
|
|||||||
mcp_listen_addr: str
|
mcp_listen_addr: str
|
||||||
mcp_session_timeout: str
|
mcp_session_timeout: str
|
||||||
log_level: list(DEBUG|INFO|WARN)
|
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:
|
debug:
|
||||||
rest: bool?
|
rest: bool?
|
||||||
mcp: bool?
|
mcp: bool?
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ type Config struct {
|
|||||||
DebugREST bool // DEBUG_REST
|
DebugREST bool // DEBUG_REST
|
||||||
DebugMCP bool // DEBUG_MCP
|
DebugMCP bool // DEBUG_MCP
|
||||||
DebugEmby bool // DEBUG_EMBY
|
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
|
// 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_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
|
||||||
"MCP_RESTRICT_LOCALHOST",
|
"MCP_RESTRICT_LOCALHOST",
|
||||||
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
|
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
|
||||||
|
"PLAYER_USERS", "PLAYER_LINKS",
|
||||||
} {
|
} {
|
||||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||||
vals[k] = v
|
vals[k] = v
|
||||||
@@ -102,6 +116,15 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.SessionTimeout = d
|
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 == "" {
|
if cfg.ServerURL == "" {
|
||||||
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
|
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func clearEnv(t *testing.T) {
|
|||||||
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
|
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
|
||||||
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
|
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
|
||||||
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
|
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
|
||||||
|
"PLAYER_USERS", "PLAYER_LINKS",
|
||||||
} {
|
} {
|
||||||
t.Setenv(k, "")
|
t.Setenv(k, "")
|
||||||
}
|
}
|
||||||
@@ -130,3 +131,25 @@ MCP_TRANSPORT=grpc`)
|
|||||||
t.Fatal("expected error for invalid transport")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@ import (
|
|||||||
// now_playing_* fields are omitted when the player is idle.
|
// now_playing_* fields are omitted when the player is idle.
|
||||||
type PlayerSession struct {
|
type PlayerSession struct {
|
||||||
ClientName string `json:"client_name"`
|
ClientName string `json:"client_name"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
DeviceName string `json:"device_name"`
|
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"`
|
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"`
|
NowPlayingTitle string `json:"now_playing_title,omitempty"`
|
||||||
NowPlayingArtists []string `json:"now_playing_artists,omitempty"`
|
NowPlayingArtists []string `json:"now_playing_artists,omitempty"`
|
||||||
NowPlayingAlbum string `json:"now_playing_album,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,
|
SessionID: s.ID,
|
||||||
DeviceID: s.DeviceID,
|
DeviceID: s.DeviceID,
|
||||||
DeviceName: s.DeviceName,
|
DeviceName: s.DeviceName,
|
||||||
DeviceIPAddress: s.RemoteEndPoint,
|
DeviceIPAddress: HostOf(s.RemoteEndPoint),
|
||||||
|
UserID: s.UserID,
|
||||||
|
UserName: s.UserName,
|
||||||
|
Online: true,
|
||||||
MediaTypes: s.PlayableMediaTypes,
|
MediaTypes: s.PlayableMediaTypes,
|
||||||
LocalToMediaServer: s.RemoteEndPoint == "::1" || s.RemoteEndPoint == "127.0.0.1",
|
LocalToMediaServer: isLoopback(s.RemoteEndPoint),
|
||||||
}
|
}
|
||||||
if np := s.NowPlayingItem; np != nil {
|
if np := s.NowPlayingItem; np != nil {
|
||||||
ps.NowPlayingTitle = np.Name
|
ps.NowPlayingTitle = np.Name
|
||||||
@@ -85,6 +91,51 @@ func (c *Client) GetPlayerSessions(ctx context.Context, userID, mediaType string
|
|||||||
return out, nil
|
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.
|
// PlayQueueItem is the output shape for play queue entries.
|
||||||
type PlayQueueItem struct {
|
type PlayQueueItem struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func TestGetPlayerSessions(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"Client": "Emby Web", "Id": "s1", "DeviceId": "d1",
|
"Client": "Emby Web", "Id": "s1", "DeviceId": "d1",
|
||||||
"DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1",
|
"DeviceName": "Chrome", "RemoteEndPoint": "127.0.0.1",
|
||||||
|
"UserId": "u1", "UserName": "Gordon",
|
||||||
"PlayableMediaTypes": []string{"Audio", "Video"},
|
"PlayableMediaTypes": []string{"Audio", "Video"},
|
||||||
"NowPlayingItem": map[string]any{
|
"NowPlayingItem": map[string]any{
|
||||||
"Name": "Track", "Id": "i1", "Artists": []string{"A"},
|
"Name": "Track", "Id": "i1", "Artists": []string{"A"},
|
||||||
@@ -48,6 +49,23 @@ func TestGetPlayerSessions(t *testing.T) {
|
|||||||
if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused {
|
if s.NowPlayingIsPaused == nil || !*s.NowPlayingIsPaused {
|
||||||
t.Error("expected is_paused")
|
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) {
|
func TestGetPlayerSessionsMediaTypeFilter(t *testing.T) {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ func NewHandler(cfg *config.Config, hostname string) http.Handler {
|
|||||||
}
|
}
|
||||||
// Per-session state: library selection and search chunking are
|
// Per-session state: library selection and search chunking are
|
||||||
// isolated between concurrent HTTP clients.
|
// 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{
|
mcpHandler := mcp.NewStreamableHTTPHandler(getServer, &mcp.StreamableHTTPOptions{
|
||||||
SessionTimeout: cfg.SessionTimeout,
|
SessionTimeout: cfg.SessionTimeout,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "HA Emby MCP"
|
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
|
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.
|
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,
|
You can browse series, seasons and episodes, find the next episode to watch,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
||||||
"git.i3omb.com/gronod/emby-mcp/internal/state"
|
"git.i3omb.com/gronod/emby-mcp/internal/state"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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.
|
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'
|
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.
|
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:
|
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
|
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:
|
Returns:
|
||||||
List of dicts as JSON with keys including client_name, session_id, device_id, device_name,
|
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 {
|
}, 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) {
|
}) (*mcp.CallToolResult, any, error) {
|
||||||
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType)
|
sessions, err := st.Client.GetPlayerSessions(ctx, st.UserID, in.MediaType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(errf("ERROR: failed to retrieve player list because: %v", err)), nil, 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
|
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{
|
mcp.AddTool(s, &mcp.Tool{
|
||||||
Name: "retrieve_player_queue",
|
Name: "retrieve_player_queue",
|
||||||
Description: `Retrieve a list of items in the play queue of a media player in JSON format.
|
Description: `Retrieve a list of items in the play queue of a media player in JSON format.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package state
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"git.i3omb.com/gronod/emby-mcp/internal/config"
|
||||||
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
"git.i3omb.com/gronod/emby-mcp/internal/emby"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ type State struct {
|
|||||||
Client *emby.Client
|
Client *emby.Client
|
||||||
UserID string
|
UserID string
|
||||||
MaxChunkSize int
|
MaxChunkSize int
|
||||||
|
PlayerUsers []string
|
||||||
|
PlayerLinks []config.PlayerLink
|
||||||
|
|
||||||
libraries []emby.Library
|
libraries []emby.Library
|
||||||
current *emby.Library
|
current *emby.Library
|
||||||
|
|||||||
@@ -29,4 +29,9 @@ else
|
|||||||
export MCP_RESTRICT_LOCALHOST="false"
|
export MCP_RESTRICT_LOCALHOST="false"
|
||||||
fi
|
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
|
exec /emby-mcp
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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" ]
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.1.2
|
||||||
@@ -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"
|
||||||
@@ -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 |
@@ -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
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user