Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ returns **`Ret=607`**. Use `client.system_info(name)` (cmd 1020) instead.
the universal "blank the channel" lever, BUT on **digital-channel** HVRs `Simplify.Encode`/`Encode`
are degenerate (`{"0":null,"1":null,"Enable":true}`) — encode lives on the remote IPCs. There the
Privacy switch is HA-side (`coordinator.private_channels` → camera entity hidden + `ClosedRecord`).
- **Camera streams**: two `XMEyeCamera` entities per channel — main (`stream=0`, unique_id
`…_ch{n}_camera`) and sub (`stream=1`, `…_ch{n}_camera_sub`, disabled by default,
translation key `camera_sub`). The sub entity probes only sub-stream URLs; the main entity probes
all and may fall back to an H.264 sub when the main is H.265. `coordinator.h265_channels` and
`stream_info` are keyed by `(channel, stream)`.

**Response codes** (`Ret`): `100`/`515` = OK (`RET_OK`); `607` = wrong command channel for that
`Name` (e.g. 1042 asked for runtime info); `101`/`106`/`203` = auth failure (`RET_AUTH_FAIL`).
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The **XMEye** brand is Xiongmai's end-user mobile app and cloud platform. If you

| Platform | What you get |
|---|---|
| **Camera** | Live RTSP stream per channel, on-demand JPEG snapshot, PTZ control (pan/tilt/zoom) |
| **Camera** | Live RTSP stream per channel (main stream, plus an optional sub-stream entity), on-demand JPEG snapshot, PTZ control (pan/tilt/zoom) |
| **Binary Sensor** | Push alarm events: motion, video loss, video blind/tamper, alarm input, I/O alarm, cross-line, intrusion |
| **Switch** | Enable / disable motion detection and recording per channel |
| **Sensor** | HDD total/used space, HDD status, firmware version |
Expand Down Expand Up @@ -81,6 +81,23 @@ This integration works with any device running the **Xiongmai Sofia firmware** t

---

## Main stream and sub stream

Each channel gets two camera entities, the same layout Reolink, UniFi Protect and ONVIF use:

| Entity | Stream | Default |
|---|---|---|
| `camera.<device>_<channel>` | Main stream (`stream=0.sdp`, full resolution) | Enabled |
| `camera.<device>_<channel>_substream` (name follows the HA language) | Sub stream (`stream=1.sdp`, low resolution) | **Disabled** |

The sub-stream entity starts faster and uses far less bandwidth — handy for dashboards with
many cameras or for remote access. Enable it under *Settings → Devices & services → XMEye →
Entities*, then point the card at it. Snapshots come from the same HTTP endpoint for both.

The main entity keeps its existing fallback: if the main stream is H.265 and the sub stream
is H.264, it plays the sub stream to avoid a transcode. Diagnostics show which stream each
entity actually picked (`stream_idx`).

## H.265 handling

Each channel's RTSP stream is probed at startup and its codec read from the SDP. H.264
Expand Down
99 changes: 77 additions & 22 deletions custom_components/xmeye/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ def _refresh_h265_issues(hass: HomeAssistant, coordinator: XMEyeCoordinator) ->
entry = coordinator.entry
states = coordinator.h265_channels
buckets = {
REPAIR_H265_TRANSCODING: sorted(ch + 1 for ch, ok in states.items() if ok),
REPAIR_H265_NO_GO2RTC: sorted(ch + 1 for ch, ok in states.items() if not ok),
REPAIR_H265_TRANSCODING: sorted({ch + 1 for (ch, _), ok in states.items() if ok}),
REPAIR_H265_NO_GO2RTC: sorted({ch + 1 for (ch, _), ok in states.items() if not ok}),
}
for translation_key, channels in buckets.items():
issue_id = f"{translation_key}_{entry.entry_id}"
Expand Down Expand Up @@ -184,6 +184,9 @@ def _is_valid_frame(data: bytes) -> bool:
return size[0] >= _MIN_SNAPSHOT_WIDTH and size[1] >= _MIN_SNAPSHOT_HEIGHT

_RTSP_PORT = 554
# RTSP stream indexes as the firmware numbers them (`stream=N.sdp`).
_STREAM_MAIN = 0
_STREAM_SUB = 1
_RTSP_PROBE_TIMEOUT = 4.0


Expand Down Expand Up @@ -309,12 +312,21 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: XMEyeCoordinator = entry.runtime_data

def _cameras(channel: int) -> list[XMEyeCamera]:
# Main stream (enabled) + sub stream (disabled by default), the same
# one-entity-per-stream layout Reolink, UniFi Protect and ONVIF use.
return [
XMEyeCamera(coordinator, channel, _STREAM_MAIN),
XMEyeCamera(coordinator, channel, _STREAM_SUB),
]

async_add_entities([
XMEyeCamera(coordinator, ch) for ch in sorted(coordinator.connected_channels)
cam for ch in sorted(coordinator.connected_channels) for cam in _cameras(ch)
])

def _on_new_channel(channel: int) -> None:
async_add_entities([XMEyeCamera(coordinator, channel)])
async_add_entities(_cameras(channel))

entry.async_on_unload(
async_dispatcher_connect(
Expand Down Expand Up @@ -345,24 +357,39 @@ class XMEyeCamera(XMEyeEntity, Camera):
_attr_translation_key = "camera"
_attr_supported_features = CameraEntityFeature.STREAM

def __init__(self, coordinator: XMEyeCoordinator, channel: int) -> None:
def __init__(
self,
coordinator: XMEyeCoordinator,
channel: int,
stream: int = _STREAM_MAIN,
) -> None:
super().__init__(coordinator)
self._channel = channel
# Which entity this is: the main-stream camera, or the dedicated
# sub-stream one (lower resolution, faster to start).
self._stream = stream
self._is_sub = stream == _STREAM_SUB

entry = coordinator.entry
self._host: str = entry.data[CONF_HOST]
self._username: str = entry.data[CONF_USERNAME]
self._password: str = entry.data[CONF_PASSWORD]

self._attr_unique_id = f"{entry.entry_id}_ch{channel}_camera"
self._attr_unique_id = f"{entry.entry_id}_ch{channel}_camera" + (
"_sub" if self._is_sub else ""
)
if self._is_sub:
self._attr_translation_key = "camera_sub"
self._attr_entity_registry_enabled_default = False

# Cached after first successful probe; None means "not yet discovered".
self._snapshot_path: str | None = None
self._stream_url: str | None = None
# Codec of the chosen RTSP stream ("H264", "H265", "unknown").
self._codec: str = CODEC_UNKNOWN
# 0 = main, 1 = sub. Tracked for diagnostics only.
self._stream_idx: int = 0
# 0 = main, 1 = sub — the stream actually picked by the probe. The
# main entity may still land on 1 (H.265 main + H.264 sub).
self._stream_idx: int = stream
# If we successfully registered a transcoded go2rtc stream, the
# registered stream name; None otherwise. Used to clean up on
# entity removal and to switch stream_source() to the go2rtc URL.
Expand All @@ -376,12 +403,34 @@ def __init__(self, coordinator: XMEyeCoordinator, channel: int) -> None:
self._stream_lock = asyncio.Lock()

@property
def name(self) -> str:
def _channel_title(self) -> str:
titles = self._coordinator.channel_titles
if titles and self._channel < len(titles) and titles[self._channel]:
return titles[self._channel]
return f"CH{self._channel + 1}"

@property
def name(self) -> str:
# A plain property on purpose: HA caches Entity.name, and the channel
# title only arrives once the coordinator has logged in.
title = self._channel_title
if not self._is_sub:
return title
template = None
if self.platform_data and self._name_translation_key:
template = self.platform_data.platform_translations.get(
self._name_translation_key
)
try:
return (template or "{title} Substream").format(title=title)
except (KeyError, IndexError, ValueError):
return f"{title} Substream"

@property
def _stream_key(self) -> tuple[int, int]:
"""Key for this entity in the coordinator's per-stream dicts."""
return (self._channel, self._stream)

@property
def available(self) -> bool:
# Privacy mode hides the camera entirely (no stream, no snapshot).
Expand Down Expand Up @@ -420,9 +469,9 @@ async def _drop_go2rtc_stream(self) -> None:
except Exception: # noqa: BLE001
_LOGGER.debug("go2rtc unregister failed for %s", identifier)
self._go2rtc_stream_name = None
# Drop this channel from the device-wide H.265 advisory. Sibling
# channels keep theirs.
if self._coordinator.h265_channels.pop(self._channel, None) is not None:
# Drop this entity from the device-wide H.265 advisory. Sibling
# channels (and the other stream of this channel) keep theirs.
if self._coordinator.h265_channels.pop(self._stream_key, None) is not None:
_refresh_h265_issues(self.hass, self._coordinator)

@callback
Expand Down Expand Up @@ -533,8 +582,8 @@ async def _ensure_go2rtc_stream(self, url: str) -> bool:
self._go2rtc_stream_name = identifier
# Keep the device-wide advisory in step: go2rtc coming or going flips
# which of the two repair issues applies.
if self._coordinator.h265_channels.get(self._channel) != ok:
self._coordinator.h265_channels[self._channel] = ok
if self._coordinator.h265_channels.get(self._stream_key) != ok:
self._coordinator.h265_channels[self._stream_key] = ok
_refresh_h265_issues(self.hass, self._coordinator)
return ok

Expand Down Expand Up @@ -593,15 +642,18 @@ def _stream_url_candidates(self) -> list[tuple[str, int]]:
For each URL shape we try the main stream (0, high-res) AND the
sub stream (1, low-res). Preference is applied later in
`_find_stream_url` based on parsed SDP codec: H.264 wins over
H.265, and the sub stream is preferred for transcoding cost when
only H.265 is available.
H.265, and the earlier candidate wins a tie.

The sub-stream entity only gets the sub-stream candidates; the main
entity keeps the full list so it can still fall back to an H.264
sub stream when the main one is H.265.
"""
h = sofia_hash(self._password)
ch = self._channel
host = self._host
user = self._username
pwd = self._password
return [
candidates = [
# XMEye / Sofia standard format — main then sub
(f"rtsp://{host}:{_RTSP_PORT}/user={user}&password={h}"
f"&channel={ch + 1}&stream=0.sdp", 0),
Expand All @@ -615,6 +667,9 @@ def _stream_url_candidates(self) -> list[tuple[str, int]]:
(f"rtsp://{host}:{_RTSP_PORT}/h264/ch{ch + 1}/main/av_stream", 0),
(f"rtsp://{host}:{_RTSP_PORT}/h264/ch{ch + 1}/sub/av_stream", 1),
]
if self._is_sub:
return [c for c in candidates if c[1] == _STREAM_SUB]
return candidates

async def _find_stream_url(self) -> None:
"""Probe every candidate and cache the best by codec.
Expand Down Expand Up @@ -649,7 +704,7 @@ async def _find_stream_url(self) -> None:

if best is not None:
self._stream_url, self._codec, self._stream_idx = best
self._coordinator.stream_info[self._channel] = {
self._coordinator.stream_info[self._stream_key] = {
"codec": self._codec,
"stream_idx": self._stream_idx,
"url": re.sub(r"password=[^&]*", "password=***", self._stream_url),
Expand All @@ -661,8 +716,8 @@ async def _find_stream_url(self) -> None:
)
return
_LOGGER.debug(
"No working RTSP stream for ch%d on %s",
self._channel + 1, self._host,
"No working RTSP %s stream for ch%d on %s",
"sub" if self._is_sub else "main", self._channel + 1, self._host,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -699,12 +754,12 @@ async def async_camera_image(
return None

def _default_stream_url(self) -> str:
"""Standard XMEye main-stream URL, used until the probe finishes."""
"""Standard XMEye URL for this entity's stream, used until the probe finishes."""
h = sofia_hash(self._password)
return (
f"rtsp://{self._host}:{_RTSP_PORT}"
f"/user={self._username}&password={h}"
f"&channel={self._channel + 1}&stream=0.sdp"
f"&channel={self._channel + 1}&stream={self._stream}.sdp"
)

async def stream_source(self) -> str | None:
Expand Down
13 changes: 7 additions & 6 deletions custom_components/xmeye/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,13 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# H.265-only channels play in browsers without HEVC. The endpoint is
# resolved lazily on first use.
self.go2rtc_client: Go2RTCClient | None = Go2RTCClient(hass)
# Channels detected as H.265 → True when go2rtc is transcoding them,
# False when it is not reachable. Drives the repair issues and
# diagnostics.
self.h265_channels: dict[int, bool] = {}
# Per-channel stream probe results, for diagnostics only.
self.stream_info: dict[int, dict[str, Any]] = {}
# Camera streams detected as H.265, keyed by (channel, stream) with
# stream 0 = main entity, 1 = sub-stream entity → True when go2rtc is
# transcoding them, False when it is not reachable. Drives the repair
# issues and diagnostics.
self.h265_channels: dict[tuple[int, int], bool] = {}
# Per-entity stream probe results, same keys, for diagnostics only.
self.stream_info: dict[tuple[int, int], dict[str, Any]] = {}

# ------------------------------------------------------------------
# Lifecycle
Expand Down
7 changes: 5 additions & 2 deletions custom_components/xmeye/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ async def async_get_config_entry_diagnostics(
f"ch{ch}_{et}": v for (ch, et), v in coordinator.states.items()
},
"streams": {
f"ch{ch + 1}": {**info, "go2rtc_transcoding": coordinator.h265_channels.get(ch)}
for ch, info in sorted(coordinator.stream_info.items())
f"ch{ch + 1}{'_sub' if stream else ''}": {
**info,
"go2rtc_transcoding": coordinator.h265_channels.get((ch, stream)),
}
for (ch, stream), info in sorted(coordinator.stream_info.items())
},
}
3 changes: 2 additions & 1 deletion custom_components/xmeye/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
"intrusion": { "name": "CH{channel} Intrusion" }
},
"camera": {
"camera": { "name": "Camera" }
"camera": { "name": "Camera" },
"camera_sub": { "name": "{title} Substream" }
},
"switch": {
"motion_detection": { "name": "CH{channel} Motion Detection" },
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Kamera"
},
"camera_sub": {
"name": "{title} Substream"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Camera"
},
"camera_sub": {
"name": "{title} Substream"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Cámara"
},
"camera_sub": {
"name": "{title} Flujo secundario"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Caméra"
},
"camera_sub": {
"name": "{title} Flux secondaire"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Telecamera"
},
"camera_sub": {
"name": "{title} Flusso secondario"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "カメラ"
},
"camera_sub": {
"name": "{title} サブストリーム"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Camera"
},
"camera_sub": {
"name": "{title} Substream"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Kamera"
},
"camera_sub": {
"name": "{title} Strumień pomocniczy"
}
},
"sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/xmeye/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"camera": {
"camera": {
"name": "Câmera"
},
"camera_sub": {
"name": "{title} Stream secundário"
}
},
"sensor": {
Expand Down
Loading
Loading