mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
Merge pull request #2147 from skrashevich/beta
dev branch: merge from skrashevich/go2rtc:beta
This commit is contained in:
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'beta'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: '1.25' }
|
||||
with: { go-version: '1.26' }
|
||||
|
||||
- name: Build go2rtc_win64
|
||||
env: { GOOS: windows, GOARCH: amd64 }
|
||||
@@ -123,9 +123,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}},enable=false
|
||||
@@ -137,15 +135,8 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -180,9 +171,7 @@ jobs:
|
||||
id: meta-hw
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-hardware,onlatest=true
|
||||
latest=auto
|
||||
@@ -197,15 +186,8 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -235,9 +217,7 @@ jobs:
|
||||
id: meta-rk
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
name=${{ github.repository }},enable=${{ github.event.repository.fork == false }}
|
||||
ghcr.io/${{ github.repository }}
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
flavor: |
|
||||
suffix=-rockchip,onlatest=true
|
||||
latest=auto
|
||||
@@ -252,15 +232,8 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' && github.event.repository.fork == false
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: '1.26'
|
||||
|
||||
- name: Build Go binary
|
||||
run: go build -ldflags "-s -w" -trimpath -o ./go2rtc
|
||||
|
||||
+6
-1
@@ -21,4 +21,9 @@ website/.vitepress/dist
|
||||
|
||||
node_modules
|
||||
package-lock.json
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
*/**/CLAUDE.md
|
||||
.claude*
|
||||
.ruff*
|
||||
|
||||
.omc
|
||||
|
||||
@@ -22,6 +22,86 @@
|
||||
|
||||
Ultimate camera streaming application with support for dozens formats and protocols.
|
||||
|
||||
---
|
||||
|
||||
> ### 🔀 Fork: [skrashevich/go2rtc](https://github.com/skrashevich/go2rtc)
|
||||
>
|
||||
> This is a fork of [AlexxIT/go2rtc](https://github.com/AlexxIT/go2rtc) with the following additions:
|
||||
>
|
||||
> **Features**
|
||||
> - **HomeKit Secure Video (HKSV)** — full recording support with motion detection (P-frame analysis, ONVIF events, API)
|
||||
> - **ONVIF motion detection** — automatic motion events from ONVIF cameras for HomeKit
|
||||
> - **WebP streaming** — native WebP encoding (snapshot & multipart stream) without FFmpeg
|
||||
> - **System resource monitoring** — CPU/memory usage in API (`/api/system`) and WebUI ASCII graphs
|
||||
> - **Read-only mode** — disable all write operations in API/WebUI for production security
|
||||
> - **Offline WebUI in Docker** — CDN JS dependencies bundled into Docker images
|
||||
>
|
||||
> **WebUI improvements**
|
||||
> - **Redesigned interface** — dark/light theme toggle, unified color scheme, improved layout
|
||||
> - **Stream Info & Probe pages** — detailed stream analysis with producers/consumers data
|
||||
> - **Stream Links page** — direct URLs for all supported formats (RTSP, WebRTC, MSE, HLS, etc.)
|
||||
> - **Mobile-responsive** — improved header, tables, and word wrapping on mobile devices
|
||||
>
|
||||
> **Bug fixes & maintenance**
|
||||
> - **YAML config merge fix** — corrected recursive merge behavior preserving comments
|
||||
> - **Streams race condition fix** — fixed race condition in stream schema handling
|
||||
> - **Go 1.26** — updated to the latest Go runtime
|
||||
>
|
||||
> #### Download
|
||||
>
|
||||
> **Docker images** (GHCR):
|
||||
> ```bash
|
||||
> # Standard
|
||||
> docker pull ghcr.io/skrashevich/go2rtc:beta
|
||||
>
|
||||
> # With hardware acceleration (Intel/AMD)
|
||||
> docker pull ghcr.io/skrashevich/go2rtc:beta-hardware
|
||||
>
|
||||
> # Rockchip
|
||||
> docker pull ghcr.io/skrashevich/go2rtc:beta-rockchip
|
||||
> ```
|
||||
>
|
||||
> **Binaries**: download from [GitHub Actions](https://github.com/skrashevich/go2rtc/actions/workflows/build.yml) artifacts (select the latest successful run on the `beta` branch).
|
||||
> Available for: Windows (amd64, i386, arm64), Linux (amd64, i386, arm, arm64), macOS (amd64, arm64), FreeBSD (amd64, arm64).
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Streams Dashboard | Add Stream |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
| Stream list with status, actions, and system monitoring | Quick setup for dozens of protocols and integrations |
|
||||
|
||||
| Stream Info | Stream Links |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
| Producers and consumers details | Direct URLs for all supported formats |
|
||||
|
||||
| Config Editor | Logs |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
| YAML configuration with syntax highlighting | Real-time log viewer with auto-update |
|
||||
|
||||
<details>
|
||||
<summary>Light theme</summary>
|
||||
|
||||
| Streams Dashboard | Add Stream |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
|
||||
| Stream Info | Stream Links |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
|
||||
| Config Editor | Logs |
|
||||
|:-:|:-:|
|
||||
|  |  |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
- zero-dependency [small app](#go2rtc-binary) for all OS (Windows, macOS, Linux, FreeBSD)
|
||||
- zero-delay for many [supported protocols](#codecs-madness) (lowest possible streaming latency)
|
||||
- [streaming input](#streaming-input) from dozens formats and protocols
|
||||
@@ -135,7 +215,7 @@ It comes preinstalled with [FFmpeg](internal/ffmpeg/README.md) and [Python](inte
|
||||
Latest, but maybe unstable version:
|
||||
|
||||
- Binary: [latest master build](https://nightly.link/AlexxIT/go2rtc/workflows/build/master)
|
||||
- Docker: `alexxit/go2rtc:master` or `alexxit/go2rtc:master-hardware` versions
|
||||
- Docker: `ghcr.io/skrashevich/go2rtc:beta` or `ghcr.io/skrashevich/go2rtc:beta-hardware` versions
|
||||
- Home Assistant add-on: `go2rtc master` or `go2rtc master hardware` versions
|
||||
|
||||
## Configuration
|
||||
@@ -144,7 +224,7 @@ This is the `go2rtc.yaml` file in [YAML-format](https://en.wikipedia.org/wiki/YA
|
||||
The configuration can be changed in the [WebUI](www/README.md) at `http://localhost:1984`.
|
||||
The editor provides syntax highlighting and checking.
|
||||
|
||||

|
||||

|
||||
|
||||
The simplest config looks like this:
|
||||
|
||||
@@ -311,7 +391,7 @@ You can preload any stream on go2rtc start. This is useful for cameras that take
|
||||
[WebUI](www/README.md) provides detailed information about all active connections, including IP-addresses, formats, protocols, number of packets and bytes transferred.
|
||||
Via the [HTTP API](internal/api/README.md) in [`json`](https://en.wikipedia.org/wiki/JSON) or [`dot`](https://en.wikipedia.org/wiki/DOT_(graph_description_language)) format on an interactive connection map.
|
||||
|
||||

|
||||

|
||||
|
||||
## Codecs
|
||||
|
||||
@@ -462,6 +542,8 @@ api:
|
||||
allow_paths: [/api, /api/streams, /api/webrtc, /api/frame.jpeg]
|
||||
# enable auth for localhost (used together with username and password)
|
||||
local_auth: true
|
||||
# disable write actions in WebUI/API
|
||||
read_only: true
|
||||
|
||||
exec:
|
||||
# use only allowed exec paths
|
||||
|
||||
+13
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.13"
|
||||
ARG GO_VERSION="1.25"
|
||||
ARG GO_VERSION="1.26"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
@@ -26,7 +26,15 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Final image
|
||||
# 2. Download CDN dependencies for offline web UI
|
||||
FROM alpine AS download-cdn
|
||||
RUN apk add --no-cache wget
|
||||
COPY www/ /web/
|
||||
COPY docker/download_cdn.sh /tmp/
|
||||
RUN sh /tmp/download_cdn.sh /web
|
||||
|
||||
|
||||
# 3. Final image
|
||||
FROM python:${PYTHON_VERSION}-alpine AS base
|
||||
|
||||
# Install ffmpeg, tini (for signal handling),
|
||||
@@ -46,10 +54,11 @@ RUN if [ "${TARGETARCH}" = "amd64" ]; then apk add --no-cache libva-intel-driver
|
||||
# RUN libva-vdpau-driver mesa-vdpau-gallium (+150MB total)
|
||||
|
||||
COPY --from=build /build/go2rtc /usr/local/bin/
|
||||
COPY --from=download-cdn /web /var/www/go2rtc
|
||||
COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/
|
||||
|
||||
EXPOSE 1984 8554 8555 8555/udp
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
||||
|
||||
+3
-3
@@ -7,9 +7,9 @@ Images are built automatically via [GitHub actions](https://github.com/AlexxIT/g
|
||||
- `alexxit/go2rtc:latest` - latest release based on `alpine` (`amd64`, `386`, `arm/v6`, `arm/v7`, `arm64`) with support for hardware transcoding for Intel iGPU and Raspberry
|
||||
- `alexxit/go2rtc:latest-hardware` - latest release based on `debian 13` (`amd64`) with support for hardware transcoding for Intel iGPU, AMD GPU and NVidia GPU
|
||||
- `alexxit/go2rtc:latest-rockchip` - latest release based on `debian 12` (`arm64`) with support for hardware transcoding for Rockchip RK35xx
|
||||
- `alexxit/go2rtc:master` - latest unstable version based on `alpine`
|
||||
- `alexxit/go2rtc:master-hardware` - latest unstable version based on `debian 13` (`amd64`)
|
||||
- `alexxit/go2rtc:master-rockchip` - latest unstable version based on `debian 12` (`arm64`)
|
||||
- `ghcr.io/skrashevich/go2rtc:beta` - latest unstable version based on `alpine`
|
||||
- `ghcr.io/skrashevich/go2rtc:beta-hardware` - latest unstable version based on `debian 13` (`amd64`)
|
||||
- `ghcr.io/skrashevich/go2rtc:beta-rockchip` - latest unstable version based on `debian 12` (`arm64`)
|
||||
|
||||
## Docker compose
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
package docker_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// cdnURLPattern is the same regex used by download_cdn.sh to extract CDN URLs.
|
||||
var cdnURLPattern = regexp.MustCompile(`https://cdn\.jsdelivr\.net/npm/[^"' )\x60]*`)
|
||||
|
||||
// HTML fixtures that mirror the real www/*.html files.
|
||||
var htmlFixtures = map[string]string{
|
||||
"hls.html": `<!doctype html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
|
||||
<video id="video"></video>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"config.html": `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||
<script>
|
||||
const monacoRoot = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min';
|
||||
window.MonacoEnvironment = {
|
||||
getWorkerUrl: function () {
|
||||
return ` + "`" + `data:text/javascript;charset=utf-8,${encodeURIComponent(` + "`" + `
|
||||
self.MonacoEnvironment = { baseUrl: '${monacoRoot}/' };
|
||||
importScripts('${monacoRoot}/vs/base/worker/workerMain.js');
|
||||
` + "`" + `)}` + "`" + `;
|
||||
}
|
||||
};
|
||||
require.config({paths: {vs: ` + "`" + `${monacoRoot}/vs` + "`" + `}});
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
|
||||
"net.html": `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js"></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
|
||||
"links.html": `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<script>
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js';
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
}
|
||||
|
||||
func parseCDNURL(rawURL string) (pkgName, filePath, localURL string) {
|
||||
npmPath := strings.TrimPrefix(rawURL, "https://cdn.jsdelivr.net/npm/")
|
||||
|
||||
parts := strings.SplitN(npmPath, "/", 2)
|
||||
pkgVer := parts[0]
|
||||
|
||||
if len(parts) > 1 {
|
||||
filePath = parts[1]
|
||||
}
|
||||
|
||||
// Remove @version suffix to get package name
|
||||
if idx := strings.LastIndex(pkgVer, "@"); idx > 0 {
|
||||
pkgName = pkgVer[:idx]
|
||||
} else {
|
||||
pkgName = pkgVer
|
||||
}
|
||||
|
||||
if filePath != "" {
|
||||
localURL = "cdn/" + pkgName + "/" + filePath
|
||||
} else {
|
||||
localURL = "cdn/" + pkgName + "/index.js"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractURLs finds all CDN URLs in the given HTML content.
|
||||
func extractURLs(htmlFiles map[string]string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, content := range htmlFiles {
|
||||
for _, match := range cdnURLPattern.FindAllString(content, -1) {
|
||||
seen[match] = true
|
||||
}
|
||||
}
|
||||
urls := make([]string, 0, len(seen))
|
||||
for u := range seen {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
sort.Strings(urls)
|
||||
return urls
|
||||
}
|
||||
|
||||
// patchHTML replaces all CDN URLs in content with local paths.
|
||||
func patchHTML(content string, urls []string) string {
|
||||
for _, u := range urls {
|
||||
_, _, localURL := parseCDNURL(u)
|
||||
content = strings.ReplaceAll(content, u, localURL)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func TestExtractURLs(t *testing.T) {
|
||||
urls := extractURLs(htmlFixtures)
|
||||
|
||||
expected := []string{
|
||||
"https://cdn.jsdelivr.net/npm/hls.js@1",
|
||||
"https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
|
||||
"https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
|
||||
"https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
|
||||
}
|
||||
|
||||
if len(urls) != len(expected) {
|
||||
t.Fatalf("expected %d URLs, got %d: %v", len(expected), len(urls), urls)
|
||||
}
|
||||
for i, u := range urls {
|
||||
if u != expected[i] {
|
||||
t.Errorf("URL[%d]: expected %q, got %q", i, expected[i], u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCDNURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
pkgName string
|
||||
filePath string
|
||||
localURL string
|
||||
}{
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/hls.js@1",
|
||||
pkgName: "hls.js",
|
||||
filePath: "",
|
||||
localURL: "cdn/hls.js/index.js",
|
||||
},
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js",
|
||||
pkgName: "js-yaml",
|
||||
filePath: "dist/js-yaml.min.js",
|
||||
localURL: "cdn/js-yaml/dist/js-yaml.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js",
|
||||
pkgName: "monaco-editor",
|
||||
filePath: "min/vs/loader.js",
|
||||
localURL: "cdn/monaco-editor/min/vs/loader.js",
|
||||
},
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min",
|
||||
pkgName: "monaco-editor",
|
||||
filePath: "min",
|
||||
localURL: "cdn/monaco-editor/min",
|
||||
},
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/vis-network@10.0.2/standalone/umd/vis-network.min.js",
|
||||
pkgName: "vis-network",
|
||||
filePath: "standalone/umd/vis-network.min.js",
|
||||
localURL: "cdn/vis-network/standalone/umd/vis-network.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js",
|
||||
pkgName: "qrcodejs",
|
||||
filePath: "qrcode.min.js",
|
||||
localURL: "cdn/qrcodejs/qrcode.min.js",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pkgName, func(t *testing.T) {
|
||||
pkgName, filePath, localURL := parseCDNURL(tt.url)
|
||||
if pkgName != tt.pkgName {
|
||||
t.Errorf("pkgName: expected %q, got %q", tt.pkgName, pkgName)
|
||||
}
|
||||
if filePath != tt.filePath {
|
||||
t.Errorf("filePath: expected %q, got %q", tt.filePath, filePath)
|
||||
}
|
||||
if localURL != tt.localURL {
|
||||
t.Errorf("localURL: expected %q, got %q", tt.localURL, localURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchHTML(t *testing.T) {
|
||||
urls := extractURLs(htmlFixtures)
|
||||
|
||||
t.Run("hls", func(t *testing.T) {
|
||||
patched := patchHTML(htmlFixtures["hls.html"], urls)
|
||||
if !strings.Contains(patched, `src="cdn/hls.js/index.js"`) {
|
||||
t.Error("hls.js src not patched")
|
||||
}
|
||||
if strings.Contains(patched, "cdn.jsdelivr.net") {
|
||||
t.Error("CDN URL still present")
|
||||
}
|
||||
if !strings.Contains(patched, `<video id="video">`) {
|
||||
t.Error("non-CDN content damaged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config", func(t *testing.T) {
|
||||
patched := patchHTML(htmlFixtures["config.html"], urls)
|
||||
if !strings.Contains(patched, `src="cdn/monaco-editor/min/vs/loader.js"`) {
|
||||
t.Error("monaco loader.js src not patched")
|
||||
}
|
||||
if !strings.Contains(patched, `src="cdn/js-yaml/dist/js-yaml.min.js"`) {
|
||||
t.Error("js-yaml src not patched")
|
||||
}
|
||||
if !strings.Contains(patched, "monacoRoot = 'cdn/monaco-editor/min'") {
|
||||
t.Error("monacoRoot variable not patched")
|
||||
}
|
||||
// Dynamic references via ${monacoRoot} must remain untouched
|
||||
if !strings.Contains(patched, "${monacoRoot}/") {
|
||||
t.Error("dynamic monacoRoot references damaged")
|
||||
}
|
||||
if strings.Contains(patched, "cdn.jsdelivr.net") {
|
||||
t.Error("CDN URL still present")
|
||||
}
|
||||
if !strings.Contains(patched, "require.config") {
|
||||
t.Error("non-CDN content damaged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("net", func(t *testing.T) {
|
||||
patched := patchHTML(htmlFixtures["net.html"], urls)
|
||||
if !strings.Contains(patched, `src="cdn/vis-network/standalone/umd/vis-network.min.js"`) {
|
||||
t.Error("vis-network src not patched")
|
||||
}
|
||||
if strings.Contains(patched, "cdn.jsdelivr.net") {
|
||||
t.Error("CDN URL still present")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("links", func(t *testing.T) {
|
||||
patched := patchHTML(htmlFixtures["links.html"], urls)
|
||||
if !strings.Contains(patched, "src = 'cdn/qrcodejs/qrcode.min.js'") {
|
||||
t.Error("qrcodejs src not patched")
|
||||
}
|
||||
if strings.Contains(patched, "cdn.jsdelivr.net") {
|
||||
t.Error("CDN URL still present")
|
||||
}
|
||||
if !strings.Contains(patched, "document.createElement") {
|
||||
t.Error("non-CDN content damaged")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractURLsFromRealFiles(t *testing.T) {
|
||||
// Verify the regex works against the actual www/*.html files
|
||||
wwwDir := filepath.Join("..", "www")
|
||||
entries, err := filepath.Glob(filepath.Join(wwwDir, "*.html"))
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Skip("www/*.html not found, skipping real file test")
|
||||
}
|
||||
|
||||
realFiles := map[string]string{}
|
||||
for _, path := range entries {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", path, err)
|
||||
}
|
||||
realFiles[filepath.Base(path)] = string(data)
|
||||
}
|
||||
|
||||
urls := extractURLs(realFiles)
|
||||
if len(urls) < 5 {
|
||||
t.Errorf("expected at least 5 CDN URLs in real files, got %d: %v", len(urls), urls)
|
||||
}
|
||||
|
||||
// Every URL must be parseable
|
||||
for _, u := range urls {
|
||||
pkgName, _, localURL := parseCDNURL(u)
|
||||
if pkgName == "" {
|
||||
t.Errorf("failed to parse package name from %q", u)
|
||||
}
|
||||
if localURL == "" {
|
||||
t.Errorf("failed to generate local URL for %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonacoVersionExtraction(t *testing.T) {
|
||||
urls := extractURLs(htmlFixtures)
|
||||
|
||||
var monacoVer string
|
||||
for _, u := range urls {
|
||||
pkgName, _, _ := parseCDNURL(u)
|
||||
if pkgName == "monaco-editor" {
|
||||
npmPath := strings.TrimPrefix(u, "https://cdn.jsdelivr.net/npm/")
|
||||
pkgVer := strings.SplitN(npmPath, "/", 2)[0]
|
||||
if idx := strings.LastIndex(pkgVer, "@"); idx > 0 {
|
||||
monacoVer = pkgVer[idx+1:]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if monacoVer != "0.55.1" {
|
||||
t.Errorf("expected monaco version 0.55.1, got %q", monacoVer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntrypoint(t *testing.T) {
|
||||
// Find the entrypoint.sh relative to the test file
|
||||
entrypoint := filepath.Join("entrypoint.sh")
|
||||
if _, err := os.Stat(entrypoint); err != nil {
|
||||
t.Skipf("entrypoint.sh not found: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a mock go2rtc that prints its arguments
|
||||
mockBin := filepath.Join(tmpDir, "go2rtc")
|
||||
os.WriteFile(mockBin, []byte("#!/bin/sh\necho \"$@\"\n"), 0755)
|
||||
|
||||
// Read the entrypoint script and adapt for testing:
|
||||
// replace "exec " with "" so the mock go2rtc output is captured,
|
||||
// replace hardcoded /var/www/go2rtc with our temp path.
|
||||
data, err := os.ReadFile(entrypoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
webDir := filepath.Join(tmpDir, "var", "www", "go2rtc")
|
||||
script := strings.ReplaceAll(string(data), "exec ", "")
|
||||
script = strings.ReplaceAll(script, "/var/www/go2rtc", webDir)
|
||||
script = strings.ReplaceAll(script, "/config/go2rtc.yaml", "/tmp/test.yaml")
|
||||
|
||||
testScript := filepath.Join(tmpDir, "test_entrypoint.sh")
|
||||
os.WriteFile(testScript, []byte(script), 0755)
|
||||
|
||||
run := func(extraArgs ...string) string {
|
||||
args := append([]string{testScript}, extraArgs...)
|
||||
cmd := exec.Command("sh", args...)
|
||||
cmd.Env = append(os.Environ(), "PATH="+tmpDir+":"+os.Getenv("PATH"))
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("entrypoint failed: %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
t.Run("with_web_dir", func(t *testing.T) {
|
||||
os.MkdirAll(webDir, 0755)
|
||||
defer os.RemoveAll(filepath.Join(tmpDir, "var"))
|
||||
|
||||
result := run("--extra-flag")
|
||||
|
||||
if !strings.Contains(result, "static_dir") {
|
||||
t.Error("static_dir not added when web dir exists")
|
||||
}
|
||||
if !strings.Contains(result, "-config /tmp/test.yaml") {
|
||||
t.Error("user config not present")
|
||||
}
|
||||
if !strings.Contains(result, "--extra-flag") {
|
||||
t.Error("extra args not passed through")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without_web_dir", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, "var"))
|
||||
|
||||
result := run("--extra-flag")
|
||||
|
||||
if strings.Contains(result, "static_dir") {
|
||||
t.Error("static_dir added when web dir absent")
|
||||
}
|
||||
if !strings.Contains(result, "-config /tmp/test.yaml") {
|
||||
t.Error("user config not present")
|
||||
}
|
||||
if !strings.Contains(result, "--extra-flag") {
|
||||
t.Error("extra args not passed through")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config_order", func(t *testing.T) {
|
||||
os.MkdirAll(webDir, 0755)
|
||||
defer os.RemoveAll(filepath.Join(tmpDir, "var"))
|
||||
|
||||
result := run()
|
||||
|
||||
// static_dir config must come BEFORE user config
|
||||
// so user config can override it
|
||||
staticIdx := strings.Index(result, "static_dir")
|
||||
yamlIdx := strings.Index(result, "/tmp/test.yaml")
|
||||
if staticIdx < 0 || yamlIdx < 0 {
|
||||
t.Fatalf("expected both configs in output: %q", result)
|
||||
}
|
||||
if staticIdx > yamlIdx {
|
||||
t.Errorf("static_dir config should come before user config for correct override order, got: %q", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
# Downloads CDN dependencies from jsdelivr for offline web UI.
|
||||
# Automatically parses CDN URLs from HTML files, so updating
|
||||
# a library version in HTML is all that's needed.
|
||||
#
|
||||
# Usage: download_cdn.sh <web_dir>
|
||||
set -e
|
||||
|
||||
WEB_DIR="${1:?Usage: download_cdn.sh <web_dir>}"
|
||||
CDN_DIR="$WEB_DIR/cdn"
|
||||
mkdir -p "$CDN_DIR"
|
||||
|
||||
# Step 1: Extract all jsdelivr CDN URLs from HTML files
|
||||
URLS=$(grep -roh 'https://cdn\.jsdelivr\.net/npm/[^"'"'"' )`]*' "$WEB_DIR"/*.html | sort -u)
|
||||
|
||||
echo "=== Found CDN URLs ==="
|
||||
echo "$URLS"
|
||||
echo ""
|
||||
|
||||
# Step 2: Process each URL
|
||||
MONACO_VER=""
|
||||
for url in $URLS; do
|
||||
# Remove CDN prefix to get npm path
|
||||
npm_path="${url#https://cdn.jsdelivr.net/npm/}"
|
||||
|
||||
# Extract package@version and file path
|
||||
pkg_ver=$(echo "$npm_path" | cut -d/ -f1)
|
||||
remaining=$(echo "$npm_path" | cut -d/ -f2-)
|
||||
if [ "$remaining" = "$npm_path" ]; then
|
||||
file_path=""
|
||||
else
|
||||
file_path="$remaining"
|
||||
fi
|
||||
|
||||
pkg_name=$(echo "$pkg_ver" | sed 's/@[^@]*$//')
|
||||
|
||||
# Monaco editor: remember version, download as tarball later
|
||||
case "$pkg_name" in
|
||||
monaco-editor)
|
||||
MONACO_VER=$(echo "$pkg_ver" | sed 's/.*@//')
|
||||
echo "Monaco editor v$MONACO_VER (will download tarball)"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Determine local file path
|
||||
if [ -n "$file_path" ]; then
|
||||
local_file="$CDN_DIR/$pkg_name/$file_path"
|
||||
else
|
||||
local_file="$CDN_DIR/$pkg_name/index.js"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$local_file")"
|
||||
echo "Downloading $pkg_ver -> $local_file"
|
||||
wget -q -O "$local_file" "$url"
|
||||
done
|
||||
|
||||
# Step 3: Download monaco-editor tarball and extract min/ directory
|
||||
# The AMD loader dynamically loads modules, so we need the entire min/vs/ tree
|
||||
if [ -n "$MONACO_VER" ]; then
|
||||
echo ""
|
||||
echo "=== Downloading monaco-editor@$MONACO_VER tarball ==="
|
||||
|
||||
TARBALL_URL=$(wget -q -O - "https://registry.npmjs.org/monaco-editor/$MONACO_VER" | \
|
||||
grep -o '"tarball":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
mkdir -p /tmp/monaco "$CDN_DIR/monaco-editor"
|
||||
wget -q -O /tmp/monaco.tgz "$TARBALL_URL"
|
||||
tar xzf /tmp/monaco.tgz -C /tmp/monaco
|
||||
|
||||
cp -r /tmp/monaco/package/min "$CDN_DIR/monaco-editor/"
|
||||
rm -rf /tmp/monaco /tmp/monaco.tgz
|
||||
|
||||
echo " Extracted min/ directory ($(du -sh "$CDN_DIR/monaco-editor/min" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Step 4: Patch HTML files to use local paths instead of CDN URLs
|
||||
echo ""
|
||||
echo "=== Patching HTML files ==="
|
||||
for url in $URLS; do
|
||||
npm_path="${url#https://cdn.jsdelivr.net/npm/}"
|
||||
|
||||
pkg_ver=$(echo "$npm_path" | cut -d/ -f1)
|
||||
remaining=$(echo "$npm_path" | cut -d/ -f2-)
|
||||
if [ "$remaining" = "$npm_path" ]; then
|
||||
file_path=""
|
||||
else
|
||||
file_path="$remaining"
|
||||
fi
|
||||
|
||||
pkg_name=$(echo "$pkg_ver" | sed 's/@[^@]*$//')
|
||||
|
||||
if [ -n "$file_path" ]; then
|
||||
local_url="cdn/$pkg_name/$file_path"
|
||||
else
|
||||
local_url="cdn/$pkg_name/index.js"
|
||||
fi
|
||||
|
||||
echo " $url -> $local_url"
|
||||
sed -i "s|$url|$local_url|g" "$WEB_DIR"/*.html
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
du -sh "$CDN_DIR"
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint wrapper for go2rtc Docker container.
|
||||
# If /var/www/go2rtc exists (CDN files bundled), automatically
|
||||
# configures static_dir to serve web UI without internet access.
|
||||
if [ -d /var/www/go2rtc ]; then
|
||||
exec go2rtc \
|
||||
-config '{"api":{"static_dir":"/var/www/go2rtc"}}' \
|
||||
-config /config/go2rtc.yaml \
|
||||
"$@"
|
||||
else
|
||||
exec go2rtc -config /config/go2rtc.yaml "$@"
|
||||
fi
|
||||
@@ -4,7 +4,7 @@
|
||||
# only debian 13 (trixie) has latest ffmpeg
|
||||
# https://packages.debian.org/trixie/ffmpeg
|
||||
ARG DEBIAN_VERSION="trixie-slim"
|
||||
ARG GO_VERSION="1.25-bookworm"
|
||||
ARG GO_VERSION="1.26-bookworm"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
@@ -26,7 +26,15 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Final image
|
||||
# 2. Download CDN dependencies for offline web UI
|
||||
FROM alpine AS download-cdn
|
||||
RUN apk add --no-cache wget
|
||||
COPY www/ /web/
|
||||
COPY docker/download_cdn.sh /tmp/
|
||||
RUN sh /tmp/download_cdn.sh /web
|
||||
|
||||
|
||||
# 3. Final image
|
||||
FROM debian:${DEBIAN_VERSION}
|
||||
|
||||
# Prepare apt for buildkit cache
|
||||
@@ -48,13 +56,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /build/go2rtc /usr/local/bin/
|
||||
COPY --from=download-cdn /web /var/www/go2rtc
|
||||
COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/
|
||||
|
||||
EXPOSE 1984 8554 8555 8555/udp
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
# https://github.com/NVIDIA/nvidia-docker/wiki/Installation-(Native-GPU-Support)
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES compute,video,utility
|
||||
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 0. Prepare images
|
||||
ARG PYTHON_VERSION="3.13-slim-bookworm"
|
||||
ARG GO_VERSION="1.25-bookworm"
|
||||
ARG GO_VERSION="1.26-bookworm"
|
||||
|
||||
|
||||
# 1. Build go2rtc binary
|
||||
@@ -24,7 +24,15 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath
|
||||
|
||||
|
||||
# 2. Final image
|
||||
# 2. Download CDN dependencies for offline web UI
|
||||
FROM alpine AS download-cdn
|
||||
RUN apk add --no-cache wget
|
||||
COPY www/ /web/
|
||||
COPY docker/download_cdn.sh /tmp/
|
||||
RUN sh /tmp/download_cdn.sh /web
|
||||
|
||||
|
||||
# 3. Final image
|
||||
FROM python:${PYTHON_VERSION}
|
||||
|
||||
# Prepare apt for buildkit cache
|
||||
@@ -42,10 +50,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked --mount=type=cache,t
|
||||
|
||||
COPY --from=build /build/go2rtc /usr/local/bin/
|
||||
ADD --chmod=755 https://github.com/MarcA711/Rockchip-FFmpeg-Builds/releases/download/6.1-8-no_extra_dump/ffmpeg /usr/local/bin
|
||||
COPY --from=download-cdn /web /var/www/go2rtc
|
||||
COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/
|
||||
|
||||
EXPOSE 1984 8554 8555 8555/udp
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
VOLUME /config
|
||||
WORKDIR /config
|
||||
|
||||
CMD ["go2rtc", "-config", "/config/go2rtc.yaml"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module pinggy
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 // indirect
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
module github.com/AlexxIT/go2rtc
|
||||
|
||||
go 1.24.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astits v1.14.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/expr-lang/expr v1.17.7
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -22,6 +21,7 @@ require (
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
|
||||
github.com/skrashevich/go-webp v0.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
|
||||
golang.org/x/crypto v0.47.0
|
||||
@@ -30,7 +30,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astikit v0.57.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -39,7 +38,6 @@ require (
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/sctp v1.9.2 // indirect
|
||||
github.com/pion/transport/v3 v3.1.1 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astikit v0.57.1 h1:fEykwH98Nny08kcRbk4uer+S8h0rKveCIpG9F6NVLuA=
|
||||
github.com/asticode/go-astikit v0.57.1/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
|
||||
github.com/asticode/go-astits v1.14.0 h1:zkgnZzipx2XX5mWycqsSBeEyDH58+i4HtyF4j2ROb00=
|
||||
github.com/asticode/go-astits v1.14.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
|
||||
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -32,24 +24,14 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
|
||||
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
|
||||
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
|
||||
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||
github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
|
||||
github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
|
||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
|
||||
github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
|
||||
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
|
||||
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
|
||||
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
|
||||
github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
|
||||
github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ=
|
||||
github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
@@ -60,41 +42,26 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
|
||||
github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=
|
||||
github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs=
|
||||
github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
|
||||
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
|
||||
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
|
||||
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
||||
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
|
||||
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
|
||||
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||
github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
|
||||
github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
|
||||
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
|
||||
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||
github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk=
|
||||
github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s=
|
||||
github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4=
|
||||
github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
@@ -106,24 +73,20 @@ github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfU
|
||||
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA=
|
||||
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/skrashevich/go-webp v0.1.0 h1:C+dtldBorS5ISATYR5mvG9HFj8GRLDGRywT0xs/ZLUQ=
|
||||
github.com/skrashevich/go-webp v0.1.0/go.mod h1:9QtuNP/H9q/qzqgaZeYalNIk7n5lfyqVs1WTaPtC/Ao=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
|
||||
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
@@ -131,22 +94,16 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -32,6 +32,7 @@ func Init() {
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
UnixListen string `yaml:"unix_listen"`
|
||||
ReadOnly bool `yaml:"read_only"`
|
||||
|
||||
AllowPaths []string `yaml:"allow_paths"`
|
||||
} `yaml:"api"`
|
||||
@@ -50,6 +51,9 @@ func Init() {
|
||||
allowPaths = cfg.Mod.AllowPaths
|
||||
basePath = cfg.Mod.BasePath
|
||||
log = app.GetLogger("api")
|
||||
ReadOnly = cfg.Mod.ReadOnly
|
||||
app.ConfigReadOnly = ReadOnly
|
||||
app.Info["read_only"] = ReadOnly
|
||||
|
||||
initStatic(cfg.Mod.StaticDir)
|
||||
|
||||
@@ -149,6 +153,15 @@ const (
|
||||
)
|
||||
|
||||
var Handler http.Handler
|
||||
var ReadOnly bool
|
||||
|
||||
func IsReadOnly() bool {
|
||||
return ReadOnly
|
||||
}
|
||||
|
||||
func ReadOnlyError(w http.ResponseWriter) {
|
||||
http.Error(w, "read-only", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// HandleFunc handle pattern with relative path:
|
||||
// - "api/streams" => "{basepath}/api/streams"
|
||||
@@ -238,6 +251,8 @@ var mu sync.Mutex
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
app.Info["host"] = r.Host
|
||||
app.Info["pid"] = os.Getpid()
|
||||
app.Info["system"] = getSystemInfo()
|
||||
mu.Unlock()
|
||||
|
||||
ResponseJSON(w, app.Info)
|
||||
@@ -249,6 +264,11 @@ func exitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if IsReadOnly() {
|
||||
ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
|
||||
s := r.URL.Query().Get("code")
|
||||
code, err := strconv.Atoi(s)
|
||||
|
||||
@@ -267,6 +287,11 @@ func restartHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if IsReadOnly() {
|
||||
ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := os.Executable()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -285,6 +310,10 @@ func logHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/jsonlines")
|
||||
_, _ = app.MemoryLog.WriteTo(w)
|
||||
case "DELETE":
|
||||
if IsReadOnly() {
|
||||
ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
app.MemoryLog.Reset()
|
||||
Response(w, "OK", "text/plain")
|
||||
default:
|
||||
|
||||
+87
-26
@@ -1,11 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
pkgyaml "github.com/AlexxIT/go2rtc/pkg/yaml"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -26,6 +30,10 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
Response(w, data, "application/yaml")
|
||||
|
||||
case "POST", "PATCH":
|
||||
if IsReadOnly() {
|
||||
ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -55,47 +63,100 @@ func configHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func mergeYAML(file1 string, yaml2 []byte) ([]byte, error) {
|
||||
// Read the contents of the first YAML file
|
||||
data1, err := os.ReadFile(file1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the first YAML file into a map
|
||||
var config1 map[string]any
|
||||
if err = yaml.Unmarshal(data1, &config1); err != nil {
|
||||
var patch map[string]any
|
||||
if err = yaml.Unmarshal(yaml2, &patch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the second YAML document into a map
|
||||
var config2 map[string]any
|
||||
if err = yaml.Unmarshal(yaml2, &config2); err != nil {
|
||||
data1, err = mergeYAMLMap(data1, nil, patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge the two maps
|
||||
config1 = merge(config1, config2)
|
||||
// validate config after merge
|
||||
if err = yaml.Unmarshal(data1, map[string]any{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Marshal the merged map into YAML
|
||||
return yaml.Marshal(&config1)
|
||||
return data1, nil
|
||||
}
|
||||
|
||||
func merge(dst, src map[string]any) map[string]any {
|
||||
for k, v := range src {
|
||||
if vv, ok := dst[k]; ok {
|
||||
switch vv := vv.(type) {
|
||||
case map[string]any:
|
||||
v := v.(map[string]any)
|
||||
dst[k] = merge(vv, v)
|
||||
case []any:
|
||||
v := v.([]any)
|
||||
dst[k] = v
|
||||
default:
|
||||
dst[k] = v
|
||||
// mergeYAMLMap recursively applies patch values onto config bytes.
|
||||
func mergeYAMLMap(data []byte, path []string, patch map[string]any) ([]byte, error) {
|
||||
for _, key := range slices.Sorted(maps.Keys(patch)) {
|
||||
value := patch[key]
|
||||
currPath := append(append([]string(nil), path...), key)
|
||||
|
||||
if valueMap, ok := value.(map[string]any); ok {
|
||||
isMap, exists, err := pathIsMapping(data, currPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
dst[k] = v
|
||||
|
||||
if exists && isMap {
|
||||
data, err = mergeYAMLMap(data, currPath, valueMap)
|
||||
} else {
|
||||
data, err = pkgyaml.Patch(data, currPath, valueMap)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
data, err = pkgyaml.Patch(data, currPath, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return dst
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// pathIsMapping reports whether path exists and ends with a mapping node.
|
||||
func pathIsMapping(data []byte, path []string) (isMap, exists bool, err error) {
|
||||
var root yaml.Node
|
||||
if err = yaml.Unmarshal(data, &root); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
if len(root.Content) == 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
if len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode {
|
||||
return false, false, errors.New("yaml: expected mapping document")
|
||||
}
|
||||
|
||||
node := root.Content[0]
|
||||
for i, part := range path {
|
||||
idx := -1
|
||||
for j := 0; j < len(node.Content); j += 2 {
|
||||
if node.Content[j].Value == part {
|
||||
idx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
valueNode := node.Content[idx+1]
|
||||
if i == len(path)-1 {
|
||||
return valueNode.Kind == yaml.MappingNode, true, nil
|
||||
}
|
||||
|
||||
if valueNode.Kind != yaml.MappingNode {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
node = valueNode
|
||||
}
|
||||
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConfigHandlerReadOnly(t *testing.T) {
|
||||
prevPath := app.ConfigPath
|
||||
prevReadOnly := ReadOnly
|
||||
t.Cleanup(func() {
|
||||
app.ConfigPath = prevPath
|
||||
ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
app.ConfigPath = filepath.Join(t.TempDir(), "config.yaml")
|
||||
ReadOnly = true
|
||||
|
||||
for _, method := range []string{"POST", "PATCH"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/config", strings.NewReader("log:\n level: info\n"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
configHandler(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.Contains(t, w.Body.String(), "read-only")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestMergeYAMLPreserveCommentedStreamList(t *testing.T) {
|
||||
base := `streams:
|
||||
yard:
|
||||
- #http://1.1.1.1
|
||||
- #http://2.2.2.2
|
||||
- http://3.3.3.3
|
||||
- #http://4.4.4.4
|
||||
log:
|
||||
level: trace
|
||||
`
|
||||
patch := `log:
|
||||
api: debug
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, "#http://1.1.1.1")
|
||||
require.Contains(t, merged, "#http://2.2.2.2")
|
||||
require.Contains(t, merged, "#http://4.4.4.4")
|
||||
require.Contains(t, merged, "- http://3.3.3.3")
|
||||
require.NotContains(t, merged, "- null")
|
||||
require.Contains(t, merged, "api: debug")
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(out, &cfg))
|
||||
}
|
||||
|
||||
func TestMergeYAMLPreserveUnchangedComments(t *testing.T) {
|
||||
base := `api:
|
||||
username: admin
|
||||
streams:
|
||||
yard:
|
||||
- #http://1.1.1.1
|
||||
- http://3.3.3.3
|
||||
`
|
||||
patch := `api:
|
||||
password: secret
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, "username: admin")
|
||||
require.Contains(t, merged, "password: secret")
|
||||
require.Contains(t, merged, "#http://1.1.1.1")
|
||||
require.NotContains(t, merged, "- null")
|
||||
}
|
||||
|
||||
func TestMergeYAMLPreserveCommentsAndFormattingAcrossSections(t *testing.T) {
|
||||
base := `# global config comment
|
||||
api: # api section comment
|
||||
username: admin # inline username comment
|
||||
streams:
|
||||
# stream comment
|
||||
yard:
|
||||
- #http://1.1.1.1
|
||||
- http://3.3.3.3
|
||||
log:
|
||||
format: |
|
||||
line1
|
||||
line2
|
||||
`
|
||||
patch := `api:
|
||||
password: "secret value"
|
||||
ffmpeg:
|
||||
bin: /usr/bin/ffmpeg
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, "# global config comment")
|
||||
require.Contains(t, merged, "# api section comment")
|
||||
require.Contains(t, merged, "# inline username comment")
|
||||
require.Contains(t, merged, "# stream comment")
|
||||
require.Contains(t, merged, "#http://1.1.1.1")
|
||||
require.Contains(t, merged, "password: secret value")
|
||||
require.Contains(t, merged, "format: |")
|
||||
require.NotContains(t, merged, "- null")
|
||||
|
||||
assertOrder(t, merged, "api:", "streams:", "log:", "ffmpeg:")
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(out, &cfg))
|
||||
require.Equal(t, "admin", cfg["api"].(map[string]any)["username"])
|
||||
require.Equal(t, "secret value", cfg["api"].(map[string]any)["password"])
|
||||
require.Equal(t, "/usr/bin/ffmpeg", cfg["ffmpeg"].(map[string]any)["bin"])
|
||||
}
|
||||
|
||||
func TestMergeYAMLPreserveQuotedValuesAndNestedStructure(t *testing.T) {
|
||||
base := `api:
|
||||
username: "admin user"
|
||||
listen: ":1984"
|
||||
webrtc:
|
||||
candidates:
|
||||
- "stun:stun.l.google.com:19302"
|
||||
streams:
|
||||
porch:
|
||||
- "rtsp://cam.local/stream?token=a:b"
|
||||
- #disabled source
|
||||
`
|
||||
patch := `webrtc:
|
||||
ice_servers:
|
||||
- urls:
|
||||
- stun:stun.cloudflare.com:3478
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, `username: "admin user"`)
|
||||
require.Contains(t, merged, `listen: ":1984"`)
|
||||
require.Contains(t, merged, `"rtsp://cam.local/stream?token=a:b"`)
|
||||
require.Contains(t, merged, "#disabled source")
|
||||
require.Contains(t, merged, "ice_servers:")
|
||||
require.NotContains(t, merged, "- null")
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(out, &cfg))
|
||||
require.Equal(t, "admin user", cfg["api"].(map[string]any)["username"])
|
||||
require.Equal(t, ":1984", cfg["api"].(map[string]any)["listen"])
|
||||
require.NotNil(t, cfg["streams"])
|
||||
require.NotNil(t, cfg["webrtc"].(map[string]any)["candidates"])
|
||||
require.NotNil(t, cfg["webrtc"].(map[string]any)["ice_servers"])
|
||||
}
|
||||
|
||||
func TestMergeYAMLPatchLogKeepsCommentedYardEntriesInline(t *testing.T) {
|
||||
base := `api:
|
||||
listen: :1984
|
||||
read_only: false
|
||||
static_dir: www
|
||||
log:
|
||||
level: trace
|
||||
mcp:
|
||||
enabled: true
|
||||
http: true
|
||||
sse: true
|
||||
streams:
|
||||
cam_main:
|
||||
- https://example.local/stream.m3u8
|
||||
yard:
|
||||
- #http://camera.local/disabled-source-a
|
||||
- #ffmpeg:http://camera.local/disabled-source-b#video=h264
|
||||
- ffmpeg:yard#video=mjpeg
|
||||
- #homekit://camera.local/disabled-source-c
|
||||
- homekit://camera.local/enabled-source
|
||||
`
|
||||
patch := `log:
|
||||
api: debug
|
||||
`
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, " level: trace")
|
||||
require.Contains(t, merged, " api: debug")
|
||||
require.Contains(t, merged, " - #http://camera.local/disabled-source-a")
|
||||
require.Contains(t, merged, " - #ffmpeg:http://camera.local/disabled-source-b#video=h264")
|
||||
require.Contains(t, merged, " - #homekit://camera.local/disabled-source-c")
|
||||
require.NotContains(t, merged, "\n -\n")
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(out, &cfg))
|
||||
require.Equal(t, "debug", cfg["log"].(map[string]any)["api"])
|
||||
}
|
||||
|
||||
func TestMergeYAMLPatchLogWithTrailingSpaces(t *testing.T) {
|
||||
// trailing spaces on "- #comment" lines could confuse node parsing
|
||||
base := "api:\n listen: :1984\nlog:\n level: trace\nstreams:\n yard:\n" +
|
||||
" - #http://192.168.88.100/long/path/to/resource \n" +
|
||||
" - #ffmpeg:http://192.168.88.100/path#video=h264 \n" +
|
||||
" - ffmpeg:yard#video=mjpeg\n"
|
||||
|
||||
patch := "log:\n api: debug\n"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
require.Contains(t, merged, " - #http://192.168.88.100/long/path/to/resource")
|
||||
require.Contains(t, merged, " - #ffmpeg:http://192.168.88.100/path#video=h264")
|
||||
require.NotContains(t, merged, "\n -\n")
|
||||
}
|
||||
|
||||
func TestMergeYAMLPatchLogPreservesLongCommentedURLs(t *testing.T) {
|
||||
base := "api:\n" +
|
||||
" listen: :1984\n" +
|
||||
" read_only: false\n" +
|
||||
" static_dir: www\n" +
|
||||
"log:\n" +
|
||||
" level: trace\n" +
|
||||
"mcp:\n" +
|
||||
" enabled: true\n" +
|
||||
" http: true\n" +
|
||||
" sse: true\n" +
|
||||
"streams:\n" +
|
||||
" sf_i280_us101:\n" +
|
||||
" - https://wzmedia.dot.ca.gov/D4/N280_at_JCT_101.stream/playlist.m3u8\n" +
|
||||
" testsrc_h264:\n" +
|
||||
" - exec:ffmpeg -hide_banner -re -f lavfi -i testsrc=size=320x240:rate=15 -c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline -pix_fmt yuv420p -crf 28 -f h264 -\n" +
|
||||
" yard:\n" +
|
||||
" - #http://192.168.88.100/c17d5873fa8f1ca5e0f94daa46e29343/live/files/high/index.m3u8\n" +
|
||||
" - #ffmpeg:http://192.168.88.100/c17d5873fa8f1ca5e0f94daa46e29343/live/files/high/index.m3u8#audio=opus/16000#video=h264\n" +
|
||||
" - ffmpeg:yard#video=mjpeg\n" +
|
||||
" - #homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000001&client_private=0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001&device_id=00:00:00:00:00:01&device_public=0000000000000000000000000000000000000000000000000000000000000001\n" +
|
||||
" - homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000002&client_private=0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002&device_id=00:00:00:00:00:02&device_public=0000000000000000000000000000000000000000000000000000000000000002\n"
|
||||
|
||||
patch := "log:\n api: debug\n"
|
||||
|
||||
path := filepath.Join(t.TempDir(), "go2rtc.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(base), 0o644))
|
||||
|
||||
out, err := mergeYAML(path, []byte(patch))
|
||||
require.NoError(t, err)
|
||||
|
||||
merged := string(out)
|
||||
|
||||
// patch applied
|
||||
require.Contains(t, merged, " api: debug")
|
||||
require.Contains(t, merged, " level: trace")
|
||||
|
||||
// commented entries must stay on same line as dash
|
||||
require.Contains(t, merged, " - #http://192.168.88.100/")
|
||||
require.Contains(t, merged, " - #ffmpeg:http://192.168.88.100/")
|
||||
require.Contains(t, merged, " - #homekit://192.168.88.100:")
|
||||
require.NotContains(t, merged, "\n -\n")
|
||||
|
||||
// non-commented entries preserved
|
||||
require.Contains(t, merged, " - ffmpeg:yard#video=mjpeg")
|
||||
require.Contains(t, merged, " - homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000002")
|
||||
|
||||
var cfg map[string]any
|
||||
require.NoError(t, yaml.Unmarshal(out, &cfg))
|
||||
require.Equal(t, "debug", cfg["log"].(map[string]any)["api"])
|
||||
}
|
||||
|
||||
func assertOrder(t *testing.T, s string, items ...string) {
|
||||
t.Helper()
|
||||
|
||||
last := -1
|
||||
for _, item := range items {
|
||||
idx := strings.Index(s, item)
|
||||
require.NotEqualf(t, -1, idx, "expected %q in output", item)
|
||||
require.Greaterf(t, idx, last, "expected %q after previous sections", item)
|
||||
last = idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package api
|
||||
|
||||
type systemInfo struct {
|
||||
CPUUsage float64 `json:"cpu_usage"` // percent 0-100
|
||||
MemTotal uint64 `json:"mem_total"` // bytes
|
||||
MemUsed uint64 `json:"mem_used"` // bytes
|
||||
}
|
||||
|
||||
func getSystemInfo() systemInfo {
|
||||
memTotal, memUsed := getMemoryInfo()
|
||||
return systemInfo{
|
||||
CPUUsage: getCPUUsage(),
|
||||
MemTotal: memTotal,
|
||||
MemUsed: memUsed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build darwin
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func getMemoryInfo() (total, used uint64) {
|
||||
total = sysctl64("hw.memsize")
|
||||
if total == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
pageSize, err := syscall.SysctlUint32("hw.pagesize")
|
||||
if err != nil {
|
||||
return total, 0
|
||||
}
|
||||
|
||||
freeCount, _ := syscall.SysctlUint32("vm.page_free_count")
|
||||
purgeableCount, _ := syscall.SysctlUint32("vm.page_purgeable_count")
|
||||
speculativeCount, _ := syscall.SysctlUint32("vm.page_speculative_count")
|
||||
|
||||
// inactive pages not available via sysctl, parse vm_stat
|
||||
inactiveCount := vmStatPages("Pages inactive")
|
||||
|
||||
available := uint64(freeCount+purgeableCount+speculativeCount)*uint64(pageSize) +
|
||||
inactiveCount*uint64(pageSize)
|
||||
if available > total {
|
||||
return total, 0
|
||||
}
|
||||
return total, total - available
|
||||
}
|
||||
|
||||
// vmStatPages parses vm_stat output for a specific counter
|
||||
func vmStatPages(key string) uint64 {
|
||||
out, err := exec.Command("vm_stat").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(line, key) {
|
||||
// format: "Pages inactive: 479321."
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(parts[1])
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
val, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sysctl64(name string) uint64 {
|
||||
s, err := syscall.Sysctl(name)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
b := []byte(s)
|
||||
for len(b) < 8 {
|
||||
b = append(b, 0)
|
||||
}
|
||||
return *(*uint64)(unsafe.Pointer(&b[0]))
|
||||
}
|
||||
|
||||
func getCPUUsage() float64 {
|
||||
s, err := syscall.Sysctl("vm.loadavg")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
raw := []byte(s)
|
||||
for len(raw) < 24 {
|
||||
raw = append(raw, 0)
|
||||
}
|
||||
|
||||
// struct loadavg { fixpt_t ldavg[3]; long fscale; }
|
||||
ldavg0 := *(*uint32)(unsafe.Pointer(&raw[0]))
|
||||
fscale := *(*int64)(unsafe.Pointer(&raw[16]))
|
||||
|
||||
if fscale == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
load1 := float64(ldavg0) / float64(fscale)
|
||||
numCPU := float64(runtime.NumCPU())
|
||||
|
||||
usage := load1 / numCPU * 100
|
||||
if usage > 100 {
|
||||
usage = 100
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build darwin
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetMemoryInfo(t *testing.T) {
|
||||
total, used := getMemoryInfo()
|
||||
|
||||
if total == 0 {
|
||||
t.Fatal("mem_total is 0")
|
||||
}
|
||||
if total < 512*1024*1024 {
|
||||
t.Fatalf("mem_total too small: %d", total)
|
||||
}
|
||||
|
||||
// total should match sysctl64("hw.memsize")
|
||||
expectedTotal := sysctl64("hw.memsize")
|
||||
if total != expectedTotal {
|
||||
t.Errorf("mem_total %d != hw.memsize %d", total, expectedTotal)
|
||||
}
|
||||
|
||||
if used == 0 {
|
||||
t.Fatal("mem_used is 0")
|
||||
}
|
||||
if used > total {
|
||||
t.Fatalf("mem_used (%d) > mem_total (%d)", used, total)
|
||||
}
|
||||
|
||||
// cross-check: used should be >= wired+active pages (minimum real usage)
|
||||
pageSize, _ := syscall.SysctlUint32("hw.pagesize")
|
||||
wired := vmStatPages("Pages wired down")
|
||||
active := vmStatPages("Pages active")
|
||||
minUsed := (wired + active) * uint64(pageSize)
|
||||
|
||||
if used < minUsed/2 {
|
||||
t.Errorf("mem_used (%d) is less than half of wired+active (%d)", used, minUsed)
|
||||
}
|
||||
|
||||
avail := total - used
|
||||
t.Logf("RAM total: %.1f GB, used: %.1f GB, avail: %.1f GB",
|
||||
float64(total)/1024/1024/1024,
|
||||
float64(used)/1024/1024/1024,
|
||||
float64(avail)/1024/1024/1024)
|
||||
}
|
||||
|
||||
func TestGetCPUUsage(t *testing.T) {
|
||||
usage := getCPUUsage()
|
||||
|
||||
// cross-check with sysctl vm.loadavg
|
||||
out, err := exec.Command("sysctl", "-n", "vm.loadavg").Output()
|
||||
if err != nil {
|
||||
t.Fatal("sysctl vm.loadavg:", err)
|
||||
}
|
||||
|
||||
// format: { 4.24 4.57 5.76 } or { 4,24 4,57 5,76 }
|
||||
s := strings.Trim(string(out), "{ }\n")
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) < 1 {
|
||||
t.Fatal("cannot parse vm.loadavg:", string(out))
|
||||
}
|
||||
load1Str := strings.ReplaceAll(fields[0], ",", ".")
|
||||
load1, err := strconv.ParseFloat(load1Str, 64)
|
||||
if err != nil {
|
||||
t.Fatal("parse load1:", err)
|
||||
}
|
||||
|
||||
numCPU := float64(runtime.NumCPU())
|
||||
expected := load1 / numCPU * 100
|
||||
if expected > 100 {
|
||||
expected = 100
|
||||
}
|
||||
|
||||
if usage < 0 || usage > 100 {
|
||||
t.Fatalf("cpu_usage out of range: %.1f%%", usage)
|
||||
}
|
||||
|
||||
// allow 15% absolute deviation (load average fluctuates between reads)
|
||||
diff := usage - expected
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 15 {
|
||||
t.Errorf("cpu_usage %.1f%% deviates from expected %.1f%% (load1=%.2f, cpus=%d) by %.1f%%",
|
||||
usage, expected, load1, int(numCPU), diff)
|
||||
}
|
||||
|
||||
t.Logf("CPU usage: %.1f%%, expected: %.1f%% (load1=%.2f, cpus=%d)",
|
||||
usage, expected, load1, int(numCPU))
|
||||
}
|
||||
|
||||
func TestVmStatPages(t *testing.T) {
|
||||
inactive := vmStatPages("Pages inactive")
|
||||
if inactive == 0 {
|
||||
t.Error("Pages inactive returned 0")
|
||||
}
|
||||
|
||||
free := vmStatPages("Pages free")
|
||||
if free == 0 {
|
||||
t.Error("Pages free returned 0")
|
||||
}
|
||||
|
||||
bogus := vmStatPages("Pages nonexistent")
|
||||
if bogus != 0 {
|
||||
t.Errorf("nonexistent key returned %d", bogus)
|
||||
}
|
||||
|
||||
t.Logf("inactive=%d, free=%d pages", inactive, free)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build linux
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getMemoryInfo() (total, used uint64) {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
var memTotal, memAvailable uint64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
val, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
memTotal = val * 1024 // kB to bytes
|
||||
case "MemAvailable:":
|
||||
memAvailable = val * 1024
|
||||
}
|
||||
}
|
||||
|
||||
if memTotal > 0 && memAvailable <= memTotal {
|
||||
return memTotal, memTotal - memAvailable
|
||||
}
|
||||
return memTotal, 0
|
||||
}
|
||||
|
||||
// previous CPU times for delta calculation
|
||||
var prevIdle, prevTotal uint64
|
||||
|
||||
func getCPUUsage() float64 {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// first line: cpu user nice system idle iowait irq softirq steal
|
||||
idx := bytes.IndexByte(data, '\n')
|
||||
if idx < 0 {
|
||||
return 0
|
||||
}
|
||||
line := string(data[:idx])
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total, idle uint64
|
||||
for i := 1; i < len(fields); i++ {
|
||||
val, err := strconv.ParseUint(fields[i], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += val
|
||||
if i == 4 { // idle is the 4th value (index 4 in fields, 1-based field 4)
|
||||
idle = val
|
||||
}
|
||||
}
|
||||
|
||||
deltaTotal := total - prevTotal
|
||||
deltaIdle := idle - prevIdle
|
||||
prevIdle = idle
|
||||
prevTotal = total
|
||||
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package api
|
||||
|
||||
func getMemoryInfo() (total, used uint64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func getCPUUsage() float64 {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build windows
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
globalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
getSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||
)
|
||||
|
||||
// MEMORYSTATUSEX structure
|
||||
type memoryStatusEx struct {
|
||||
dwLength uint32
|
||||
dwMemoryLoad uint32
|
||||
ullTotalPhys uint64
|
||||
ullAvailPhys uint64
|
||||
ullTotalPageFile uint64
|
||||
ullAvailPageFile uint64
|
||||
ullTotalVirtual uint64
|
||||
ullAvailVirtual uint64
|
||||
ullAvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
func getMemoryInfo() (total, used uint64) {
|
||||
var ms memoryStatusEx
|
||||
ms.dwLength = uint32(unsafe.Sizeof(ms))
|
||||
|
||||
ret, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||
if ret == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return ms.ullTotalPhys, ms.ullTotalPhys - ms.ullAvailPhys
|
||||
}
|
||||
|
||||
type filetime struct {
|
||||
dwLowDateTime uint32
|
||||
dwHighDateTime uint32
|
||||
}
|
||||
|
||||
func (ft filetime) ticks() uint64 {
|
||||
return uint64(ft.dwHighDateTime)<<32 | uint64(ft.dwLowDateTime)
|
||||
}
|
||||
|
||||
var prevIdleWin, prevTotalWin uint64
|
||||
|
||||
func getCPUUsage() float64 {
|
||||
var idleTime, kernelTime, userTime filetime
|
||||
|
||||
ret, _, _ := getSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleTime)),
|
||||
uintptr(unsafe.Pointer(&kernelTime)),
|
||||
uintptr(unsafe.Pointer(&userTime)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
idle := idleTime.ticks()
|
||||
total := kernelTime.ticks() + userTime.ticks() // kernel includes idle
|
||||
|
||||
deltaTotal := total - prevTotalWin
|
||||
deltaIdle := idle - prevIdleWin
|
||||
prevIdleWin = idle
|
||||
prevTotalWin = total
|
||||
|
||||
if deltaTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100
|
||||
}
|
||||
@@ -20,11 +20,15 @@ func LoadConfig(v any) {
|
||||
}
|
||||
|
||||
var configMu sync.Mutex
|
||||
var ConfigReadOnly bool
|
||||
|
||||
func PatchConfig(path []string, value any) error {
|
||||
if ConfigPath == "" {
|
||||
return errors.New("config file disabled")
|
||||
}
|
||||
if ConfigReadOnly {
|
||||
return errors.New("config is read-only")
|
||||
}
|
||||
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPatchConfigReadOnly(t *testing.T) {
|
||||
prevPath := ConfigPath
|
||||
prevReadOnly := ConfigReadOnly
|
||||
t.Cleanup(func() {
|
||||
ConfigPath = prevPath
|
||||
ConfigReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(""), 0644))
|
||||
|
||||
ConfigPath = path
|
||||
ConfigReadOnly = true
|
||||
|
||||
err := PatchConfig([]string{"streams", "cam"}, "rtsp://example.com")
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "config is read-only")
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func sendBroadcasts(conn *net.UDPConn) {
|
||||
IP: net.IP{255, 255, 255, 255},
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
for range 3 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = conn.WriteToUDP(data, addr)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
)
|
||||
|
||||
@@ -12,6 +13,10 @@ func apiFFmpeg(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if api.IsReadOnly() {
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
dst := query.Get("dst")
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiFFmpegReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/ffmpeg?dst=cam&text=hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiFFmpeg(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ Docker users should add the `--privileged` option to the container for access to
|
||||
|
||||
**Supported on:** Linux binary, Docker, Hass Addon.
|
||||
|
||||
Docker users should install: `alexxit/go2rtc:master-hardware`. Docker users should add the `--privileged` option to the container for access to the hardware.
|
||||
Docker users should install: `ghcr.io/skrashevich/go2rtc:beta-hardware`. Docker users should add the `--privileged` option to the container for access to the hardware.
|
||||
|
||||
Hass Addon users should install **go2rtc master hardware** version.
|
||||
|
||||
@@ -69,7 +69,7 @@ Hass Addon users should install **go2rtc master hardware** version.
|
||||
|
||||
**Supported on:** Windows binary, Linux binary, Docker.
|
||||
|
||||
Docker users should install: `alexxit/go2rtc:master-hardware`.
|
||||
Docker users should install: `ghcr.io/skrashevich/go2rtc:beta-hardware`.
|
||||
|
||||
Read more [here](https://docs.frigate.video/configuration/hardware_acceleration) and [here](https://jellyfin.org/docs/general/administration/hardware-acceleration/#nvidia-hardware-acceleration-on-docker-linux).
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ func runToString(bin string, args string) string {
|
||||
}
|
||||
|
||||
func cut(s string, sep byte, pos int) string {
|
||||
for n := 0; n < pos; n++ {
|
||||
for range pos {
|
||||
if i := strings.IndexByte(s, sep); i > 0 {
|
||||
s = s[i+1:]
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,109 @@ homekit:
|
||||
name: Dahua camera # custom camera name, default: generated from stream ID
|
||||
device_id: dahua1 # custom ID, default: generated from stream ID
|
||||
device_private: dahua1 # custom key, default: generated from stream ID
|
||||
speaker: true # enable 2-way audio (default: false, enable only if camera has a speaker)
|
||||
```
|
||||
|
||||
### HKSV (HomeKit Secure Video)
|
||||
|
||||
go2rtc can expose any camera as a HomeKit Secure Video (HKSV) camera. This allows Apple Home to record video clips to iCloud when motion is detected.
|
||||
|
||||
**Requirements:**
|
||||
- Apple Home Hub (Apple TV, HomePod or iPad) on the same network
|
||||
- iCloud storage plan with HomeKit Secure Video support
|
||||
- Camera source with H264 video (AAC audio recommended)
|
||||
|
||||
**Minimal HKSV config**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
outdoor: rtsp://admin:password@192.168.1.123/stream1
|
||||
|
||||
homekit:
|
||||
outdoor:
|
||||
hksv: true # enable HomeKit Secure Video
|
||||
motion: continuous # always report motion, Home Hub decides what to record
|
||||
```
|
||||
|
||||
**Full HKSV config**
|
||||
|
||||
```yaml
|
||||
streams:
|
||||
outdoor:
|
||||
- rtsp://admin:password@192.168.1.123/stream1
|
||||
- ffmpeg:outdoor#video=h264#hardware # transcode to H264 if needed
|
||||
- ffmpeg:outdoor#audio=aac # AAC-LC audio for HKSV recording
|
||||
|
||||
homekit:
|
||||
outdoor:
|
||||
pin: 12345678
|
||||
name: Outdoor Camera
|
||||
hksv: true
|
||||
motion: api # motion triggered via API
|
||||
```
|
||||
|
||||
**HKSV Doorbell config**
|
||||
|
||||
```yaml
|
||||
homekit:
|
||||
front_door:
|
||||
category_id: doorbell
|
||||
hksv: true
|
||||
motion: api
|
||||
```
|
||||
|
||||
**Motion modes:**
|
||||
|
||||
- `continuous` — MotionDetected is always true; Home Hub continuously receives video and decides what to save. Simplest setup, recommended for most cameras.
|
||||
- `detect` — automatic motion detection by analyzing H264 P-frame sizes. No external dependencies or CPU-heavy decoding. Works with any H264 source and resolution. Compares each P-frame size against an adaptive baseline using EMA (exponential moving average). When a P-frame exceeds the threshold ratio, motion is triggered with a 30s hold time and 5s cooldown.
|
||||
- `api` — motion is triggered externally via HTTP API. Use this with Frigate, ONVIF events, or any other motion detection system.
|
||||
|
||||
**Motion detect config:**
|
||||
|
||||
```yaml
|
||||
homekit:
|
||||
outdoor:
|
||||
hksv: true
|
||||
motion: detect
|
||||
motion_threshold: 1.0 # P-frame size / baseline ratio to trigger motion (default: 2.0)
|
||||
```
|
||||
|
||||
The `motion_threshold` controls sensitivity — it's the ratio of P-frame size to the adaptive baseline. When a P-frame exceeds `baseline × threshold`, motion is triggered.
|
||||
|
||||
| Scenario | threshold | Notes |
|
||||
|---|---|---|
|
||||
| Quiet indoor scene | 1.3–1.5 | Low noise, stable baseline, even small motion is visible |
|
||||
| Standard camera (yard, hallway) | 2.0 (default) | Good balance between sensitivity and false positives |
|
||||
| Outdoor with trees/shadows/wind | 2.5–3.0 | Wind and shadows produce medium P-frames, need margin |
|
||||
| Busy street / complex scene | 3.0–5.0 | Lots of background motion, react only to large events |
|
||||
|
||||
Values below 1.0 are meaningless (triggers on every frame). Values above 5.0 require very large motion (person filling half the frame).
|
||||
|
||||
**How to tune:** set `log.level: trace` and watch `motion: status` lines — they show current `ratio`. Walk in front of the camera and note the ratio values:
|
||||
|
||||
```
|
||||
motion: status baseline=5000 ratio=0.95 ← quiet
|
||||
motion: status baseline=5000 ratio=3.21 ← person walked by
|
||||
motion: status baseline=5000 ratio=1.40 ← shadow/wind
|
||||
```
|
||||
|
||||
Set threshold between "noise" and "real motion". In this example, 2.0 is a good choice (ignores 1.4, catches 3.2).
|
||||
|
||||
**Motion API:**
|
||||
|
||||
```bash
|
||||
# Get motion status
|
||||
curl "http://localhost:1984/api/homekit/motion?id=outdoor"
|
||||
# → {"id":"outdoor","motion":false}
|
||||
|
||||
# Trigger motion start
|
||||
curl -X POST "http://localhost:1984/api/homekit/motion?id=outdoor"
|
||||
|
||||
# Clear motion
|
||||
curl -X DELETE "http://localhost:1984/api/homekit/motion?id=outdoor"
|
||||
|
||||
# Trigger doorbell ring
|
||||
curl -X POST "http://localhost:1984/api/homekit/doorbell?id=front_door"
|
||||
```
|
||||
|
||||
**Proxy HomeKit camera**
|
||||
|
||||
@@ -43,6 +43,10 @@ func apiDiscovery(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func apiHomekit(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() && r.Method != "GET" {
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiHomekitReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
t.Run("POST blocked", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/homekit", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiHomekit(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
|
||||
t.Run("GET allowed", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/homekit", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiHomekit(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
}
|
||||
+295
-61
@@ -1,18 +1,26 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
@@ -20,12 +28,18 @@ import (
|
||||
func Init() {
|
||||
var cfg struct {
|
||||
Mod map[string]struct {
|
||||
Pin string `yaml:"pin"`
|
||||
Name string `yaml:"name"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
DevicePrivate string `yaml:"device_private"`
|
||||
CategoryID string `yaml:"category_id"`
|
||||
Pairings []string `yaml:"pairings"`
|
||||
Pin string `yaml:"pin"`
|
||||
Name string `yaml:"name"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
DevicePrivate string `yaml:"device_private"`
|
||||
CategoryID string `yaml:"category_id"`
|
||||
Pairings []string `yaml:"pairings"`
|
||||
HKSV bool `yaml:"hksv"`
|
||||
Motion string `yaml:"motion"`
|
||||
MotionThreshold float64 `yaml:"motion_threshold"`
|
||||
MotionHoldTime float64 `yaml:"motion_hold_time"`
|
||||
OnvifURL string `yaml:"onvif_url"`
|
||||
Speaker *bool `yaml:"speaker"`
|
||||
} `yaml:"homekit"`
|
||||
}
|
||||
app.LoadConfig(&cfg)
|
||||
@@ -36,14 +50,16 @@ func Init() {
|
||||
|
||||
api.HandleFunc("api/homekit", apiHomekit)
|
||||
api.HandleFunc("api/homekit/accessories", apiHomekitAccessories)
|
||||
api.HandleFunc("api/homekit/motion", apiMotion)
|
||||
api.HandleFunc("api/homekit/doorbell", apiDoorbell)
|
||||
api.HandleFunc("api/discovery/homekit", apiDiscovery)
|
||||
|
||||
if cfg.Mod == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hosts = map[string]*server{}
|
||||
servers = map[string]*server{}
|
||||
hosts = map[string]*hksv.Server{}
|
||||
servers = map[string]*hksv.Server{}
|
||||
var entries []*mdns.ServiceEntry
|
||||
|
||||
for id, conf := range cfg.Mod {
|
||||
@@ -53,65 +69,74 @@ func Init() {
|
||||
continue
|
||||
}
|
||||
|
||||
if conf.Pin == "" {
|
||||
conf.Pin = "19550224" // default PIN
|
||||
var proxyURL string
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
proxyURL = url
|
||||
}
|
||||
|
||||
pin, err := hap.SanitizePin(conf.Pin)
|
||||
// Remap "onvif" → "api" for hksv.Server; ONVIF watcher drives motion externally.
|
||||
motionMode := conf.Motion
|
||||
if motionMode == "onvif" {
|
||||
motionMode = "api"
|
||||
}
|
||||
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: id,
|
||||
Pin: conf.Pin,
|
||||
Name: conf.Name,
|
||||
DeviceID: conf.DeviceID,
|
||||
DevicePrivate: conf.DevicePrivate,
|
||||
CategoryID: conf.CategoryID,
|
||||
Pairings: conf.Pairings,
|
||||
ProxyURL: proxyURL,
|
||||
HKSV: conf.HKSV,
|
||||
MotionMode: motionMode,
|
||||
MotionThreshold: conf.MotionThreshold,
|
||||
Speaker: conf.Speaker,
|
||||
UserAgent: app.UserAgent,
|
||||
Version: app.Version,
|
||||
Streams: &go2rtcStreamProvider{},
|
||||
Store: &go2rtcPairingStore{},
|
||||
Snapshots: &go2rtcSnapshotProvider{},
|
||||
LiveStream: &go2rtcLiveStreamHandler{},
|
||||
Logger: log,
|
||||
Port: uint16(api.Port),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
log.Error().Err(err).Str("stream", id).Msg("[homekit] create server failed")
|
||||
continue
|
||||
}
|
||||
|
||||
deviceID := calcDeviceID(conf.DeviceID, id) // random MAC-address
|
||||
name := calcName(conf.Name, deviceID)
|
||||
setupID := calcSetupID(id)
|
||||
|
||||
srv := &server{
|
||||
stream: id,
|
||||
pairings: conf.Pairings,
|
||||
setupID: setupID,
|
||||
// Start ONVIF motion watcher if configured.
|
||||
if conf.Motion == "onvif" {
|
||||
onvifURL := conf.OnvifURL
|
||||
if onvifURL == "" {
|
||||
sources := stream.Sources()
|
||||
log.Debug().Str("stream", id).Strs("sources", sources).
|
||||
Msg("[homekit] onvif motion: searching for ONVIF URL in stream sources")
|
||||
onvifURL = findOnvifURL(sources)
|
||||
}
|
||||
if onvifURL == "" {
|
||||
log.Warn().Str("stream", id).Msg("[homekit] onvif motion: no ONVIF URL found, set onvif_url or use onvif:// stream source")
|
||||
} else {
|
||||
holdTime := time.Duration(conf.MotionHoldTime) * time.Second
|
||||
if holdTime <= 0 {
|
||||
holdTime = 30 * time.Second
|
||||
}
|
||||
log.Info().Str("stream", id).Str("onvif_url", onvifURL).
|
||||
Dur("hold_time", holdTime).Msg("[homekit] starting ONVIF motion watcher")
|
||||
startOnvifMotionWatcher(srv, onvifURL, holdTime, log)
|
||||
}
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: calcDevicePrivate(conf.DevicePrivate, id),
|
||||
GetClientPublic: srv.GetPair,
|
||||
}
|
||||
entry := srv.MDNSEntry()
|
||||
entries = append(entries, entry)
|
||||
|
||||
srv.mdns = &mdns.ServiceEntry{
|
||||
Name: name,
|
||||
Port: uint16(api.Port),
|
||||
Info: map[string]string{
|
||||
hap.TXTConfigNumber: "1",
|
||||
hap.TXTFeatureFlags: "0",
|
||||
hap.TXTDeviceID: deviceID,
|
||||
hap.TXTModel: app.UserAgent,
|
||||
hap.TXTProtoVersion: "1.1",
|
||||
hap.TXTStateNumber: "1",
|
||||
hap.TXTStatusFlags: hap.StatusNotPaired,
|
||||
hap.TXTCategory: calcCategoryID(conf.CategoryID),
|
||||
hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
|
||||
},
|
||||
}
|
||||
entries = append(entries, srv.mdns)
|
||||
|
||||
srv.UpdateStatus()
|
||||
|
||||
if url := findHomeKitURL(stream.Sources()); url != "" {
|
||||
// 1. Act as transparent proxy for HomeKit camera
|
||||
srv.proxyURL = url
|
||||
} else {
|
||||
// 2. Act as basic HomeKit camera
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", app.Version)
|
||||
}
|
||||
|
||||
host := srv.mdns.Host(mdns.ServiceHAP)
|
||||
host := entry.Host(mdns.ServiceHAP)
|
||||
hosts[host] = srv
|
||||
servers[id] = srv
|
||||
|
||||
log.Trace().Msgf("[homekit] new server: %s", srv.mdns)
|
||||
log.Trace().Msgf("[homekit] new server: %s", entry)
|
||||
}
|
||||
|
||||
api.HandleFunc(hap.PathPairSetup, hapHandler)
|
||||
@@ -125,8 +150,183 @@ func Init() {
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
var hosts map[string]*server
|
||||
var servers map[string]*server
|
||||
var hosts map[string]*hksv.Server
|
||||
var servers map[string]*hksv.Server
|
||||
|
||||
// go2rtcStreamProvider implements hksv.StreamProvider
|
||||
type go2rtcStreamProvider struct{}
|
||||
|
||||
func (p *go2rtcStreamProvider) AddConsumer(name string, cons core.Consumer) error {
|
||||
stream := streams.Get(name)
|
||||
if stream == nil {
|
||||
return errors.New("stream not found: " + name)
|
||||
}
|
||||
return stream.AddConsumer(cons)
|
||||
}
|
||||
|
||||
func (p *go2rtcStreamProvider) RemoveConsumer(name string, cons core.Consumer) {
|
||||
if s := streams.Get(name); s != nil {
|
||||
s.RemoveConsumer(cons)
|
||||
}
|
||||
}
|
||||
|
||||
// go2rtcPairingStore implements hksv.PairingStore
|
||||
type go2rtcPairingStore struct{}
|
||||
|
||||
func (s *go2rtcPairingStore) SavePairings(name string, pairings []string) error {
|
||||
return app.PatchConfig([]string{"homekit", name, "pairings"}, pairings)
|
||||
}
|
||||
|
||||
// go2rtcSnapshotProvider implements hksv.SnapshotProvider
|
||||
type go2rtcSnapshotProvider struct{}
|
||||
|
||||
func (s *go2rtcSnapshotProvider) GetSnapshot(streamName string, width, height int) ([]byte, error) {
|
||||
stream := streams.Get(streamName)
|
||||
if stream == nil {
|
||||
return nil, errors.New("stream not found: " + streamName)
|
||||
}
|
||||
|
||||
cons := magic.NewKeyframe()
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
once := &core.OnceBuffer{}
|
||||
_, _ = cons.WriteTo(once)
|
||||
b := once.Buffer()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
var err error
|
||||
if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// go2rtcLiveStreamHandler implements hksv.LiveStreamHandler
|
||||
type go2rtcLiveStreamHandler struct {
|
||||
mu sync.Mutex
|
||||
consumers map[string]*homekit.Consumer
|
||||
lastSessionID string
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
|
||||
consumer := homekit.NewConsumer(conn, srtp.Server)
|
||||
consumer.SetOffer(offer)
|
||||
|
||||
old := h.setConsumer(offer.SessionID, consumer)
|
||||
if old != nil && old != consumer {
|
||||
_ = old.Stop()
|
||||
}
|
||||
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) GetEndpointsResponse() any {
|
||||
consumer := h.latestConsumer()
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
answer := consumer.GetAnswer()
|
||||
v, _ := tlv8.MarshalBase64(answer)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker hksv.ConnTracker) error {
|
||||
sessionID := conf.Control.SessionID
|
||||
consumer := h.getConsumer(sessionID)
|
||||
|
||||
if consumer == nil {
|
||||
return errors.New("no consumer")
|
||||
}
|
||||
|
||||
if !consumer.SetConfig(conf) {
|
||||
return errors.New("wrong config")
|
||||
}
|
||||
|
||||
connTracker.AddConn(consumer)
|
||||
|
||||
stream := streams.Get(streamName)
|
||||
if stream == nil {
|
||||
connTracker.DelConn(consumer)
|
||||
return errors.New("stream not found: " + streamName)
|
||||
}
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
connTracker.DelConn(consumer)
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(consumer)
|
||||
connTracker.DelConn(consumer)
|
||||
h.removeConsumer(sessionID, consumer)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) StopStream(sessionID string, connTracker hksv.ConnTracker) error {
|
||||
consumer := h.getConsumer(sessionID)
|
||||
|
||||
if consumer != nil {
|
||||
_ = consumer.Stop()
|
||||
h.removeConsumer(sessionID, consumer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) setConsumer(sessionID string, consumer *homekit.Consumer) *homekit.Consumer {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.consumers == nil {
|
||||
h.consumers = map[string]*homekit.Consumer{}
|
||||
}
|
||||
|
||||
old := h.consumers[sessionID]
|
||||
h.consumers[sessionID] = consumer
|
||||
h.lastSessionID = sessionID
|
||||
return old
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) getConsumer(sessionID string) *homekit.Consumer {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.consumers[sessionID]
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) latestConsumer() *homekit.Consumer {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.consumers[h.lastSessionID]
|
||||
}
|
||||
|
||||
func (h *go2rtcLiveStreamHandler) removeConsumer(sessionID string, consumer *homekit.Consumer) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.consumers[sessionID] == consumer {
|
||||
delete(h.consumers, sessionID)
|
||||
if h.lastSessionID == sessionID {
|
||||
h.lastSessionID = ""
|
||||
for id := range h.consumers {
|
||||
h.lastSessionID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamHandler(rawURL string) (core.Producer, error) {
|
||||
if srtp.Server == nil {
|
||||
@@ -145,7 +345,7 @@ func streamHandler(rawURL string) (core.Producer, error) {
|
||||
return client, err
|
||||
}
|
||||
|
||||
func resolve(host string) *server {
|
||||
func resolve(host string) *hksv.Server {
|
||||
if len(hosts) == 1 {
|
||||
for _, srv := range hosts {
|
||||
return srv
|
||||
@@ -158,9 +358,6 @@ func resolve(host string) *server {
|
||||
}
|
||||
|
||||
func hapHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Can support multiple HomeKit cameras on single port ONLY for Apple devices.
|
||||
// Doesn't support Home Assistant and any other open source projects
|
||||
// because they don't send the host header in requests.
|
||||
srv := resolve(r.Host)
|
||||
if srv == nil {
|
||||
log.Error().Msg("[homekit] unknown host: " + r.Host)
|
||||
@@ -189,6 +386,43 @@ func findHomeKitURL(sources []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func apiMotion(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
srv := servers[id]
|
||||
if srv == nil {
|
||||
http.Error(w, "server not found: "+id, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": id,
|
||||
"motion": srv.MotionDetected(),
|
||||
})
|
||||
case "POST":
|
||||
srv.SetMotionDetected(true)
|
||||
case "DELETE":
|
||||
srv.SetMotionDetected(false)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func apiDoorbell(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := r.URL.Query().Get("id")
|
||||
srv := servers[id]
|
||||
if srv == nil {
|
||||
http.Error(w, "server not found: "+id, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
srv.TriggerDoorbell()
|
||||
}
|
||||
|
||||
func parseBitrate(s string) int {
|
||||
n := len(s)
|
||||
if n == 0 {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/onvif"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
onvifSubscriptionTimeout = 60 * time.Second
|
||||
onvifPullTimeout = 30 * time.Second
|
||||
onvifMessageLimit = 10
|
||||
onvifRenewMargin = 10 * time.Second
|
||||
onvifMinReconnectDelay = 5 * time.Second
|
||||
onvifMaxReconnectDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
type onvifPullPoint interface {
|
||||
PullMessages(timeout time.Duration, limit int) ([]byte, error)
|
||||
Renew(timeout time.Duration) error
|
||||
Unsubscribe() error
|
||||
}
|
||||
|
||||
type onvifPullPointFactory func(rawURL string, timeout time.Duration) (onvifPullPoint, error)
|
||||
|
||||
// onvifMotionWatcher subscribes to ONVIF PullPoint events
|
||||
// and forwards motion state to an hksv.Server.
|
||||
type onvifMotionWatcher struct {
|
||||
srv *hksv.Server
|
||||
onvifURL string
|
||||
holdTime time.Duration
|
||||
log zerolog.Logger
|
||||
|
||||
now func() time.Time
|
||||
newPullPoint onvifPullPointFactory
|
||||
subscriptionTimeout time.Duration
|
||||
pullTimeout time.Duration
|
||||
renewMargin time.Duration
|
||||
messageLimit int
|
||||
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newOnvifMotionWatcher(srv *hksv.Server, onvifURL string, holdTime time.Duration, log zerolog.Logger) *onvifMotionWatcher {
|
||||
return &onvifMotionWatcher{
|
||||
srv: srv,
|
||||
onvifURL: onvifURL,
|
||||
holdTime: holdTime,
|
||||
log: log,
|
||||
now: time.Now,
|
||||
newPullPoint: newOnvifPullPoint,
|
||||
subscriptionTimeout: onvifSubscriptionTimeout,
|
||||
pullTimeout: onvifPullTimeout,
|
||||
renewMargin: onvifRenewMargin,
|
||||
messageLimit: onvifMessageLimit,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// startOnvifMotionWatcher creates and starts a new ONVIF motion watcher.
|
||||
func startOnvifMotionWatcher(srv *hksv.Server, onvifURL string, holdTime time.Duration, log zerolog.Logger) *onvifMotionWatcher {
|
||||
w := newOnvifMotionWatcher(srv, onvifURL, holdTime, log)
|
||||
go w.run()
|
||||
return w
|
||||
}
|
||||
|
||||
// stop shuts down the watcher goroutine.
|
||||
func (w *onvifMotionWatcher) stop() {
|
||||
w.once.Do(func() { close(w.done) })
|
||||
}
|
||||
|
||||
// run is the main loop: create subscription, poll, handle events, reconnect on failure.
|
||||
func (w *onvifMotionWatcher) run() {
|
||||
w.log.Debug().Str("url", w.onvifURL).Dur("hold_time", w.holdTime).
|
||||
Msg("[homekit] onvif motion watcher starting")
|
||||
|
||||
delay := onvifMinReconnectDelay
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.done:
|
||||
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (before connect)")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
w.log.Debug().Str("url", w.onvifURL).Msg("[homekit] onvif motion connecting to camera")
|
||||
|
||||
err := w.connectAndPoll()
|
||||
if err != nil {
|
||||
w.log.Warn().Err(err).Str("url", w.onvifURL).Msg("[homekit] onvif motion error")
|
||||
} else {
|
||||
delay = onvifMinReconnectDelay
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.done:
|
||||
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (after poll)")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
w.log.Debug().Dur("delay", delay).Msg("[homekit] onvif motion reconnecting")
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-w.done:
|
||||
w.log.Debug().Msg("[homekit] onvif motion watcher stopped (during backoff)")
|
||||
return
|
||||
}
|
||||
|
||||
delay *= 2
|
||||
if delay > onvifMaxReconnectDelay {
|
||||
delay = onvifMaxReconnectDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connectAndPoll creates a subscription and polls for events until an error occurs or stop is called.
|
||||
func (w *onvifMotionWatcher) connectAndPoll() error {
|
||||
w.log.Trace().Str("url", w.onvifURL).Dur("timeout", w.subscriptionTimeout).
|
||||
Msg("[homekit] onvif motion: creating pull point subscription")
|
||||
|
||||
sub, err := w.newPullPoint(w.onvifURL, w.subscriptionTimeout)
|
||||
if err != nil {
|
||||
w.log.Debug().Err(err).Str("url", w.onvifURL).
|
||||
Msg("[homekit] onvif motion: pull point creation failed")
|
||||
return err
|
||||
}
|
||||
|
||||
w.log.Info().Str("url", w.onvifURL).Msg("[homekit] onvif motion subscription created")
|
||||
|
||||
defer func() {
|
||||
w.log.Trace().Msg("[homekit] onvif motion: unsubscribing")
|
||||
_ = sub.Unsubscribe()
|
||||
}()
|
||||
|
||||
// motionActive tracks whether we've reported motion=true to the HKSV server.
|
||||
// Hold timer ensures motion stays active for at least holdTime after last trigger,
|
||||
// regardless of whether the camera sends explicit "motion=false".
|
||||
// This matches the behavior of the built-in MotionDetector (30s hold time).
|
||||
motionActive := false
|
||||
var holdTimer *time.Timer
|
||||
defer func() {
|
||||
if holdTimer != nil {
|
||||
holdTimer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
renewInterval := w.subscriptionRenewInterval()
|
||||
renewAt := w.now().Add(renewInterval)
|
||||
|
||||
w.log.Trace().Dur("renew_interval", renewInterval).
|
||||
Msg("[homekit] onvif motion: subscription renew scheduled")
|
||||
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.done:
|
||||
w.log.Debug().Int("polls", pollCount).
|
||||
Msg("[homekit] onvif motion: poll loop stopped")
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
if !renewAt.After(w.now()) {
|
||||
w.log.Trace().Msg("[homekit] onvif motion: renewing subscription")
|
||||
if err := sub.Renew(w.subscriptionTimeout); err != nil {
|
||||
w.log.Warn().Err(err).Msg("[homekit] onvif motion: renew failed")
|
||||
return err
|
||||
}
|
||||
renewAt = w.now().Add(renewInterval)
|
||||
w.log.Trace().Msg("[homekit] onvif motion: subscription renewed")
|
||||
}
|
||||
|
||||
pullTimeout := w.nextPullTimeout(renewAt)
|
||||
|
||||
w.log.Trace().Dur("timeout", pullTimeout).Int("limit", w.messageLimit).
|
||||
Int("poll", pollCount+1).Msg("[homekit] onvif motion: pulling messages")
|
||||
|
||||
b, err := sub.PullMessages(pullTimeout, w.messageLimit)
|
||||
if err != nil {
|
||||
w.log.Debug().Err(err).Int("polls", pollCount).
|
||||
Msg("[homekit] onvif motion: pull messages failed")
|
||||
return err
|
||||
}
|
||||
pollCount++
|
||||
|
||||
w.log.Trace().Int("bytes", len(b)).Int("poll", pollCount).
|
||||
Msg("[homekit] onvif motion: pull response received")
|
||||
|
||||
if l := w.log.Trace(); l.Enabled() {
|
||||
l.Str("body", string(b)).Msg("[homekit] onvif motion: raw response")
|
||||
}
|
||||
|
||||
motion, found := onvif.ParseMotionEvents(b)
|
||||
|
||||
w.log.Trace().Bool("found", found).Bool("motion", motion).
|
||||
Bool("active", motionActive).Msg("[homekit] onvif motion: parse result")
|
||||
|
||||
if !found {
|
||||
w.log.Trace().Msg("[homekit] onvif motion: no motion events in response")
|
||||
continue
|
||||
}
|
||||
|
||||
if motion {
|
||||
// Motion detected — activate and start/reset hold timer.
|
||||
if !motionActive {
|
||||
motionActive = true
|
||||
w.srv.SetMotionDetected(true)
|
||||
w.log.Debug().Msg("[homekit] onvif motion: detected")
|
||||
} else {
|
||||
w.log.Trace().Msg("[homekit] onvif motion: still active, resetting hold timer")
|
||||
}
|
||||
|
||||
// Reset hold timer on every motion=true event.
|
||||
if holdTimer != nil {
|
||||
holdTimer.Stop()
|
||||
}
|
||||
holdTimer = time.AfterFunc(w.holdTime, func() {
|
||||
motionActive = false
|
||||
w.srv.SetMotionDetected(false)
|
||||
w.log.Debug().Msg("[homekit] onvif motion: hold expired")
|
||||
})
|
||||
} else {
|
||||
// Camera sent explicit motion=false.
|
||||
// Do NOT clear immediately — let the hold timer handle it.
|
||||
// This ensures motion stays active for at least holdTime,
|
||||
// giving the Home Hub enough time to open the DataStream.
|
||||
w.log.Debug().Dur("remaining_hold", w.holdTime).
|
||||
Bool("active", motionActive).
|
||||
Msg("[homekit] onvif motion: camera reported clear, waiting for hold timer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *onvifMotionWatcher) subscriptionRenewInterval() time.Duration {
|
||||
interval := w.subscriptionTimeout - w.renewMargin
|
||||
if interval <= 0 {
|
||||
interval = w.subscriptionTimeout / 2
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func (w *onvifMotionWatcher) nextPullTimeout(renewAt time.Time) time.Duration {
|
||||
timeout := w.pullTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = time.Second
|
||||
}
|
||||
|
||||
if untilRenew := renewAt.Sub(w.now()); untilRenew > 0 && untilRenew < timeout {
|
||||
timeout = untilRenew
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = time.Second
|
||||
}
|
||||
|
||||
return timeout
|
||||
}
|
||||
|
||||
func newOnvifPullPoint(rawURL string, timeout time.Duration) (onvifPullPoint, error) {
|
||||
client, err := onvif.NewClient(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.CreatePullPointSubscription(timeout)
|
||||
}
|
||||
|
||||
// findOnvifURL looks for an onvif:// URL in stream sources.
|
||||
func findOnvifURL(sources []string) string {
|
||||
for _, src := range sources {
|
||||
if strings.HasPrefix(src, "onvif://") || strings.HasPrefix(src, "onvif:") {
|
||||
return src
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func TestOnvifMotionWatcherConnectAndPollRenewsBeforeLeaseExpires(t *testing.T) {
|
||||
start := time.Unix(0, 0)
|
||||
now := start
|
||||
stopErr := errors.New("stop pull loop")
|
||||
|
||||
sub := &fakeOnvifPullPoint{
|
||||
t: t,
|
||||
now: &now,
|
||||
pullErrAt: 3,
|
||||
pullErr: stopErr,
|
||||
}
|
||||
|
||||
w := newOnvifMotionWatcher(&hksv.Server{}, "onvif://camera", 30*time.Second, zerolog.Nop())
|
||||
w.now = func() time.Time { return now }
|
||||
w.newPullPoint = func(rawURL string, timeout time.Duration) (onvifPullPoint, error) {
|
||||
if rawURL != "onvif://camera" {
|
||||
t.Fatalf("unexpected ONVIF URL: %s", rawURL)
|
||||
}
|
||||
if timeout != 60*time.Second {
|
||||
t.Fatalf("unexpected subscription timeout: %v", timeout)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
err := w.connectAndPoll()
|
||||
if !errors.Is(err, stopErr) {
|
||||
t.Fatalf("expected %v, got %v", stopErr, err)
|
||||
}
|
||||
|
||||
wantPulls := []time.Duration{30 * time.Second, 20 * time.Second, 30 * time.Second}
|
||||
if len(sub.pullTimeouts) != len(wantPulls) {
|
||||
t.Fatalf("unexpected pull count: got %d want %d", len(sub.pullTimeouts), len(wantPulls))
|
||||
}
|
||||
for i, want := range wantPulls {
|
||||
if sub.pullTimeouts[i] != want {
|
||||
t.Fatalf("pull %d timeout mismatch: got %v want %v", i+1, sub.pullTimeouts[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
if sub.renewCalls != 1 {
|
||||
t.Fatalf("expected 1 renew call, got %d", sub.renewCalls)
|
||||
}
|
||||
if !sub.unsubscribed {
|
||||
t.Fatal("expected unsubscribe on exit")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeOnvifPullPoint struct {
|
||||
t *testing.T
|
||||
|
||||
now *time.Time
|
||||
|
||||
pullTimeouts []time.Duration
|
||||
renewCalls int
|
||||
unsubscribed bool
|
||||
|
||||
pullErrAt int
|
||||
pullErr error
|
||||
}
|
||||
|
||||
func (f *fakeOnvifPullPoint) PullMessages(timeout time.Duration, limit int) ([]byte, error) {
|
||||
if limit != 10 {
|
||||
f.t.Fatalf("unexpected message limit: %d", limit)
|
||||
}
|
||||
|
||||
f.pullTimeouts = append(f.pullTimeouts, timeout)
|
||||
*f.now = f.now.Add(timeout)
|
||||
|
||||
if f.pullErrAt > 0 && len(f.pullTimeouts) == f.pullErrAt {
|
||||
return nil, f.pullErr
|
||||
}
|
||||
|
||||
return []byte(`<tev:PullMessagesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`), nil
|
||||
}
|
||||
|
||||
func (f *fakeOnvifPullPoint) Renew(timeout time.Duration) error {
|
||||
if timeout != 60*time.Second {
|
||||
f.t.Fatalf("unexpected renew timeout: %v", timeout)
|
||||
}
|
||||
|
||||
f.renewCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeOnvifPullPoint) Unsubscribe() error {
|
||||
f.unsubscribed = true
|
||||
return nil
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
package homekit
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
srtp2 "github.com/AlexxIT/go2rtc/internal/srtp"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
hap *hap.Server // server for HAP connection and encryption
|
||||
mdns *mdns.ServiceEntry
|
||||
|
||||
pairings []string // pairings list
|
||||
conns []any
|
||||
mu sync.Mutex
|
||||
|
||||
accessory *hap.Accessory // HAP accessory
|
||||
consumer *homekit.Consumer
|
||||
proxyURL string
|
||||
setupID string
|
||||
stream string // stream name from YAML
|
||||
}
|
||||
|
||||
func (s *server) MarshalJSON() ([]byte, error) {
|
||||
v := struct {
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Paired int `json:"paired,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
SetupCode string `json:"setup_code,omitempty"`
|
||||
SetupID string `json:"setup_id,omitempty"`
|
||||
Conns []any `json:"connections,omitempty"`
|
||||
}{
|
||||
Name: s.mdns.Name,
|
||||
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||
CategoryID: s.mdns.Info[hap.TXTCategory],
|
||||
Paired: len(s.pairings),
|
||||
Conns: s.conns,
|
||||
}
|
||||
if v.Paired == 0 {
|
||||
v.SetupCode = s.hap.Pin
|
||||
v.SetupID = s.setupID
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (s *server) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
// Fix reading from Body after Hijack.
|
||||
r.Body = io.NopCloser(rw)
|
||||
|
||||
switch r.RequestURI {
|
||||
case hap.PathPairSetup:
|
||||
id, key, err := s.hap.PairSetup(r, rw)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.AddPair(id, key, hap.PermissionAdmin)
|
||||
|
||||
case hap.PathPairVerify:
|
||||
id, key, err := s.hap.PairVerify(r, rw)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[homekit] %s: new conn", conn.RemoteAddr())
|
||||
|
||||
controller, err := hap.NewConn(conn, rw, key, false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(controller)
|
||||
defer s.DelConn(controller)
|
||||
|
||||
var handler homekit.HandlerFunc
|
||||
|
||||
switch {
|
||||
case s.accessory != nil:
|
||||
handler = homekit.ServerHandler(s)
|
||||
case s.proxyURL != "":
|
||||
client, err := hap.Dial(s.proxyURL)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
handler = homekit.ProxyHandler(s, client.Conn)
|
||||
}
|
||||
|
||||
// If your iPhone goes to sleep, it will be an EOF error.
|
||||
if err = handler(controller); err != nil && !errors.Is(err, io.EOF) {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type logger struct {
|
||||
v any
|
||||
}
|
||||
|
||||
func (l logger) String() string {
|
||||
switch v := l.v.(type) {
|
||||
case *hap.Conn:
|
||||
return "hap " + v.RemoteAddr().String()
|
||||
case *hds.Conn:
|
||||
return "hds " + v.RemoteAddr().String()
|
||||
case *homekit.Consumer:
|
||||
return "rtp " + v.RemoteAddr
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (s *server) AddConn(v any) {
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] add conn %s", logger{v})
|
||||
s.mu.Lock()
|
||||
s.conns = append(s.conns, v)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) DelConn(v any) {
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] del conn %s", logger{v})
|
||||
s.mu.Lock()
|
||||
if i := slices.Index(s.conns, v); i >= 0 {
|
||||
s.conns = slices.Delete(s.conns, i, i+1)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) UpdateStatus() {
|
||||
// true status is important, or device may be offline in Apple Home
|
||||
if len(s.pairings) == 0 {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusNotPaired
|
||||
} else {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusPaired
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) pairIndex(id string) int {
|
||||
id = "client_id=" + id
|
||||
for i, pairing := range s.pairings {
|
||||
if strings.HasPrefix(pairing, id) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *server) GetPair(id string) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if i := s.pairIndex(id); i >= 0 {
|
||||
query, _ := url.ParseQuery(s.pairings[i])
|
||||
b, _ := hex.DecodeString(query.Get("client_public"))
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) AddPair(id string, public []byte, permissions byte) {
|
||||
log.Debug().Str("stream", s.stream).Msgf("[homekit] add pair id=%s public=%x perm=%d", id, public, permissions)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.pairIndex(id) < 0 {
|
||||
s.pairings = append(s.pairings, fmt.Sprintf(
|
||||
"client_id=%s&client_public=%x&permissions=%d", id, public, permissions,
|
||||
))
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) DelPair(id string) {
|
||||
log.Debug().Str("stream", s.stream).Msgf("[homekit] del pair id=%s", id)
|
||||
|
||||
s.mu.Lock()
|
||||
if i := s.pairIndex(id); i >= 0 {
|
||||
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
||||
s.UpdateStatus()
|
||||
s.PatchConfig()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *server) PatchConfig() {
|
||||
if err := app.PatchConfig([]string{"homekit", s.stream, "pairings"}, s.pairings); err != nil {
|
||||
log.Error().Err(err).Msgf(
|
||||
"[homekit] can't save %s pairings=%v", s.stream, s.pairings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||
return []*hap.Accessory{s.accessory}
|
||||
}
|
||||
|
||||
func (s *server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] get char aid=%d iid=0x%x", aid, iid)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
log.Warn().Msgf("[homekit] get unknown characteristic: %d", iid)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
consumer := s.consumer
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
return char.Value
|
||||
}
|
||||
|
||||
func (s *server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] set char aid=%d iid=0x%x value=%v", aid, iid, value)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
log.Warn().Msgf("[homekit] set unknown characteristic: %d", iid)
|
||||
return
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
var offer camera.SetupEndpointsRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
consumer := homekit.NewConsumer(conn, srtp2.Server)
|
||||
consumer.SetOffer(&offer)
|
||||
s.consumer = consumer
|
||||
|
||||
case camera.TypeSelectedStreamConfiguration:
|
||||
var conf camera.SelectedStreamConfiguration
|
||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
|
||||
|
||||
switch conf.Control.Command {
|
||||
case camera.SessionCommandEnd:
|
||||
for _, consumer := range s.conns {
|
||||
if consumer, ok := consumer.(*homekit.Consumer); ok {
|
||||
if consumer.SessionID() == conf.Control.SessionID {
|
||||
_ = consumer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case camera.SessionCommandStart:
|
||||
consumer := s.consumer
|
||||
if consumer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !consumer.SetConfig(&conf) {
|
||||
log.Warn().Msgf("[homekit] wrong config")
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(consumer)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = consumer.WriteTo(nil)
|
||||
stream.RemoveConsumer(consumer)
|
||||
|
||||
s.DelConn(consumer)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
log.Trace().Str("stream", s.stream).Msgf("[homekit] get image width=%d height=%d", width, height)
|
||||
|
||||
stream := streams.Get(s.stream)
|
||||
cons := magic.NewKeyframe()
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
once := &core.OnceBuffer{} // init and first frame
|
||||
_, _ = cons.WriteTo(once)
|
||||
b := once.Buffer()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
var err error
|
||||
if b, err = ffmpeg.JPEGWithScale(b, width, height); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func calcName(name, seed string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("go2rtc-%02X%02X", b[0], b[2])
|
||||
}
|
||||
|
||||
func calcDeviceID(deviceID, seed string) string {
|
||||
if deviceID != "" {
|
||||
if len(deviceID) >= 17 {
|
||||
// 1. Returd device_id as is (ex. AA:BB:CC:DD:EE:FF)
|
||||
return deviceID
|
||||
}
|
||||
// 2. Use device_id as seed if not zero
|
||||
seed = deviceID
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[32], b[34], b[36], b[38], b[40], b[42])
|
||||
}
|
||||
|
||||
func calcDevicePrivate(private, seed string) []byte {
|
||||
if private != "" {
|
||||
// 1. Decode private from HEX string
|
||||
if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize {
|
||||
// 2. Return if OK
|
||||
return b
|
||||
}
|
||||
// 3. Use private as seed if not zero
|
||||
seed = private
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
|
||||
}
|
||||
|
||||
func calcSetupID(seed string) string {
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X%02X", b[44], b[46])
|
||||
}
|
||||
|
||||
func calcCategoryID(categoryID string) string {
|
||||
switch categoryID {
|
||||
case "bridge":
|
||||
return hap.CategoryBridge
|
||||
case "doorbell":
|
||||
return hap.CategoryDoorbell
|
||||
}
|
||||
if core.Atoi(categoryID) > 0 {
|
||||
return categoryID
|
||||
}
|
||||
return hap.CategoryCamera
|
||||
}
|
||||
@@ -111,6 +111,14 @@ func handleTCP(rawURL string) (core.Producer, error) {
|
||||
}
|
||||
|
||||
func apiStream(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() {
|
||||
switch r.Method {
|
||||
case "PUT", "PATCH", "POST", "DELETE":
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dst := r.URL.Query().Get("dst")
|
||||
stream := streams.Get(dst)
|
||||
if stream == nil {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiStreamReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/stream?dst=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiStream(w, req)
|
||||
|
||||
require.Equal(t, stdhttp.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func apiRing(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err = ringAPI.GetAuth(code); err != nil {
|
||||
if ringAPI.Using2FA {
|
||||
// Return 2FA prompt
|
||||
api.ResponseJSON(w, map[string]interface{}{
|
||||
api.ResponseJSON(w, map[string]any{
|
||||
"needs_2fa": true,
|
||||
"prompt": ringAPI.PromptFor2FA,
|
||||
})
|
||||
|
||||
@@ -24,6 +24,10 @@ var Auth struct {
|
||||
}
|
||||
|
||||
func apiHandle(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() && r.Method != "GET" {
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if Auth.UserData == nil {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package roborock
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiHandleReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/roborock", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiHandle(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
@@ -12,6 +12,13 @@ import (
|
||||
|
||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
w = creds.SecretResponse(w)
|
||||
if api.IsReadOnly() {
|
||||
switch r.Method {
|
||||
case "PUT", "PATCH", "POST", "DELETE":
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
src := query.Get("src")
|
||||
@@ -130,6 +137,13 @@ func apiStreamsDOT(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func apiPreload(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() {
|
||||
switch r.Method {
|
||||
case "PUT", "DELETE":
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
// GET - return all preloads
|
||||
if r.Method == "GET" {
|
||||
api.ResponseJSON(w, GetPreloads())
|
||||
@@ -177,5 +191,7 @@ func apiPreload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func apiSchemes(w http.ResponseWriter, r *http.Request) {
|
||||
// Wait until all module Init() calls finish in main.
|
||||
WaitReady()
|
||||
api.ResponseJSON(w, SupportedSchemes())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiStreamsReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
for _, method := range []string{"PUT", "PATCH", "POST", "DELETE"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/streams?src=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiStreams(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("GET allowed", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/streams", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiStreams(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiPreloadReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
for _, method := range []string{"PUT", "DELETE"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/preload?src=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiPreload(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("GET allowed", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/preload", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiPreload(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiSchemes(t *testing.T) {
|
||||
SetReady()
|
||||
|
||||
// Setup: Register some test handlers and redirects
|
||||
HandleFunc("rtsp", func(url string) (core.Producer, error) { return nil, nil })
|
||||
HandleFunc("rtmp", func(url string) (core.Producer, error) { return nil, nil })
|
||||
@@ -38,6 +42,8 @@ func TestApiSchemes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiSchemesNoDuplicates(t *testing.T) {
|
||||
SetReady()
|
||||
|
||||
// Setup: Register a scheme in both handlers and redirects
|
||||
HandleFunc("duplicate", func(url string) (core.Producer, error) { return nil, nil })
|
||||
RedirectFunc("duplicate", func(url string) (string, error) { return "", nil })
|
||||
@@ -64,3 +70,46 @@ func TestApiSchemesNoDuplicates(t *testing.T) {
|
||||
// Should only appear once
|
||||
require.Equal(t, 1, count, "scheme 'duplicate' should appear exactly once")
|
||||
}
|
||||
|
||||
func TestApiSchemesWaitsForReady(t *testing.T) {
|
||||
oldReady := ready
|
||||
oldReadyOnce := readyOnce
|
||||
ready = make(chan struct{})
|
||||
readyOnce = sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
ready = oldReady
|
||||
readyOnce = oldReadyOnce
|
||||
})
|
||||
|
||||
HandleFunc("waittest", func(url string) (core.Producer, error) { return nil, nil })
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/schemes", nil)
|
||||
w := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
apiSchemes(w, req)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("apiSchemes returned before streams became ready")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
SetReady()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("apiSchemes did not return after streams became ready")
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var schemes []string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &schemes)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, schemes, "waittest")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package streams
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
ready = make(chan struct{})
|
||||
readyOnce sync.Once
|
||||
)
|
||||
|
||||
func SetReady() {
|
||||
readyOnce.Do(func() {
|
||||
close(ready)
|
||||
})
|
||||
}
|
||||
|
||||
func WaitReady() {
|
||||
<-ready
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ func NewStream(source any) *Stream {
|
||||
case []any:
|
||||
s := new(Stream)
|
||||
for _, src := range source {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
str, ok := src.(string)
|
||||
if !ok {
|
||||
log.Error().Msgf("[stream] NewStream: Expected string, got %v", src)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/AlexxIT/go2rtc/internal/app"
|
||||
"github.com/AlexxIT/go2rtc/internal/ffmpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/streams"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/magic"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/pkg/webp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
api.HandleFunc("api/frame.webp", handlerKeyframe)
|
||||
api.HandleFunc("api/stream.webp", handlerStream)
|
||||
|
||||
log = app.GetLogger("webp")
|
||||
}
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
var cache map[string]cacheEntry
|
||||
var cacheMu sync.Mutex
|
||||
|
||||
type cacheEntry struct {
|
||||
payload []byte
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func handlerKeyframe(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
stream, _ := streams.GetOrPatch(query)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
quality := 75
|
||||
if s := query.Get("quality"); s != "" {
|
||||
if q, err := strconv.Atoi(s); err == nil && q > 0 && q <= 100 {
|
||||
quality = q
|
||||
}
|
||||
}
|
||||
|
||||
var b []byte
|
||||
|
||||
if s := query.Get("cache"); s != "" {
|
||||
if timeout, err := time.ParseDuration(s); err == nil {
|
||||
src := query.Get("src")
|
||||
|
||||
cacheMu.Lock()
|
||||
entry, found := cache[src]
|
||||
cacheMu.Unlock()
|
||||
|
||||
if found && time.Since(entry.timestamp) < timeout {
|
||||
writeWebPResponse(w, entry.payload)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
entry = cacheEntry{payload: b, timestamp: time.Now()}
|
||||
cacheMu.Lock()
|
||||
if cache == nil {
|
||||
cache = map[string]cacheEntry{src: entry}
|
||||
} else {
|
||||
cache[src] = entry
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
cons := magic.NewKeyframe()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
once := &core.OnceBuffer{}
|
||||
_, _ = cons.WriteTo(once)
|
||||
b = once.Buffer()
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
|
||||
var err error
|
||||
switch cons.CodecName() {
|
||||
case core.CodecH264, core.CodecH265:
|
||||
ts := time.Now()
|
||||
var jpegBytes []byte
|
||||
if jpegBytes, err = ffmpeg.JPEGWithQuery(b, query); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debug().Msgf("[webp] transcoding time=%s", time.Since(ts))
|
||||
if b, err = webp.EncodeJPEG(jpegBytes, quality); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case core.CodecJPEG:
|
||||
fixed := mjpeg.FixJPEG(b)
|
||||
if b, err = webp.EncodeJPEG(fixed, quality); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeWebPResponse(w, b)
|
||||
}
|
||||
|
||||
func writeWebPResponse(w http.ResponseWriter, b []byte) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "image/webp")
|
||||
h.Set("Content-Length", strconv.Itoa(len(b)))
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
if _, err := w.Write(b); err != nil {
|
||||
log.Error().Err(err).Caller().Send()
|
||||
}
|
||||
}
|
||||
|
||||
func handlerStream(w http.ResponseWriter, r *http.Request) {
|
||||
src := r.URL.Query().Get("src")
|
||||
stream := streams.Get(src)
|
||||
if stream == nil {
|
||||
http.Error(w, api.StreamNotFound, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
cons := webp.NewConsumer()
|
||||
cons.WithRequest(r)
|
||||
|
||||
if err := stream.AddConsumer(cons); err != nil {
|
||||
log.Error().Err(err).Msg("[api.webp] add consumer")
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "close")
|
||||
h.Set("Pragma", "no-cache")
|
||||
|
||||
wr := webp.NewWriter(w)
|
||||
_, _ = cons.WriteTo(wr)
|
||||
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
// Verify Init() runs without panicking and registers API endpoints.
|
||||
// api.HandleFunc registrations are idempotent so calling Init multiple times is safe.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Init() panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
Init()
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (m *milestoneAPI) GetToken() error {
|
||||
return errors.New("milesone: authentication failed: " + res.Status)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
var payload map[string]any
|
||||
if err = json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ const MimeSDP = "application/sdp"
|
||||
var sessions = map[string]*webrtc.Conn{}
|
||||
|
||||
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() {
|
||||
switch r.Method {
|
||||
case "POST", "PATCH", "DELETE":
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
query := r.URL.Query()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package webrtc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSyncHandlerReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
for _, method := range []string{"POST", "PATCH", "DELETE"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/api/webrtc?dst=test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
syncHandler(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ func getCloud(email string) (*wyze.Cloud, error) {
|
||||
}
|
||||
|
||||
func apiWyze(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() && r.Method != "GET" {
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
apiDeviceList(w, r)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package wyze
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiWyzeReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/wyze", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiWyze(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
@@ -219,6 +219,10 @@ func wakeUpCamera(url *url.URL) error {
|
||||
}
|
||||
|
||||
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
|
||||
if api.IsReadOnly() && r.Method != "GET" {
|
||||
api.ReadOnlyError(w)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
apiDeviceList(w, r)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package xiaomi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/internal/api"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApiXiaomiReadOnly(t *testing.T) {
|
||||
prevReadOnly := api.ReadOnly
|
||||
t.Cleanup(func() {
|
||||
api.ReadOnly = prevReadOnly
|
||||
})
|
||||
|
||||
api.ReadOnly = true
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/xiaomi", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
apiXiaomi(w, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/AlexxIT/go2rtc/internal/kasa"
|
||||
"github.com/AlexxIT/go2rtc/internal/mjpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/mp4"
|
||||
"github.com/AlexxIT/go2rtc/internal/webp"
|
||||
"github.com/AlexxIT/go2rtc/internal/mpeg"
|
||||
"github.com/AlexxIT/go2rtc/internal/multitrans"
|
||||
"github.com/AlexxIT/go2rtc/internal/nest"
|
||||
@@ -73,6 +74,7 @@ func main() {
|
||||
{"mp4", mp4.Init}, // MP4 API
|
||||
{"hls", hls.Init}, // HLS API
|
||||
{"mjpeg", mjpeg.Init}, // MJPEG API
|
||||
{"webp", webp.Init}, // WebP API
|
||||
// Other sources and servers
|
||||
{"hass", hass.Init}, // hass source, Hass API server
|
||||
{"homekit", homekit.Init}, // homekit source, HomeKit server
|
||||
@@ -121,5 +123,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
streams.SetReady()
|
||||
|
||||
shell.RunUntilSignal()
|
||||
}
|
||||
|
||||
@@ -43,5 +43,8 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -114,8 +114,8 @@ func (a *writer) Write(p []byte) (n int, err error) {
|
||||
|
||||
w := img.Bounds().Dx()
|
||||
h := img.Bounds().Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
if a.color != nil {
|
||||
a.color(uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
@@ -155,7 +155,7 @@ const x256b = "\x00\x00\x00\x00\x80\x80\x80\xc0\x80\x00\x00\x00\xff\xff\xff\xff\
|
||||
|
||||
func xterm256color(r, g, b uint8, n int) (index uint8) {
|
||||
best := uint16(0xFFFF)
|
||||
for i := 0; i < n; i++ {
|
||||
for i := range n {
|
||||
diff := sqDiff(r, x256r[i]) + sqDiff(g, x256g[i]) + sqDiff(b, x256b[i])
|
||||
if diff < best {
|
||||
best = diff
|
||||
|
||||
+7
-7
@@ -14,7 +14,7 @@ func NewReader(b []byte) *Reader {
|
||||
}
|
||||
|
||||
//goland:noinspection GoStandardMethods
|
||||
func (r *Reader) ReadByte() byte {
|
||||
func (r *Reader) ReadUint8() byte {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits8(8)
|
||||
}
|
||||
@@ -33,26 +33,26 @@ func (r *Reader) ReadUint16() uint16 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits16(16)
|
||||
}
|
||||
return uint16(r.ReadByte())<<8 | uint16(r.ReadByte())
|
||||
return uint16(r.ReadUint8())<<8 | uint16(r.ReadUint8())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint24() uint32 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits(24)
|
||||
}
|
||||
return uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte())
|
||||
return uint32(r.ReadUint8())<<16 | uint32(r.ReadUint8())<<8 | uint32(r.ReadUint8())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadUint32() uint32 {
|
||||
if r.bits != 0 {
|
||||
return r.ReadBits(32)
|
||||
}
|
||||
return uint32(r.ReadByte())<<24 | uint32(r.ReadByte())<<16 | uint32(r.ReadByte())<<8 | uint32(r.ReadByte())
|
||||
return uint32(r.ReadUint8())<<24 | uint32(r.ReadUint8())<<16 | uint32(r.ReadUint8())<<8 | uint32(r.ReadUint8())
|
||||
}
|
||||
|
||||
func (r *Reader) ReadBit() byte {
|
||||
if r.bits == 0 {
|
||||
r.byte = r.ReadByte()
|
||||
r.byte = r.ReadUint8()
|
||||
r.bits = 7
|
||||
} else {
|
||||
r.bits--
|
||||
@@ -106,8 +106,8 @@ func (r *Reader) ReadBytes(n int) (b []byte) {
|
||||
r.pos += n
|
||||
} else {
|
||||
b = make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
b[i] = r.ReadByte()
|
||||
for i := range n {
|
||||
b[i] = r.ReadUint8()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ func NewWriter(buf []byte) *Writer {
|
||||
}
|
||||
|
||||
//goland:noinspection GoStandardMethods
|
||||
func (w *Writer) WriteByte(b byte) {
|
||||
func (w *Writer) WriteUint8(b byte) {
|
||||
if w.bits != 0 {
|
||||
w.WriteBits8(b, 8)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (w *Writer) WriteBits8(v, n byte) {
|
||||
}
|
||||
|
||||
func (w *Writer) WriteAllBits(bit, n byte) {
|
||||
for i := byte(0); i < n; i++ {
|
||||
for range n {
|
||||
w.WriteBit(bit)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (w *Writer) WriteUint16(v uint16) {
|
||||
func (w *Writer) WriteBytes(bytes ...byte) {
|
||||
if w.bits != 0 {
|
||||
for _, b := range bytes {
|
||||
w.WriteByte(b)
|
||||
w.WriteUint8(b)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,17 +118,3 @@ func TestName(t *testing.T) {
|
||||
// stage3
|
||||
_ = prod2.Stop()
|
||||
}
|
||||
|
||||
func TestStripUserinfo(t *testing.T) {
|
||||
s := `streams:
|
||||
test:
|
||||
- ffmpeg:rtsp://username:password@10.1.2.3:554/stream1
|
||||
- ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy
|
||||
`
|
||||
s = StripUserinfo(s)
|
||||
require.Equal(t, `streams:
|
||||
test:
|
||||
- ffmpeg:rtsp://***@10.1.2.3:554/stream1
|
||||
- ffmpeg:rtsp://10.1.2.3:554/stream1@#video=copy
|
||||
`, s)
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func RandString(size, base byte) string {
|
||||
if base == 0 {
|
||||
return string(b)
|
||||
}
|
||||
for i := byte(0); i < size; i++ {
|
||||
for i := range size {
|
||||
b[i] = symbols[b[i]%base]
|
||||
}
|
||||
return string(b)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func TestTimeToRTP(t *testing.T) {
|
||||
// Video timestamp increases by 50ms, SampleRate 90000, RTP timestamp increases by 4500
|
||||
// Audio timestamp increases by 64ms, SampleRate 16000, RTP timestamp increases by 1024
|
||||
frameN := 1
|
||||
for i := 0; i < 32; i++ {
|
||||
for range 32 {
|
||||
// 1000ms/(90000/4500) = 50ms
|
||||
require.Equal(t, uint32(frameN*4500), TimeToRTP(uint32(frameN*50), 90000))
|
||||
// 1000ms/(16000/1024) = 64ms
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ func DecodeConfig(conf []byte) (profile []byte, sps []byte, pps []byte) {
|
||||
|
||||
count := conf[5] & 0x1F
|
||||
conf = conf[6:]
|
||||
for i := byte(0); i < count; i++ {
|
||||
for range count {
|
||||
if len(conf) < 2 {
|
||||
return
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func DecodeConfig(conf []byte) (profile []byte, sps []byte, pps []byte) {
|
||||
|
||||
count = conf[0]
|
||||
conf = conf[1:]
|
||||
for i := byte(0); i < count; i++ {
|
||||
for range count {
|
||||
if len(conf) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
+13
-13
@@ -92,15 +92,15 @@ func DecodeSPS(sps []byte) *SPS {
|
||||
// ffmpeg -i file.h264 -c copy -bsf:v trace_headers -f null -
|
||||
r := bits.NewReader(sps)
|
||||
|
||||
hdr := r.ReadByte()
|
||||
hdr := r.ReadUint8()
|
||||
if hdr&0x1F != NALUTypeSPS {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := &SPS{
|
||||
profile_idc: r.ReadByte(),
|
||||
profile_iop: r.ReadByte(),
|
||||
level_idc: r.ReadByte(),
|
||||
profile_idc: r.ReadUint8(),
|
||||
profile_iop: r.ReadUint8(),
|
||||
level_idc: r.ReadUint8(),
|
||||
seq_parameter_set_id: r.ReadUEGolomb(),
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func DecodeSPS(sps []byte) *SPS {
|
||||
|
||||
s.seq_scaling_matrix_present_flag = r.ReadBit()
|
||||
if s.seq_scaling_matrix_present_flag != 0 {
|
||||
for i := byte(0); i < n; i++ {
|
||||
for i := range n {
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
seq_scaling_list_present_flag := r.ReadBit()
|
||||
if seq_scaling_list_present_flag != 0 {
|
||||
@@ -176,7 +176,7 @@ func DecodeSPS(sps []byte) *SPS {
|
||||
if s.vui_parameters_present_flag != 0 {
|
||||
s.aspect_ratio_info_present_flag = r.ReadBit()
|
||||
if s.aspect_ratio_info_present_flag != 0 {
|
||||
s.aspect_ratio_idc = r.ReadByte()
|
||||
s.aspect_ratio_idc = r.ReadUint8()
|
||||
if s.aspect_ratio_idc == 255 {
|
||||
s.sar_width = r.ReadUint16()
|
||||
s.sar_height = r.ReadUint16()
|
||||
@@ -225,7 +225,7 @@ func DecodeSPS(sps []byte) *SPS {
|
||||
func (s *SPS) scaling_list(r *bits.Reader, sizeOfScalingList int) {
|
||||
lastScale := int32(8)
|
||||
nextScale := int32(8)
|
||||
for j := 0; j < sizeOfScalingList; j++ {
|
||||
for range sizeOfScalingList {
|
||||
if nextScale != 0 {
|
||||
delta_scale := r.ReadSEGolomb()
|
||||
nextScale = (lastScale + delta_scale + 256) % 256
|
||||
@@ -279,11 +279,11 @@ func (s *SPS) String() string {
|
||||
func FixPixFmt(sps []byte) {
|
||||
r := bits.NewReader(sps)
|
||||
|
||||
_ = r.ReadByte()
|
||||
_ = r.ReadUint8()
|
||||
|
||||
profile := r.ReadByte()
|
||||
_ = r.ReadByte()
|
||||
_ = r.ReadByte()
|
||||
profile := r.ReadUint8()
|
||||
_ = r.ReadUint8()
|
||||
_ = r.ReadUint8()
|
||||
_ = r.ReadUEGolomb()
|
||||
|
||||
switch profile {
|
||||
@@ -300,7 +300,7 @@ func FixPixFmt(sps []byte) {
|
||||
_ = r.ReadBit()
|
||||
|
||||
if r.ReadBit() != 0 {
|
||||
for i := byte(0); i < n; i++ {
|
||||
for range n {
|
||||
if r.ReadBit() != 0 {
|
||||
return // skip
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func FixPixFmt(sps []byte) {
|
||||
|
||||
if r.ReadBit() != 0 {
|
||||
if r.ReadBit() != 0 {
|
||||
if r.ReadByte() == 255 {
|
||||
if r.ReadUint8() == 255 {
|
||||
_ = r.ReadUint16()
|
||||
_ = r.ReadUint16()
|
||||
}
|
||||
|
||||
+2
-2
@@ -92,7 +92,7 @@ func (s *SPS) profile_tier_level(r *bits.Reader) bool {
|
||||
s.sub_layer_profile_present_flag = make([]byte, s.sps_max_sub_layers_minus1)
|
||||
s.sub_layer_level_present_flag = make([]byte, s.sps_max_sub_layers_minus1)
|
||||
|
||||
for i := byte(0); i < s.sps_max_sub_layers_minus1; i++ {
|
||||
for i := range s.sps_max_sub_layers_minus1 {
|
||||
s.sub_layer_profile_present_flag[i] = r.ReadBit()
|
||||
s.sub_layer_level_present_flag[i] = r.ReadBit()
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (s *SPS) profile_tier_level(r *bits.Reader) bool {
|
||||
}
|
||||
}
|
||||
|
||||
for i := byte(0); i < s.sps_max_sub_layers_minus1; i++ {
|
||||
for i := range s.sps_max_sub_layers_minus1 {
|
||||
if s.sub_layer_profile_present_flag[i] != 0 {
|
||||
_ = r.ReadBits8(2) // sub_layer_profile_space
|
||||
_ = r.ReadBit() // sub_layer_tier_flag
|
||||
|
||||
@@ -13,12 +13,85 @@ func NewAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
|
||||
ServiceCameraRTPStreamManagement(),
|
||||
//hap.ServiceHAPProtocolInformation(),
|
||||
ServiceMicrophone(),
|
||||
ServiceSpeaker(),
|
||||
},
|
||||
}
|
||||
acc.InitIID()
|
||||
return acc
|
||||
}
|
||||
|
||||
func NewHKSVAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
|
||||
rtpStream := ServiceCameraRTPStreamManagement()
|
||||
motionSensor := ServiceMotionSensor()
|
||||
operatingMode := ServiceCameraOperatingMode()
|
||||
recordingMgmt := ServiceCameraEventRecordingManagement()
|
||||
dataStreamMgmt := ServiceDataStreamManagement()
|
||||
|
||||
acc := &hap.Accessory{
|
||||
AID: hap.DeviceAID,
|
||||
Services: []*hap.Service{
|
||||
hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware),
|
||||
rtpStream,
|
||||
ServiceMicrophone(),
|
||||
ServiceSpeaker(),
|
||||
motionSensor,
|
||||
operatingMode,
|
||||
recordingMgmt,
|
||||
dataStreamMgmt,
|
||||
},
|
||||
}
|
||||
acc.InitIID()
|
||||
|
||||
// HAP-NodeJS: only RecordingManagement links to DataStreamManagement
|
||||
recordingMgmt.Linked = []int{int(dataStreamMgmt.IID)}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
func NewHKSVDoorbellAccessory(manuf, model, name, serial, firmware string) *hap.Accessory {
|
||||
rtpStream := ServiceCameraRTPStreamManagement()
|
||||
motionSensor := ServiceMotionSensor()
|
||||
operatingMode := ServiceCameraOperatingMode()
|
||||
recordingMgmt := ServiceCameraEventRecordingManagement()
|
||||
dataStreamMgmt := ServiceDataStreamManagement()
|
||||
doorbell := ServiceDoorbell()
|
||||
|
||||
acc := &hap.Accessory{
|
||||
AID: hap.DeviceAID,
|
||||
Services: []*hap.Service{
|
||||
hap.ServiceAccessoryInformation(manuf, model, name, serial, firmware),
|
||||
rtpStream,
|
||||
ServiceMicrophone(),
|
||||
ServiceSpeaker(),
|
||||
motionSensor,
|
||||
operatingMode,
|
||||
recordingMgmt,
|
||||
dataStreamMgmt,
|
||||
doorbell,
|
||||
},
|
||||
}
|
||||
acc.InitIID()
|
||||
|
||||
// HAP-NodeJS: only RecordingManagement links to DataStreamManagement
|
||||
recordingMgmt.Linked = []int{int(dataStreamMgmt.IID)}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
func ServiceSpeaker() *hap.Service {
|
||||
return &hap.Service{
|
||||
Type: "113", // 'Speaker'
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: "11A",
|
||||
Format: hap.FormatBool,
|
||||
Value: 0,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ServiceMicrophone() *hap.Service {
|
||||
return &hap.Service{
|
||||
Type: "112", // 'Microphone'
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNilCharacter(t *testing.T) {
|
||||
var res SetupEndpoints
|
||||
var res SetupEndpointsRequest
|
||||
char := &hap.Character{}
|
||||
err := char.ReadTLV8(&res)
|
||||
require.NotNil(t, err)
|
||||
|
||||
@@ -2,6 +2,19 @@ package camera
|
||||
|
||||
const TypeSupportedAudioRecordingConfiguration = "207"
|
||||
|
||||
//goland:noinspection ALL
|
||||
const (
|
||||
AudioRecordingCodecTypeAACELD = 2
|
||||
AudioRecordingCodecTypeAACLC = 3
|
||||
|
||||
AudioRecordingSampleRate8Khz = 0
|
||||
AudioRecordingSampleRate16Khz = 1
|
||||
AudioRecordingSampleRate24Khz = 2
|
||||
AudioRecordingSampleRate32Khz = 3
|
||||
AudioRecordingSampleRate44Khz = 4
|
||||
AudioRecordingSampleRate48Khz = 5
|
||||
)
|
||||
|
||||
type SupportedAudioRecordingConfiguration struct {
|
||||
CodecConfigs []AudioRecordingCodecConfiguration `tlv8:"1"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package camera
|
||||
|
||||
import (
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
)
|
||||
|
||||
func ServiceMotionSensor() *hap.Service {
|
||||
return &hap.Service{
|
||||
Type: "85",
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: "22",
|
||||
Format: hap.FormatBool,
|
||||
Value: false,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
{
|
||||
Type: "75",
|
||||
Format: hap.FormatBool,
|
||||
Value: true,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ServiceCameraOperatingMode() *hap.Service {
|
||||
return &hap.Service{
|
||||
Type: "21A",
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: "21B",
|
||||
Format: hap.FormatBool,
|
||||
Value: true,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
{
|
||||
Type: "223",
|
||||
Format: hap.FormatBool,
|
||||
Value: true,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
{
|
||||
Type: "225",
|
||||
Format: hap.FormatBool,
|
||||
Value: true,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ServiceCameraEventRecordingManagement() *hap.Service {
|
||||
val205, _ := tlv8.MarshalBase64(SupportedCameraRecordingConfiguration{
|
||||
PrebufferLength: 4000,
|
||||
EventTriggerOptions: 0x01, // motion
|
||||
MediaContainerConfigurations: MediaContainerConfigurations{
|
||||
MediaContainerType: 0, // fragmented MP4
|
||||
MediaContainerParameters: MediaContainerParameters{
|
||||
FragmentLength: 4000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
val206, _ := tlv8.MarshalBase64(SupportedVideoRecordingConfiguration{
|
||||
CodecConfigs: []VideoRecordingCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: VideoRecordingCodecParameters{
|
||||
ProfileID: VideoCodecProfileHigh,
|
||||
Level: VideoCodecLevel40,
|
||||
Bitrate: 2000,
|
||||
IFrameInterval: 4000,
|
||||
},
|
||||
CodecAttrs: VideoCodecAttributes{Width: 1920, Height: 1080, Framerate: 30},
|
||||
},
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: VideoRecordingCodecParameters{
|
||||
ProfileID: VideoCodecProfileMain,
|
||||
Level: VideoCodecLevel31,
|
||||
Bitrate: 1000,
|
||||
IFrameInterval: 4000,
|
||||
},
|
||||
CodecAttrs: VideoCodecAttributes{Width: 1280, Height: 720, Framerate: 30},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
val207, _ := tlv8.MarshalBase64(SupportedAudioRecordingConfiguration{
|
||||
CodecConfigs: []AudioRecordingCodecConfiguration{
|
||||
{
|
||||
CodecType: AudioRecordingCodecTypeAACLC,
|
||||
CodecParams: []AudioRecordingCodecParameters{
|
||||
{
|
||||
Channels: 1,
|
||||
BitrateMode: []byte{AudioCodecBitrateVariable},
|
||||
SampleRate: []byte{AudioRecordingSampleRate24Khz, AudioRecordingSampleRate32Khz, AudioRecordingSampleRate48Khz},
|
||||
MaxAudioBitrate: []uint32{64},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Default selected recording configuration (Home Hub expects this to persist)
|
||||
val209, _ := tlv8.MarshalBase64(SelectedCameraRecordingConfiguration{
|
||||
GeneralConfig: SupportedCameraRecordingConfiguration{
|
||||
PrebufferLength: 4000,
|
||||
EventTriggerOptions: 0x01, // motion
|
||||
MediaContainerConfigurations: MediaContainerConfigurations{
|
||||
MediaContainerType: 0,
|
||||
MediaContainerParameters: MediaContainerParameters{
|
||||
FragmentLength: 4000,
|
||||
},
|
||||
},
|
||||
},
|
||||
VideoConfig: SupportedVideoRecordingConfiguration{
|
||||
CodecConfigs: []VideoRecordingCodecConfiguration{
|
||||
{
|
||||
CodecType: VideoCodecTypeH264,
|
||||
CodecParams: VideoRecordingCodecParameters{
|
||||
ProfileID: VideoCodecProfileHigh,
|
||||
Level: VideoCodecLevel40,
|
||||
Bitrate: 2000,
|
||||
IFrameInterval: 4000,
|
||||
},
|
||||
CodecAttrs: VideoCodecAttributes{Width: 1920, Height: 1080, Framerate: 30},
|
||||
},
|
||||
},
|
||||
},
|
||||
AudioConfig: SupportedAudioRecordingConfiguration{
|
||||
CodecConfigs: []AudioRecordingCodecConfiguration{
|
||||
{
|
||||
CodecType: AudioRecordingCodecTypeAACLC,
|
||||
CodecParams: []AudioRecordingCodecParameters{
|
||||
{
|
||||
Channels: 1,
|
||||
BitrateMode: []byte{AudioCodecBitrateVariable},
|
||||
SampleRate: []byte{AudioRecordingSampleRate24Khz},
|
||||
MaxAudioBitrate: []uint32{64},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return &hap.Service{
|
||||
Type: "204",
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: "B0",
|
||||
Format: hap.FormatUInt8,
|
||||
Value: 0,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
{
|
||||
Type: TypeSupportedCameraRecordingConfiguration,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: val205,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
{
|
||||
Type: TypeSupportedVideoRecordingConfiguration,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: val206,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
{
|
||||
Type: TypeSupportedAudioRecordingConfiguration,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: val207,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
{
|
||||
Type: TypeSelectedCameraRecordingConfiguration,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: val209,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
{
|
||||
Type: "226",
|
||||
Format: hap.FormatUInt8,
|
||||
Value: 0,
|
||||
Perms: hap.EVPRPW,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ServiceDataStreamManagement() *hap.Service {
|
||||
val130, _ := tlv8.MarshalBase64(SupportedDataStreamTransportConfiguration{
|
||||
Configs: []TransferTransportConfiguration{
|
||||
{TransportType: 0}, // TCP
|
||||
},
|
||||
})
|
||||
|
||||
return &hap.Service{
|
||||
Type: "129",
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: TypeSupportedDataStreamTransportConfiguration,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: val130,
|
||||
Perms: hap.PR,
|
||||
},
|
||||
{
|
||||
Type: TypeSetupDataStreamTransport,
|
||||
Format: hap.FormatTLV8,
|
||||
Value: "",
|
||||
Perms: []string{"pr", "pw", "wr"},
|
||||
},
|
||||
{
|
||||
Type: "37",
|
||||
Format: hap.FormatString,
|
||||
Value: "1.0",
|
||||
Perms: hap.PR,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ServiceDoorbell() *hap.Service {
|
||||
return &hap.Service{
|
||||
Type: "121",
|
||||
Characters: []*hap.Character{
|
||||
{
|
||||
Type: "73",
|
||||
Format: hap.FormatUInt8,
|
||||
Value: nil,
|
||||
Perms: hap.EVPR,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ func (c *Character) RemoveListener(w io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Character) ListenerCount() int {
|
||||
return len(c.listeners)
|
||||
}
|
||||
|
||||
func (c *Character) NotifyListeners(ignore io.Writer) error {
|
||||
if c.listeners == nil {
|
||||
return nil
|
||||
|
||||
+12
-12
@@ -1,8 +1,7 @@
|
||||
package hds
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
@@ -13,22 +12,23 @@ func TestEncryption(t *testing.T) {
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
|
||||
c, err := Client(nil, key, salt, true)
|
||||
c1, c2 := net.Pipe()
|
||||
t.Cleanup(func() { c1.Close(); c2.Close() })
|
||||
|
||||
writer, err := NewConn(c1, key, salt, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
c.wr = bufio.NewWriter(buf)
|
||||
|
||||
n, err := c.Write([]byte("test"))
|
||||
reader, err := NewConn(c2, key, salt, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, n)
|
||||
|
||||
c, err = Client(nil, key, salt, false)
|
||||
c.rd = bufio.NewReader(buf)
|
||||
require.NoError(t, err)
|
||||
go func() {
|
||||
n, err := writer.Write([]byte("test"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, n)
|
||||
}()
|
||||
|
||||
b := make([]byte, 32)
|
||||
n, err = c.Read(b)
|
||||
n, err := reader.Read(b)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "test", string(b[:n]))
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package hds
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
// opack tags
|
||||
const (
|
||||
opackTrue = 0x01
|
||||
opackFalse = 0x02
|
||||
opackTerminator = 0x03
|
||||
opackNull = 0x04
|
||||
opackIntNeg1 = 0x07
|
||||
opackSmallInt0 = 0x08 // 0x08-0x2F = integers 0-39
|
||||
opackSmallInt39 = 0x2F
|
||||
opackInt8 = 0x30
|
||||
opackInt16 = 0x31
|
||||
opackInt32 = 0x32
|
||||
opackInt64 = 0x33
|
||||
opackFloat32 = 0x35
|
||||
opackFloat64 = 0x36
|
||||
opackStr0 = 0x40 // 0x40-0x60 = inline string, length 0-32
|
||||
opackStr32 = 0x60
|
||||
opackStrLen1 = 0x61
|
||||
opackStrLen2 = 0x62
|
||||
opackStrLen4 = 0x63
|
||||
opackStrLen8 = 0x64
|
||||
opackData0 = 0x70 // 0x70-0x90 = inline data, length 0-32
|
||||
opackData32 = 0x90
|
||||
opackDataLen1 = 0x91
|
||||
opackDataLen2 = 0x92
|
||||
opackDataLen4 = 0x93
|
||||
opackDataLen8 = 0x94
|
||||
opackArr0 = 0xD0 // 0xD0-0xDE = counted array, 0-14 elements
|
||||
opackArr14 = 0xDE
|
||||
opackArrTerm = 0xDF // terminated array
|
||||
opackDict0 = 0xE0 // 0xE0-0xEE = counted dict, 0-14 pairs
|
||||
opackDict14 = 0xEE
|
||||
opackDictTerm = 0xEF // terminated dict
|
||||
)
|
||||
|
||||
func OpackMarshal(v any) []byte {
|
||||
var buf []byte
|
||||
return opackEncode(buf, v)
|
||||
}
|
||||
|
||||
func OpackUnmarshal(data []byte) (any, error) {
|
||||
v, _, err := opackDecode(data)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func opackEncode(buf []byte, v any) []byte {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return append(buf, opackNull)
|
||||
case bool:
|
||||
if v {
|
||||
return append(buf, opackTrue)
|
||||
}
|
||||
return append(buf, opackFalse)
|
||||
case int:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case int8:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case int16:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case int32:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case int64:
|
||||
return opackEncodeInt(buf, v)
|
||||
case uint:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case uint8:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case uint16:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case uint32:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case uint64:
|
||||
return opackEncodeInt(buf, int64(v))
|
||||
case float32:
|
||||
buf = append(buf, opackFloat32)
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, math.Float32bits(v))
|
||||
return append(buf, b...)
|
||||
case float64:
|
||||
buf = append(buf, opackFloat64)
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(b, math.Float64bits(v))
|
||||
return append(buf, b...)
|
||||
case string:
|
||||
return opackEncodeString(buf, v)
|
||||
case []byte:
|
||||
return opackEncodeData(buf, v)
|
||||
case []any:
|
||||
return opackEncodeArray(buf, v)
|
||||
case map[string]any:
|
||||
return opackEncodeDict(buf, v)
|
||||
default:
|
||||
return append(buf, opackNull)
|
||||
}
|
||||
}
|
||||
|
||||
func opackEncodeInt(buf []byte, v int64) []byte {
|
||||
if v == -1 {
|
||||
return append(buf, opackIntNeg1)
|
||||
}
|
||||
if v >= 0 && v <= 39 {
|
||||
return append(buf, byte(opackSmallInt0+v))
|
||||
}
|
||||
if v >= -128 && v <= 127 {
|
||||
return append(buf, opackInt8, byte(v))
|
||||
}
|
||||
if v >= -32768 && v <= 32767 {
|
||||
buf = append(buf, opackInt16)
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, uint16(v))
|
||||
return append(buf, b...)
|
||||
}
|
||||
if v >= -2147483648 && v <= 2147483647 {
|
||||
buf = append(buf, opackInt32)
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, uint32(v))
|
||||
return append(buf, b...)
|
||||
}
|
||||
buf = append(buf, opackInt64)
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(b, uint64(v))
|
||||
return append(buf, b...)
|
||||
}
|
||||
|
||||
func opackEncodeString(buf []byte, s string) []byte {
|
||||
n := len(s)
|
||||
if n <= 32 {
|
||||
buf = append(buf, byte(opackStr0+n))
|
||||
} else if n <= 0xFF {
|
||||
buf = append(buf, opackStrLen1, byte(n))
|
||||
} else if n <= 0xFFFF {
|
||||
buf = append(buf, opackStrLen2)
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, uint16(n))
|
||||
buf = append(buf, b...)
|
||||
} else {
|
||||
buf = append(buf, opackStrLen4)
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, uint32(n))
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
return append(buf, s...)
|
||||
}
|
||||
|
||||
func opackEncodeData(buf []byte, data []byte) []byte {
|
||||
n := len(data)
|
||||
if n <= 32 {
|
||||
buf = append(buf, byte(opackData0+n))
|
||||
} else if n <= 0xFF {
|
||||
buf = append(buf, opackDataLen1, byte(n))
|
||||
} else if n <= 0xFFFF {
|
||||
buf = append(buf, opackDataLen2)
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, uint16(n))
|
||||
buf = append(buf, b...)
|
||||
} else {
|
||||
buf = append(buf, opackDataLen4)
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, uint32(n))
|
||||
buf = append(buf, b...)
|
||||
}
|
||||
return append(buf, data...)
|
||||
}
|
||||
|
||||
func opackEncodeArray(buf []byte, arr []any) []byte {
|
||||
n := len(arr)
|
||||
if n <= 14 {
|
||||
buf = append(buf, byte(opackArr0+n))
|
||||
} else {
|
||||
buf = append(buf, opackArrTerm)
|
||||
}
|
||||
for _, v := range arr {
|
||||
buf = opackEncode(buf, v)
|
||||
}
|
||||
if n > 14 {
|
||||
buf = append(buf, opackTerminator)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func opackEncodeDict(buf []byte, dict map[string]any) []byte {
|
||||
n := len(dict)
|
||||
if n <= 14 {
|
||||
buf = append(buf, byte(opackDict0+n))
|
||||
} else {
|
||||
buf = append(buf, opackDictTerm)
|
||||
}
|
||||
for k, v := range dict {
|
||||
buf = opackEncodeString(buf, k)
|
||||
buf = opackEncode(buf, v)
|
||||
}
|
||||
if n > 14 {
|
||||
buf = append(buf, opackTerminator)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
var errOpackTruncated = errors.New("opack: truncated data")
|
||||
var errOpackInvalidTag = errors.New("opack: invalid tag")
|
||||
|
||||
func opackDecode(data []byte) (any, int, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
|
||||
tag := data[0]
|
||||
off := 1
|
||||
|
||||
switch {
|
||||
case tag == opackNull:
|
||||
return nil, off, nil
|
||||
case tag == opackTrue:
|
||||
return true, off, nil
|
||||
case tag == opackFalse:
|
||||
return false, off, nil
|
||||
case tag == opackTerminator:
|
||||
return nil, off, nil
|
||||
case tag == opackIntNeg1:
|
||||
return int64(-1), off, nil
|
||||
case tag >= opackSmallInt0 && tag <= opackSmallInt39:
|
||||
return int64(tag - opackSmallInt0), off, nil
|
||||
case tag == opackInt8:
|
||||
if len(data) < 2 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
return int64(int8(data[1])), 2, nil
|
||||
case tag == opackInt16:
|
||||
if len(data) < 3 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v := int16(binary.LittleEndian.Uint16(data[1:3]))
|
||||
return int64(v), 3, nil
|
||||
case tag == opackInt32:
|
||||
if len(data) < 5 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v := int32(binary.LittleEndian.Uint32(data[1:5]))
|
||||
return int64(v), 5, nil
|
||||
case tag == opackInt64:
|
||||
if len(data) < 9 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v := int64(binary.LittleEndian.Uint64(data[1:9]))
|
||||
return int64(v), 9, nil
|
||||
case tag == opackFloat32:
|
||||
if len(data) < 5 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v := math.Float32frombits(binary.LittleEndian.Uint32(data[1:5]))
|
||||
return float64(v), 5, nil
|
||||
case tag == opackFloat64:
|
||||
if len(data) < 9 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v := math.Float64frombits(binary.LittleEndian.Uint64(data[1:9]))
|
||||
return v, 9, nil
|
||||
|
||||
// Inline string (0-32 bytes)
|
||||
case tag >= opackStr0 && tag <= opackStr32:
|
||||
n := int(tag - opackStr0)
|
||||
if len(data) < off+n {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
return string(data[off : off+n]), off + n, nil
|
||||
|
||||
// String with length prefix
|
||||
case tag >= opackStrLen1 && tag <= opackStrLen4:
|
||||
n, sz := opackReadLen(data[off:], tag-opackStrLen1+1)
|
||||
if sz == 0 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
off += sz
|
||||
if len(data) < off+n {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
return string(data[off : off+n]), off + n, nil
|
||||
|
||||
// Inline data (0-32 bytes)
|
||||
case tag >= opackData0 && tag <= opackData32:
|
||||
n := int(tag - opackData0)
|
||||
if len(data) < off+n {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
b := make([]byte, n)
|
||||
copy(b, data[off:off+n])
|
||||
return b, off + n, nil
|
||||
|
||||
// Data with length prefix
|
||||
case tag >= opackDataLen1 && tag <= opackDataLen4:
|
||||
n, sz := opackReadLen(data[off:], tag-opackDataLen1+1)
|
||||
if sz == 0 {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
off += sz
|
||||
if len(data) < off+n {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
b := make([]byte, n)
|
||||
copy(b, data[off:off+n])
|
||||
return b, off + n, nil
|
||||
|
||||
// Counted array (0-14)
|
||||
case tag >= opackArr0 && tag <= opackArr14:
|
||||
count := int(tag - opackArr0)
|
||||
return opackDecodeArray(data[off:], count, false)
|
||||
|
||||
// Terminated array
|
||||
case tag == opackArrTerm:
|
||||
return opackDecodeArray(data[off:], 0, true)
|
||||
|
||||
// Counted dict (0-14)
|
||||
case tag >= opackDict0 && tag <= opackDict14:
|
||||
count := int(tag - opackDict0)
|
||||
return opackDecodeDict(data[off:], count, false)
|
||||
|
||||
// Terminated dict
|
||||
case tag == opackDictTerm:
|
||||
return opackDecodeDict(data[off:], 0, true)
|
||||
}
|
||||
|
||||
return nil, 0, errOpackInvalidTag
|
||||
}
|
||||
|
||||
// opackReadLen reads a length from data using the given byte count (1=1byte, 2=2bytes, 3=4bytes, 4=8bytes)
|
||||
func opackReadLen(data []byte, lenBytes byte) (int, int) {
|
||||
switch lenBytes {
|
||||
case 1:
|
||||
if len(data) < 1 {
|
||||
return 0, 0
|
||||
}
|
||||
return int(data[0]), 1
|
||||
case 2:
|
||||
if len(data) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
return int(binary.LittleEndian.Uint16(data[:2])), 2
|
||||
case 3: // 4-byte length (tag offset 3 = 4 bytes)
|
||||
if len(data) < 4 {
|
||||
return 0, 0
|
||||
}
|
||||
return int(binary.LittleEndian.Uint32(data[:4])), 4
|
||||
case 4: // 8-byte length
|
||||
if len(data) < 8 {
|
||||
return 0, 0
|
||||
}
|
||||
return int(binary.LittleEndian.Uint64(data[:8])), 8
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func opackDecodeArray(data []byte, count int, terminated bool) ([]any, int, error) {
|
||||
var arr []any
|
||||
off := 0
|
||||
for i := 0; terminated || i < count; i++ {
|
||||
if off >= len(data) {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
if terminated && data[off] == opackTerminator {
|
||||
off++
|
||||
break
|
||||
}
|
||||
v, n, err := opackDecode(data[off:])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
off += n
|
||||
}
|
||||
return arr, off + 1, nil // +1 for outer tag
|
||||
}
|
||||
|
||||
func opackDecodeDict(data []byte, count int, terminated bool) (map[string]any, int, error) {
|
||||
dict := make(map[string]any)
|
||||
off := 0
|
||||
for i := 0; terminated || i < count; i++ {
|
||||
if off >= len(data) {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
if terminated && data[off] == opackTerminator {
|
||||
off++
|
||||
break
|
||||
}
|
||||
// key
|
||||
k, n, err := opackDecode(data[off:])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
off += n
|
||||
|
||||
key, ok := k.(string)
|
||||
if !ok {
|
||||
return nil, 0, errors.New("opack: dict key is not string")
|
||||
}
|
||||
|
||||
// value
|
||||
if off >= len(data) {
|
||||
return nil, 0, errOpackTruncated
|
||||
}
|
||||
v, n2, err := opackDecode(data[off:])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
off += n2
|
||||
dict[key] = v
|
||||
}
|
||||
return dict, off + 1, nil // +1 for outer tag
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package hds
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HDS message types
|
||||
const (
|
||||
ProtoDataSend = "dataSend"
|
||||
ProtoControl = "control"
|
||||
|
||||
TopicOpen = "open"
|
||||
TopicData = "data"
|
||||
TopicClose = "close"
|
||||
TopicAck = "ack"
|
||||
TopicHello = "hello"
|
||||
|
||||
StatusSuccess = 0
|
||||
)
|
||||
|
||||
// Message represents an HDS application-level message
|
||||
type Message struct {
|
||||
Protocol string
|
||||
Topic string
|
||||
ID int64
|
||||
IsEvent bool
|
||||
Status int64
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// Session wraps an HDS encrypted connection with application-level protocol handling.
|
||||
// HDS messages format: [1 byte header_length][opack header dict][opack message dict]
|
||||
type Session struct {
|
||||
conn *Conn
|
||||
mu sync.Mutex
|
||||
id int64
|
||||
|
||||
OnDataSendOpen func(streamID int) error
|
||||
OnDataSendClose func(streamID int) error
|
||||
}
|
||||
|
||||
func NewSession(conn *Conn) *Session {
|
||||
return &Session{conn: conn}
|
||||
}
|
||||
|
||||
// ReadMessage reads and decodes an HDS application message
|
||||
func (s *Session) ReadMessage() (*Message, error) {
|
||||
buf := make([]byte, 64*1024)
|
||||
n, err := s.conn.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := buf[:n]
|
||||
|
||||
if len(data) < 2 {
|
||||
return nil, errors.New("hds: message too short")
|
||||
}
|
||||
|
||||
headerLen := int(data[0])
|
||||
if len(data) < 1+headerLen {
|
||||
return nil, errors.New("hds: header truncated")
|
||||
}
|
||||
|
||||
headerData := data[1 : 1+headerLen]
|
||||
bodyData := data[1+headerLen:]
|
||||
|
||||
headerVal, err := OpackUnmarshal(headerData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header, ok := headerVal.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("hds: header is not dict")
|
||||
}
|
||||
|
||||
msg := &Message{
|
||||
Protocol: opackString(header["protocol"]),
|
||||
}
|
||||
|
||||
if topic, ok := header["event"]; ok {
|
||||
msg.IsEvent = true
|
||||
msg.Topic = opackString(topic)
|
||||
} else if topic, ok := header["request"]; ok {
|
||||
msg.Topic = opackString(topic)
|
||||
msg.ID = opackInt(header["id"])
|
||||
} else if topic, ok := header["response"]; ok {
|
||||
msg.Topic = opackString(topic)
|
||||
msg.ID = opackInt(header["id"])
|
||||
msg.Status = opackInt(header["status"])
|
||||
}
|
||||
|
||||
if len(bodyData) > 0 {
|
||||
bodyVal, err := OpackUnmarshal(bodyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m, ok := bodyVal.(map[string]any); ok {
|
||||
msg.Body = m
|
||||
}
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// WriteMessage sends an HDS application message
|
||||
func (s *Session) WriteMessage(header, body map[string]any) error {
|
||||
headerBytes := OpackMarshal(header)
|
||||
bodyBytes := OpackMarshal(body)
|
||||
|
||||
msg := make([]byte, 0, 1+len(headerBytes)+len(bodyBytes))
|
||||
msg = append(msg, byte(len(headerBytes)))
|
||||
msg = append(msg, headerBytes...)
|
||||
msg = append(msg, bodyBytes...)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.conn.Write(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteResponse sends a response to a request
|
||||
func (s *Session) WriteResponse(protocol, topic string, id int64, status int, body map[string]any) error {
|
||||
header := map[string]any{
|
||||
"protocol": protocol,
|
||||
"response": topic,
|
||||
"id": id,
|
||||
"status": status,
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return s.WriteMessage(header, body)
|
||||
}
|
||||
|
||||
// WriteEvent sends an unsolicited event
|
||||
func (s *Session) WriteEvent(protocol, topic string, body map[string]any) error {
|
||||
header := map[string]any{
|
||||
"protocol": protocol,
|
||||
"event": topic,
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return s.WriteMessage(header, body)
|
||||
}
|
||||
|
||||
// WriteRequest sends a request
|
||||
func (s *Session) WriteRequest(protocol, topic string, body map[string]any) (int64, error) {
|
||||
s.mu.Lock()
|
||||
s.id++
|
||||
id := s.id
|
||||
s.mu.Unlock()
|
||||
|
||||
header := map[string]any{
|
||||
"protocol": protocol,
|
||||
"request": topic,
|
||||
"id": id,
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
return id, s.WriteMessage(header, body)
|
||||
}
|
||||
|
||||
// maxChunkSize is the maximum data chunk size for HDS media transfer (256 KiB)
|
||||
const maxChunkSize = 0x40000
|
||||
|
||||
// SendMediaInit sends the fMP4 initialization segment (ftyp+moov)
|
||||
func (s *Session) SendMediaInit(streamID int, initData []byte) error {
|
||||
return s.sendMediaData(streamID, "mediaInitialization", initData, 1)
|
||||
}
|
||||
|
||||
// SendMediaFragment sends an fMP4 fragment (moof+mdat), splitting into chunks if needed
|
||||
func (s *Session) SendMediaFragment(streamID int, fragment []byte, sequence int) error {
|
||||
return s.sendMediaData(streamID, "mediaFragment", fragment, sequence)
|
||||
}
|
||||
|
||||
// sendMediaData sends media data with proper HAP-NodeJS compatible packet structure.
|
||||
// Large data is split into chunks of maxChunkSize bytes.
|
||||
func (s *Session) sendMediaData(streamID int, dataType string, data []byte, sequence int) error {
|
||||
totalSize := len(data)
|
||||
chunkSeq := 1
|
||||
|
||||
for offset := 0; offset < totalSize; offset += maxChunkSize {
|
||||
end := offset + maxChunkSize
|
||||
if end > totalSize {
|
||||
end = totalSize
|
||||
}
|
||||
chunk := data[offset:end]
|
||||
isLast := end >= totalSize
|
||||
|
||||
metadata := map[string]any{
|
||||
"dataType": dataType,
|
||||
"dataSequenceNumber": sequence,
|
||||
"dataChunkSequenceNumber": chunkSeq,
|
||||
"isLastDataChunk": isLast,
|
||||
}
|
||||
if chunkSeq == 1 {
|
||||
metadata["dataTotalSize"] = totalSize
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"streamId": streamID,
|
||||
"packets": []any{
|
||||
map[string]any{
|
||||
"data": chunk,
|
||||
"metadata": metadata,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.WriteEvent(ProtoDataSend, TopicData, body); err != nil {
|
||||
return err
|
||||
}
|
||||
chunkSeq++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run processes incoming HDS messages in a loop
|
||||
func (s *Session) Run() error {
|
||||
// Handle control/hello handshake
|
||||
msg, err := s.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg.Protocol == ProtoControl && msg.Topic == TopicHello {
|
||||
if err := s.WriteResponse(ProtoControl, TopicHello, msg.ID, StatusSuccess, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Main message loop
|
||||
for {
|
||||
msg, err := s.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msg.Protocol != ProtoDataSend {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Topic {
|
||||
case TopicOpen:
|
||||
streamID := int(opackInt(msg.Body["streamId"]))
|
||||
// Acknowledge the open request
|
||||
if err := s.WriteResponse(ProtoDataSend, TopicOpen, msg.ID, StatusSuccess, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.OnDataSendOpen != nil {
|
||||
if err := s.OnDataSendOpen(streamID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case TopicClose:
|
||||
streamID := int(opackInt(msg.Body["streamId"]))
|
||||
// Acknowledge the close request
|
||||
if err := s.WriteResponse(ProtoDataSend, TopicClose, msg.ID, StatusSuccess, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.OnDataSendClose != nil {
|
||||
if err := s.OnDataSendClose(streamID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
case TopicAck:
|
||||
// Acknowledgement from controller, nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Close() error {
|
||||
return s.conn.Close()
|
||||
}
|
||||
|
||||
func opackString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opackInt(v any) int64 {
|
||||
switch v := v.(type) {
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case float64:
|
||||
return int64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package hds
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newSessionPair creates a connected accessory/controller session pair for testing.
|
||||
func newSessionPair(t *testing.T) (accessory *Session, controller *Session) {
|
||||
t.Helper()
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
|
||||
c1, c2 := net.Pipe()
|
||||
t.Cleanup(func() { c1.Close(); c2.Close() })
|
||||
|
||||
accConn, err := NewConn(c1, key, salt, false) // accessory
|
||||
require.NoError(t, err)
|
||||
ctrlConn, err := NewConn(c2, key, salt, true) // controller
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewSession(accConn), NewSession(ctrlConn)
|
||||
}
|
||||
|
||||
// readLargeMsg reads a message using a large buffer (for messages with 256KB+ chunks).
|
||||
// Session.ReadMessage uses 64KB which is too small for media chunks in tests.
|
||||
func readLargeMsg(t *testing.T, s *Session) *Message {
|
||||
t.Helper()
|
||||
buf := make([]byte, 512*1024) // 512KB
|
||||
n, err := s.conn.Read(buf)
|
||||
require.NoError(t, err)
|
||||
data := buf[:n]
|
||||
|
||||
require.GreaterOrEqual(t, len(data), 2)
|
||||
headerLen := int(data[0])
|
||||
require.GreaterOrEqual(t, len(data), 1+headerLen)
|
||||
|
||||
headerVal, err := OpackUnmarshal(data[1 : 1+headerLen])
|
||||
require.NoError(t, err)
|
||||
header := headerVal.(map[string]any)
|
||||
|
||||
msg := &Message{Protocol: opackString(header["protocol"])}
|
||||
if topic, ok := header["event"]; ok {
|
||||
msg.IsEvent = true
|
||||
msg.Topic = opackString(topic)
|
||||
} else if topic, ok := header["response"]; ok {
|
||||
msg.Topic = opackString(topic)
|
||||
msg.ID = opackInt(header["id"])
|
||||
msg.Status = opackInt(header["status"])
|
||||
} else if topic, ok := header["request"]; ok {
|
||||
msg.Topic = opackString(topic)
|
||||
msg.ID = opackInt(header["id"])
|
||||
}
|
||||
|
||||
bodyData := data[1+headerLen:]
|
||||
if len(bodyData) > 0 {
|
||||
bodyVal, err := OpackUnmarshal(bodyData)
|
||||
require.NoError(t, err)
|
||||
if m, ok := bodyVal.(map[string]any); ok {
|
||||
msg.Body = m
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// extractPacket extracts data and metadata from a dataSend.data message body.
|
||||
func extractPacket(t *testing.T, body map[string]any) (data []byte, metadata map[string]any) {
|
||||
t.Helper()
|
||||
packets, ok := body["packets"].([]any)
|
||||
require.True(t, ok, "packets must be array")
|
||||
require.Len(t, packets, 1)
|
||||
|
||||
pkt, ok := packets[0].(map[string]any)
|
||||
require.True(t, ok, "packet element must be dict")
|
||||
|
||||
data, ok = pkt["data"].([]byte)
|
||||
require.True(t, ok, "data must be []byte")
|
||||
|
||||
metadata, ok = pkt["metadata"].(map[string]any)
|
||||
require.True(t, ok, "metadata must be dict")
|
||||
return
|
||||
}
|
||||
|
||||
// --- SendMediaInit tests ---
|
||||
|
||||
func TestSendMediaInit_Structure(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
initData := bytes.Repeat([]byte{0xAB}, 100)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaInit(1, initData))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, ProtoDataSend, msg.Protocol)
|
||||
require.Equal(t, TopicData, msg.Topic)
|
||||
require.True(t, msg.IsEvent)
|
||||
require.Equal(t, int64(1), opackInt(msg.Body["streamId"]))
|
||||
|
||||
data, meta := extractPacket(t, msg.Body)
|
||||
require.Equal(t, initData, data)
|
||||
require.Equal(t, "mediaInitialization", opackString(meta["dataType"]))
|
||||
require.Equal(t, int64(1), opackInt(meta["dataSequenceNumber"]))
|
||||
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, true, meta["isLastDataChunk"])
|
||||
require.Equal(t, int64(len(initData)), opackInt(meta["dataTotalSize"]))
|
||||
}
|
||||
|
||||
func TestSendMediaInit_AlwaysSeqOne(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaInit(42, []byte{1, 2, 3}))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, meta := extractPacket(t, msg.Body)
|
||||
require.Equal(t, int64(1), opackInt(meta["dataSequenceNumber"]))
|
||||
require.Equal(t, int64(42), opackInt(msg.Body["streamId"]))
|
||||
}
|
||||
|
||||
// --- SendMediaFragment single chunk tests ---
|
||||
|
||||
func TestSendMediaFragment_SingleChunk(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
fragment := bytes.Repeat([]byte{0xCD}, 1000) // well under 256KB
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaFragment(5, fragment, 3))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
data, meta := extractPacket(t, msg.Body)
|
||||
require.Equal(t, fragment, data)
|
||||
require.Equal(t, "mediaFragment", opackString(meta["dataType"]))
|
||||
require.Equal(t, int64(3), opackInt(meta["dataSequenceNumber"]))
|
||||
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, true, meta["isLastDataChunk"])
|
||||
require.Equal(t, int64(1000), opackInt(meta["dataTotalSize"]))
|
||||
}
|
||||
|
||||
// --- SendMediaFragment multi-chunk tests (using readLargeMsg) ---
|
||||
|
||||
func TestSendMediaFragment_MultipleChunks(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
totalSize := maxChunkSize*2 + 100 // 2 full chunks + partial
|
||||
fragment := make([]byte, totalSize)
|
||||
for i := range fragment {
|
||||
fragment[i] = byte(i % 251) // use prime to verify no data corruption
|
||||
}
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaFragment(1, fragment, 7))
|
||||
}()
|
||||
|
||||
var assembled []byte
|
||||
|
||||
// Chunk 1: full 256KB
|
||||
msg1 := readLargeMsg(t, ctrl)
|
||||
data1, meta1 := extractPacket(t, msg1.Body)
|
||||
require.Len(t, data1, maxChunkSize)
|
||||
require.Equal(t, int64(1), opackInt(meta1["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, false, meta1["isLastDataChunk"])
|
||||
require.Equal(t, int64(totalSize), opackInt(meta1["dataTotalSize"]))
|
||||
require.Equal(t, int64(7), opackInt(meta1["dataSequenceNumber"]))
|
||||
assembled = append(assembled, data1...)
|
||||
|
||||
// Chunk 2: full 256KB
|
||||
msg2 := readLargeMsg(t, ctrl)
|
||||
data2, meta2 := extractPacket(t, msg2.Body)
|
||||
require.Len(t, data2, maxChunkSize)
|
||||
require.Equal(t, int64(2), opackInt(meta2["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, false, meta2["isLastDataChunk"])
|
||||
// dataTotalSize only in first chunk
|
||||
_, hasTotalSize := meta2["dataTotalSize"]
|
||||
require.False(t, hasTotalSize, "dataTotalSize should only be in first chunk")
|
||||
assembled = append(assembled, data2...)
|
||||
|
||||
// Chunk 3: remaining 100 bytes
|
||||
msg3 := readLargeMsg(t, ctrl)
|
||||
data3, meta3 := extractPacket(t, msg3.Body)
|
||||
require.Len(t, data3, 100)
|
||||
require.Equal(t, int64(3), opackInt(meta3["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, true, meta3["isLastDataChunk"])
|
||||
assembled = append(assembled, data3...)
|
||||
|
||||
require.Equal(t, fragment, assembled, "reassembled data must match original")
|
||||
}
|
||||
|
||||
func TestSendMediaFragment_ExactChunkBoundary(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
fragment := bytes.Repeat([]byte{0xAA}, maxChunkSize) // exactly 256KB
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaFragment(1, fragment, 2))
|
||||
}()
|
||||
|
||||
msg := readLargeMsg(t, ctrl)
|
||||
data, meta := extractPacket(t, msg.Body)
|
||||
require.Len(t, data, maxChunkSize)
|
||||
require.Equal(t, int64(1), opackInt(meta["dataChunkSequenceNumber"]))
|
||||
require.Equal(t, true, meta["isLastDataChunk"]) // single chunk
|
||||
}
|
||||
|
||||
func TestSendMediaFragment_TwoExactChunks(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
fragment := bytes.Repeat([]byte{0xBB}, maxChunkSize*2) // exactly 2 chunks
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaFragment(1, fragment, 4))
|
||||
}()
|
||||
|
||||
msg1 := readLargeMsg(t, ctrl)
|
||||
_, meta1 := extractPacket(t, msg1.Body)
|
||||
require.Equal(t, false, meta1["isLastDataChunk"])
|
||||
require.Equal(t, int64(1), opackInt(meta1["dataChunkSequenceNumber"]))
|
||||
|
||||
msg2 := readLargeMsg(t, ctrl)
|
||||
_, meta2 := extractPacket(t, msg2.Body)
|
||||
require.Equal(t, true, meta2["isLastDataChunk"])
|
||||
require.Equal(t, int64(2), opackInt(meta2["dataChunkSequenceNumber"]))
|
||||
}
|
||||
|
||||
func TestSendMediaFragment_SequencePreserved(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
// All chunks of a multi-chunk fragment share the same dataSequenceNumber
|
||||
totalSize := maxChunkSize + 50
|
||||
fragment := bytes.Repeat([]byte{0x11}, totalSize)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.SendMediaFragment(1, fragment, 42))
|
||||
}()
|
||||
|
||||
msg1 := readLargeMsg(t, ctrl)
|
||||
_, meta1 := extractPacket(t, msg1.Body)
|
||||
require.Equal(t, int64(42), opackInt(meta1["dataSequenceNumber"]))
|
||||
|
||||
msg2, err := ctrl.ReadMessage() // second chunk is small (50 bytes)
|
||||
require.NoError(t, err)
|
||||
_, meta2 := extractPacket(t, msg2.Body)
|
||||
require.Equal(t, int64(42), opackInt(meta2["dataSequenceNumber"]))
|
||||
}
|
||||
|
||||
// --- WriteEvent / WriteResponse / WriteRequest round-trip tests ---
|
||||
|
||||
func TestWriteEvent_ReadMessage(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.WriteEvent("testProto", "testTopic", map[string]any{
|
||||
"key": "value",
|
||||
}))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "testProto", msg.Protocol)
|
||||
require.Equal(t, "testTopic", msg.Topic)
|
||||
require.True(t, msg.IsEvent)
|
||||
require.Equal(t, "value", msg.Body["key"])
|
||||
}
|
||||
|
||||
func TestWriteResponse_ReadMessage(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.WriteResponse("proto", "topic", 5, 0, map[string]any{"ok": true}))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "proto", msg.Protocol)
|
||||
require.Equal(t, "topic", msg.Topic)
|
||||
require.Equal(t, int64(5), msg.ID)
|
||||
require.Equal(t, int64(0), msg.Status)
|
||||
require.False(t, msg.IsEvent)
|
||||
require.Equal(t, true, msg.Body["ok"])
|
||||
}
|
||||
|
||||
func TestWriteRequest_ReadMessage(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
id, err := acc.WriteRequest("proto", "topic", map[string]any{"x": int64(10)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), id) // first request
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "proto", msg.Protocol)
|
||||
require.Equal(t, "topic", msg.Topic)
|
||||
require.Equal(t, int64(1), msg.ID)
|
||||
require.False(t, msg.IsEvent)
|
||||
}
|
||||
|
||||
func TestWriteRequest_IncrementingIDs(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
id1, _ := acc.WriteRequest("p", "t", nil)
|
||||
id2, _ := acc.WriteRequest("p", "t", nil)
|
||||
id3, _ := acc.WriteRequest("p", "t", nil)
|
||||
require.Equal(t, int64(1), id1)
|
||||
require.Equal(t, int64(2), id2)
|
||||
require.Equal(t, int64(3), id3)
|
||||
}()
|
||||
|
||||
for expected := int64(1); expected <= 3; expected++ {
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expected, msg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEvent_NilBody(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.WriteEvent("p", "t", nil))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, msg.Body) // nil is replaced with empty map
|
||||
}
|
||||
|
||||
func TestWriteResponse_NilBody(t *testing.T) {
|
||||
acc, ctrl := newSessionPair(t)
|
||||
|
||||
go func() {
|
||||
require.NoError(t, acc.WriteResponse("p", "t", 1, 0, nil))
|
||||
}()
|
||||
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, msg.Body)
|
||||
}
|
||||
|
||||
// --- Helper tests ---
|
||||
|
||||
func TestOpackHelpers(t *testing.T) {
|
||||
require.Equal(t, "", opackString(nil))
|
||||
require.Equal(t, "", opackString(42))
|
||||
require.Equal(t, "hello", opackString("hello"))
|
||||
|
||||
require.Equal(t, int64(0), opackInt(nil))
|
||||
require.Equal(t, int64(0), opackInt("not a number"))
|
||||
require.Equal(t, int64(42), opackInt(int64(42)))
|
||||
require.Equal(t, int64(7), opackInt(int(7)))
|
||||
require.Equal(t, int64(3), opackInt(float64(3.9)))
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
func BenchmarkSendMediaFragment_Small(b *testing.B) {
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
accConn, _ := NewConn(c1, key, salt, false)
|
||||
ctrlConn, _ := NewConn(c2, key, salt, true)
|
||||
|
||||
acc := NewSession(accConn)
|
||||
fragment := bytes.Repeat([]byte{0xAA}, 2000) // 2KB typical P-frame fragment
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
if _, err := ctrlConn.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
b.SetBytes(int64(len(fragment)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = acc.SendMediaFragment(1, fragment, i)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSendMediaFragment_Large(b *testing.B) {
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
accConn, _ := NewConn(c1, key, salt, false)
|
||||
ctrlConn, _ := NewConn(c2, key, salt, true)
|
||||
|
||||
acc := NewSession(accConn)
|
||||
fragment := bytes.Repeat([]byte{0xBB}, 5*1024*1024) // 5MB typical GOP
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 512*1024)
|
||||
for {
|
||||
if _, err := ctrlConn.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
b.SetBytes(int64(len(fragment)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = acc.SendMediaFragment(1, fragment, i)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOpackMarshal_MediaBody(b *testing.B) {
|
||||
data := bytes.Repeat([]byte{0xCC}, maxChunkSize)
|
||||
body := map[string]any{
|
||||
"streamId": 1,
|
||||
"packets": []any{
|
||||
map[string]any{
|
||||
"data": data,
|
||||
"metadata": map[string]any{
|
||||
"dataType": "mediaFragment",
|
||||
"dataSequenceNumber": 42,
|
||||
"dataChunkSequenceNumber": 1,
|
||||
"isLastDataChunk": true,
|
||||
"dataTotalSize": len(data),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.SetBytes(int64(len(data)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
OpackMarshal(body)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWriteMessage(b *testing.B) {
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
accConn, _ := NewConn(c1, key, salt, false)
|
||||
ctrlConn, _ := NewConn(c2, key, salt, true)
|
||||
|
||||
acc := NewSession(accConn)
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
if _, err := ctrlConn.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
header := map[string]any{"protocol": "dataSend", "event": "data"}
|
||||
body := map[string]any{"streamId": 1, "test": true}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = acc.WriteMessage(header, body)
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func appendValue(b []byte, tag byte, value reflect.Value) ([]byte, error) {
|
||||
if value.Type().Elem().Kind() == reflect.Uint8 {
|
||||
n := value.Len()
|
||||
b = append(b, tag, byte(n))
|
||||
for i := 0; i < n; i++ {
|
||||
for i := range n {
|
||||
b = append(b, byte(value.Index(i).Uint()))
|
||||
}
|
||||
return b, nil
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestMarshal(t *testing.T) {
|
||||
|
||||
func TestBytes(t *testing.T) {
|
||||
bytes := make([]byte, 255)
|
||||
for i := 0; i < len(bytes); i++ {
|
||||
for i := range len(bytes) {
|
||||
bytes[i] = byte(i)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
# hksv - HomeKit Secure Video Library for Go
|
||||
|
||||
`hksv` is a standalone Go library that implements HomeKit Secure Video (HKSV) recording, motion detection, and HAP (HomeKit Accessory Protocol) camera server functionality. It can be used independently of go2rtc in any Go project that needs HKSV support.
|
||||
|
||||
## Author
|
||||
|
||||
Sergei "svk" Krashevich <svk@svk.su>
|
||||
|
||||
## Features
|
||||
|
||||
- **HKSV Recording** - Fragmented MP4 (fMP4) muxing with GOP-based buffering, sent over HDS (HomeKit DataStream)
|
||||
- **Motion Detection** - P-frame size analysis using EMA (Exponential Moving Average) baseline with configurable threshold
|
||||
- **HAP Server** - Full HomeKit pairing (SRP), encrypted communication, accessory management
|
||||
- **Proxy Mode** - Transparent proxy for existing HomeKit cameras
|
||||
- **Live Streaming** - Pluggable interface for RTP/SRTP live view (bring your own implementation)
|
||||
- **Zero internal dependencies** - Only depends on `pkg/` packages, never on `internal/`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pkg/hksv/
|
||||
hksv.go - Server, Config, interfaces (StreamProvider, PairingStore, etc.)
|
||||
consumer.go - HKSVConsumer: fMP4 muxer + GOP buffer + HDS sender
|
||||
session.go - hksvSession: HDS DataStream lifecycle management
|
||||
motion.go - MotionDetector: P-frame based motion detection
|
||||
helpers.go - Helper functions for ID/name generation
|
||||
consumer_test.go - Consumer tests and benchmarks
|
||||
motion_test.go - Motion detector tests and benchmarks
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```
|
||||
pkg/hksv/
|
||||
-> pkg/core (Consumer, Connection, Media, Codec, Receiver, Sender)
|
||||
-> pkg/hap (Server, Conn, Accessory, Character)
|
||||
-> pkg/hap/hds (Conn, Session - encrypted DataStream)
|
||||
-> pkg/hap/camera (TLV8 structs, services, accessory factories)
|
||||
-> pkg/hap/tlv8 (marshal/unmarshal)
|
||||
-> pkg/homekit (ServerHandler, ProxyHandler, HandlerFunc)
|
||||
-> pkg/mp4 (Muxer - fMP4)
|
||||
-> pkg/h264 (IsKeyframe, RTPDepay, RepairAVCC)
|
||||
-> pkg/aac (RTPDepay)
|
||||
-> pkg/mdns (ServiceEntry for mDNS advertisement)
|
||||
-> github.com/pion/rtp
|
||||
-> github.com/rs/zerolog
|
||||
-> ZERO imports from internal/
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal HKSV Camera
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-camera",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to create server")
|
||||
}
|
||||
|
||||
// Register HAP endpoints
|
||||
http.HandleFunc(hap.PathPairSetup, func(w http.ResponseWriter, r *http.Request) {
|
||||
srv.Handle(w, r)
|
||||
})
|
||||
http.HandleFunc(hap.PathPairVerify, func(w http.ResponseWriter, r *http.Request) {
|
||||
srv.Handle(w, r)
|
||||
})
|
||||
|
||||
// Advertise via mDNS
|
||||
entry := srv.MDNSEntry()
|
||||
go mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{entry})
|
||||
|
||||
// Start HTTP server
|
||||
logger.Info().Msg("HomeKit camera running on :8080")
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### HKSV Camera with Live Streaming
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-camera",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
|
||||
// Required interfaces
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
LiveStream: &myLiveStreamHandler{}, // enables live view in Home app
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### Basic Camera (no HKSV, live streaming only)
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "basic-cam",
|
||||
Pin: "27041991",
|
||||
HKSV: false, // no HKSV recording
|
||||
|
||||
Streams: &myStreamProvider{},
|
||||
LiveStream: &myLiveStreamHandler{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### Proxy Mode (transparent proxy for existing HomeKit camera)
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "proxied-cam",
|
||||
Pin: "27041991",
|
||||
ProxyURL: "homekit://192.168.1.100:51827?device_id=AA:BB:CC:DD:EE:FF&...",
|
||||
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
```
|
||||
|
||||
### HomeKit Doorbell
|
||||
|
||||
```go
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "my-doorbell",
|
||||
Pin: "27041991",
|
||||
CategoryID: "doorbell", // creates doorbell accessory
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
|
||||
Streams: &myStreamProvider{},
|
||||
Store: &myPairingStore{},
|
||||
Snapshots: &mySnapshotProvider{},
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
|
||||
// Trigger doorbell press from external event
|
||||
srv.TriggerDoorbell()
|
||||
```
|
||||
|
||||
## Interfaces
|
||||
|
||||
The library uses dependency injection via four interfaces. You implement these to connect `hksv` to your own stream management, storage, and media pipeline.
|
||||
|
||||
### StreamProvider (required)
|
||||
|
||||
Connects HKSV consumers to your video/audio streams.
|
||||
|
||||
```go
|
||||
type StreamProvider interface {
|
||||
// AddConsumer connects a consumer to the named stream.
|
||||
// The consumer implements core.Consumer (AddTrack, WriteTo, Stop).
|
||||
AddConsumer(streamName string, consumer core.Consumer) error
|
||||
|
||||
// RemoveConsumer disconnects a consumer from the named stream.
|
||||
RemoveConsumer(streamName string, consumer core.Consumer)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation:**
|
||||
|
||||
```go
|
||||
type myStreamProvider struct {
|
||||
streams map[string]*Stream // your stream registry
|
||||
}
|
||||
|
||||
func (p *myStreamProvider) AddConsumer(name string, cons core.Consumer) error {
|
||||
stream, ok := p.streams[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("stream not found: %s", name)
|
||||
}
|
||||
return stream.AddConsumer(cons)
|
||||
}
|
||||
|
||||
func (p *myStreamProvider) RemoveConsumer(name string, cons core.Consumer) {
|
||||
if stream, ok := p.streams[name]; ok {
|
||||
stream.RemoveConsumer(cons)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PairingStore (optional)
|
||||
|
||||
Persists HomeKit pairing data across restarts. If `nil`, pairings are lost on restart and the device must be re-paired.
|
||||
|
||||
```go
|
||||
type PairingStore interface {
|
||||
SavePairings(streamName string, pairings []string) error
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (JSON file):**
|
||||
|
||||
```go
|
||||
type filePairingStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (s *filePairingStore) SavePairings(name string, pairings []string) error {
|
||||
data := map[string][]string{name: pairings}
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0644)
|
||||
}
|
||||
```
|
||||
|
||||
### SnapshotProvider (optional)
|
||||
|
||||
Generates JPEG snapshots for HomeKit `/resource` requests (shown in the Home app timeline and notifications). If `nil`, snapshots are not available.
|
||||
|
||||
```go
|
||||
type SnapshotProvider interface {
|
||||
GetSnapshot(streamName string, width, height int) ([]byte, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (ffmpeg):**
|
||||
|
||||
```go
|
||||
type ffmpegSnapshotProvider struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (p *ffmpegSnapshotProvider) GetSnapshot(name string, w, h int) ([]byte, error) {
|
||||
stream := p.streams[name]
|
||||
if stream == nil {
|
||||
return nil, errors.New("stream not found")
|
||||
}
|
||||
|
||||
// Capture one keyframe from the stream
|
||||
frame, err := stream.CaptureKeyframe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to JPEG using ffmpeg
|
||||
return ffmpegToJPEG(frame, w, h)
|
||||
}
|
||||
```
|
||||
|
||||
### LiveStreamHandler (optional)
|
||||
|
||||
Handles live-streaming requests from the Home app (RTP/SRTP setup). If `nil`, only HKSV recording is available (no live view).
|
||||
|
||||
```go
|
||||
type LiveStreamHandler interface {
|
||||
// SetupEndpoints handles a SetupEndpoints request (HAP characteristic 118).
|
||||
// Creates the RTP/SRTP consumer, returns the response value.
|
||||
SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error)
|
||||
|
||||
// GetEndpointsResponse returns the current endpoints response (for GET requests).
|
||||
GetEndpointsResponse() any
|
||||
|
||||
// StartStream starts RTP streaming with the given configuration.
|
||||
// The connTracker is used to register/unregister the live stream connection
|
||||
// on the HKSV server (for connection tracking and MarshalJSON).
|
||||
StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker ConnTracker) error
|
||||
|
||||
// StopStream stops a stream matching the given session ID.
|
||||
StopStream(sessionID string, connTracker ConnTracker) error
|
||||
}
|
||||
|
||||
type ConnTracker interface {
|
||||
AddConn(v any)
|
||||
DelConn(v any)
|
||||
}
|
||||
```
|
||||
|
||||
**Example implementation (SRTP-based):**
|
||||
|
||||
```go
|
||||
type srtpLiveStreamHandler struct {
|
||||
mu sync.Mutex
|
||||
consumer *homekit.Consumer
|
||||
srtp *srtp.Server
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error) {
|
||||
consumer := homekit.NewConsumer(conn, h.srtp)
|
||||
consumer.SetOffer(offer)
|
||||
|
||||
h.mu.Lock()
|
||||
h.consumer = consumer
|
||||
h.mu.Unlock()
|
||||
|
||||
answer := consumer.GetAnswer()
|
||||
v, err := tlv8.MarshalBase64(answer)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) GetEndpointsResponse() any {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer == nil {
|
||||
return nil
|
||||
}
|
||||
answer := consumer.GetAnswer()
|
||||
v, _ := tlv8.MarshalBase64(answer)
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) StartStream(streamName string, conf *camera.SelectedStreamConfiguration, ct hksv.ConnTracker) error {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer == nil {
|
||||
return errors.New("no consumer")
|
||||
}
|
||||
if !consumer.SetConfig(conf) {
|
||||
return errors.New("wrong config")
|
||||
}
|
||||
|
||||
ct.AddConn(consumer)
|
||||
stream := h.streams[streamName]
|
||||
if err := stream.AddConsumer(consumer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = consumer.WriteTo(nil) // blocks until stream ends
|
||||
stream.RemoveConsumer(consumer)
|
||||
ct.DelConn(consumer)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *srtpLiveStreamHandler) StopStream(sessionID string, ct hksv.ConnTracker) error {
|
||||
h.mu.Lock()
|
||||
consumer := h.consumer
|
||||
h.mu.Unlock()
|
||||
if consumer != nil && consumer.SessionID() == sessionID {
|
||||
_ = consumer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Config Reference
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// Required
|
||||
StreamName string // stream identifier (used for lookups)
|
||||
Pin string // HomeKit pairing PIN, e.g. "27041991" (default)
|
||||
Port uint16 // HAP HTTP port
|
||||
Logger zerolog.Logger // structured logger
|
||||
Streams StreamProvider // stream registry (required for HKSV/live/motion)
|
||||
|
||||
// Optional - server identity
|
||||
Name string // mDNS display name (auto-generated from DeviceID if empty)
|
||||
DeviceID string // MAC-like ID, e.g. "AA:BB:CC:DD:EE:FF" (auto-generated if empty)
|
||||
DevicePrivate string // ed25519 private key hex (auto-generated if empty)
|
||||
CategoryID string // "camera" (default), "doorbell", "bridge", or numeric
|
||||
Pairings []string // pre-existing pairings from storage
|
||||
|
||||
// Optional - mode
|
||||
ProxyURL string // if set, acts as transparent proxy (no local accessory)
|
||||
HKSV bool // enable HKSV recording support
|
||||
|
||||
// Optional - motion detection
|
||||
MotionMode string // "api" (external trigger), "continuous" (always on), "detect" (P-frame analysis)
|
||||
MotionThreshold float64 // ratio threshold for "detect" mode (default 2.0, lower = more sensitive)
|
||||
|
||||
// Optional - hardware
|
||||
Speaker *bool // include Speaker service for 2-way audio (default false)
|
||||
|
||||
// Optional - metadata
|
||||
UserAgent string // for mDNS TXTModel field
|
||||
Version string // for accessory firmware version
|
||||
|
||||
// Optional - persistence and features
|
||||
Store PairingStore // nil = pairings not persisted
|
||||
Snapshots SnapshotProvider // nil = no snapshot support
|
||||
LiveStream LiveStreamHandler // nil = no live streaming (HKSV recording only)
|
||||
}
|
||||
```
|
||||
|
||||
## Motion Detection
|
||||
|
||||
The library includes a built-in P-frame based motion detector that works without any external motion detection system.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. During a **warmup phase** (30 P-frames), the detector establishes a baseline average frame size using fast EMA (alpha=0.1).
|
||||
2. After warmup, each P-frame size is compared against the baseline multiplied by the threshold.
|
||||
3. If `frame_size > baseline * threshold`, motion is detected.
|
||||
4. Motion stays active for a **hold period** (30 seconds) after the last trigger frame.
|
||||
5. After motion ends, there is a **cooldown period** (5 seconds) before new motion can be detected.
|
||||
6. The baseline is updated continuously with slow EMA (alpha=0.02) during idle periods.
|
||||
7. FPS is recalibrated every 150 frames for accurate hold/cooldown timing.
|
||||
|
||||
### Motion Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `"api"` | Motion is triggered externally via `srv.SetMotionDetected(true/false)` |
|
||||
| `"detect"` | Automatic P-frame analysis (starts on first Home Hub connection) |
|
||||
| `"continuous"` | Always reports motion every 30 seconds (for testing/always-record) |
|
||||
|
||||
### Using the MotionDetector Standalone
|
||||
|
||||
The `MotionDetector` can be used independently as a `core.Consumer`:
|
||||
|
||||
```go
|
||||
onMotion := func(detected bool) {
|
||||
if detected {
|
||||
log.Println("Motion started!")
|
||||
// start recording, send notification, etc.
|
||||
} else {
|
||||
log.Println("Motion ended")
|
||||
}
|
||||
}
|
||||
|
||||
detector := hksv.NewMotionDetector(2.0, onMotion, logger)
|
||||
|
||||
// Attach to a stream (detector implements core.Consumer)
|
||||
err := stream.AddConsumer(detector)
|
||||
|
||||
// Blocks until Stop() is called
|
||||
go func() {
|
||||
detector.WriteTo(nil)
|
||||
}()
|
||||
|
||||
// Later, stop the detector
|
||||
detector.Stop()
|
||||
```
|
||||
|
||||
## Server API
|
||||
|
||||
### Motion Control
|
||||
|
||||
```go
|
||||
// Check current motion status
|
||||
detected := srv.MotionDetected()
|
||||
|
||||
// Trigger motion detected (for "api" mode or external sensors)
|
||||
srv.SetMotionDetected(true)
|
||||
|
||||
// Clear motion
|
||||
srv.SetMotionDetected(false)
|
||||
|
||||
// Trigger doorbell press event
|
||||
srv.TriggerDoorbell()
|
||||
```
|
||||
|
||||
### Connection Tracking
|
||||
|
||||
```go
|
||||
// Register a connection (for monitoring/JSON output)
|
||||
srv.AddConn(conn)
|
||||
|
||||
// Unregister a connection
|
||||
srv.DelConn(conn)
|
||||
```
|
||||
|
||||
### Pairing Management
|
||||
|
||||
```go
|
||||
// Add a new pairing (called automatically during HAP pair-setup)
|
||||
srv.AddPair(clientID, publicKey, hap.PermissionAdmin)
|
||||
|
||||
// Remove a pairing
|
||||
srv.DelPair(clientID)
|
||||
|
||||
// Get client's public key (used by HAP pair-verify)
|
||||
pubKey := srv.GetPair(clientID)
|
||||
```
|
||||
|
||||
### JSON Serialization
|
||||
|
||||
The server implements `json.Marshaler` for status reporting:
|
||||
|
||||
```go
|
||||
b, _ := json.Marshal(srv)
|
||||
// {"name":"go2rtc-A1B2","device_id":"AA:BB:CC:DD:EE:FF","paired":1,"category_id":"17","connections":[...]}
|
||||
|
||||
// If not paired, includes setup_code and setup_id for QR code generation
|
||||
// {"name":"go2rtc-A1B2","device_id":"AA:BB:CC:DD:EE:FF","setup_code":"195-50-224","setup_id":"A1B2"}
|
||||
```
|
||||
|
||||
### mDNS Advertisement
|
||||
|
||||
```go
|
||||
entry := srv.MDNSEntry()
|
||||
|
||||
// Start mDNS advertisement
|
||||
go mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{entry})
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
For deterministic ID generation from stream names:
|
||||
|
||||
```go
|
||||
// Generate a display name from a seed
|
||||
name := hksv.CalcName("", "my-camera")
|
||||
// => "go2rtc-A1B2" (deterministic from seed)
|
||||
|
||||
name = hksv.CalcName("My Camera", "")
|
||||
// => "My Camera" (uses provided name)
|
||||
|
||||
// Generate a MAC-like device ID
|
||||
deviceID := hksv.CalcDeviceID("", "my-camera")
|
||||
// => "AA:BB:CC:DD:EE:FF" (deterministic from seed)
|
||||
|
||||
// Generate an ed25519 private key
|
||||
privateKey := hksv.CalcDevicePrivate("", "my-camera")
|
||||
// => []byte{...} (deterministic 64-byte ed25519 key)
|
||||
|
||||
// Generate a setup ID for QR codes
|
||||
setupID := hksv.CalcSetupID("my-camera")
|
||||
// => "A1B2"
|
||||
|
||||
// Convert category string to HAP constant
|
||||
catID := hksv.CalcCategoryID("doorbell")
|
||||
// => "18" (hap.CategoryDoorbell)
|
||||
```
|
||||
|
||||
## Multiple Cameras
|
||||
|
||||
You can run multiple HKSV cameras on a single port. Each camera gets its own mDNS entry and is resolved by hostname:
|
||||
|
||||
```go
|
||||
cameras := []string{"front-door", "backyard", "garage"}
|
||||
var entries []*mdns.ServiceEntry
|
||||
|
||||
for _, name := range cameras {
|
||||
srv, _ := hksv.NewServer(hksv.Config{
|
||||
StreamName: name,
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
MotionMode: "detect",
|
||||
Streams: provider,
|
||||
Logger: logger,
|
||||
Port: 8080,
|
||||
})
|
||||
|
||||
entry := srv.MDNSEntry()
|
||||
entries = append(entries, entry)
|
||||
|
||||
// Map hostname -> server for HTTP routing
|
||||
host := entry.Host(mdns.ServiceHAP)
|
||||
handlers[host] = srv
|
||||
}
|
||||
|
||||
// Single HTTP server handles all cameras
|
||||
http.HandleFunc(hap.PathPairSetup, func(w http.ResponseWriter, r *http.Request) {
|
||||
if srv := handlers[r.Host]; srv != nil {
|
||||
srv.Handle(w, r)
|
||||
}
|
||||
})
|
||||
http.HandleFunc(hap.PathPairVerify, func(w http.ResponseWriter, r *http.Request) {
|
||||
if srv := handlers[r.Host]; srv != nil {
|
||||
srv.Handle(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
go mdns.Serve(mdns.ServiceHAP, entries)
|
||||
http.ListenAndServe(":8080", nil)
|
||||
```
|
||||
|
||||
## HKSV Recording Flow
|
||||
|
||||
Understanding the recording flow helps with debugging:
|
||||
|
||||
```
|
||||
1. Home Hub discovers camera via mDNS
|
||||
2. Home Hub connects -> PairSetup (first time) or PairVerify (subsequent)
|
||||
3. On PairVerify success:
|
||||
- If motion="detect": MotionDetector starts consuming the video stream
|
||||
- If motion="continuous": prepareHKSVConsumer() + startContinuousMotion()
|
||||
4. Motion detected -> SetMotionDetected(true) -> HAP event notification
|
||||
5. Home Hub receives motion event -> sets up HDS DataStream:
|
||||
- SetCharacteristic(TypeSetupDataStreamTransport) -> TCP listener created
|
||||
- Home Hub connects to TCP port -> encrypted HDS connection established
|
||||
- hksvSession created
|
||||
6. Home Hub opens dataSend stream:
|
||||
- handleOpen() -> takes prepared consumer (or creates new one)
|
||||
- consumer.Activate() -> sends fMP4 init segment over HDS
|
||||
- H264 keyframes trigger GOP flush -> mediaFragment sent over HDS
|
||||
7. Home Hub closes dataSend -> handleClose() -> consumer stopped
|
||||
8. Motion timeout -> SetMotionDetected(false)
|
||||
```
|
||||
|
||||
## Example CLI Application
|
||||
|
||||
The `example/` directory contains a standalone CLI app that exports any RTSP camera as an HKSV camera in HomeKit.
|
||||
|
||||
### Build & Run
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
go run ./pkg/hksv/example -url rtsp://camera:554/stream
|
||||
|
||||
# Or build a binary
|
||||
go build -o hksv-camera ./pkg/hksv/example
|
||||
./hksv-camera -url rtsp://admin:pass@192.168.1.100:554/h264
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-url` | (required) | RTSP stream URL |
|
||||
| `-pin` | `27041991` | HomeKit pairing PIN |
|
||||
| `-port` | `0` (auto) | HAP HTTP port |
|
||||
| `-motion` | `detect` | Motion mode: `detect`, `continuous`, `api` |
|
||||
| `-threshold` | `2.0` | Motion sensitivity (lower = more sensitive) |
|
||||
| `-pairings` | `pairings.json` | File to persist HomeKit pairings |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Connects to the RTSP source, discovers available tracks (H264/AAC)
|
||||
2. Creates an HKSV server with HAP pairing and encrypted communication
|
||||
3. Advertises the camera via mDNS — it appears in the Home app
|
||||
4. On motion detection, Home Hub opens an HDS DataStream and records fMP4 fragments
|
||||
5. Pairings are saved to a JSON file so the camera survives restarts
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
RTSP Camera ──► rtsp.Conn (Producer)
|
||||
│
|
||||
▼
|
||||
streamProvider ◄── hksv.Server
|
||||
(AddConsumer) │ │
|
||||
│ ▼ ▼
|
||||
├── MotionDetector HKSVConsumer
|
||||
│ (P-frame EMA) (fMP4 → HDS)
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ HAP event → Home Hub
|
||||
│ motion notify records video
|
||||
│
|
||||
└── mDNS advertisement
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./pkg/hksv/...
|
||||
|
||||
# Run with verbose output
|
||||
go test -v ./pkg/hksv/...
|
||||
|
||||
# Run benchmarks
|
||||
go test -bench=. ./pkg/hksv/...
|
||||
|
||||
# Run specific test
|
||||
go test -v -run TestMotionDetector_BasicTrigger ./pkg/hksv/...
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22+
|
||||
- Dependencies: `github.com/pion/rtp`, `github.com/rs/zerolog` (plus go2rtc `pkg/` packages)
|
||||
@@ -0,0 +1,257 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/aac"
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mp4"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// HKSVConsumer implements core.Consumer, generates fMP4 and sends over HDS.
|
||||
// It can be pre-started without an HDS session, buffering init data until activated.
|
||||
type HKSVConsumer struct {
|
||||
core.Connection
|
||||
muxer *mp4.Muxer
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
log zerolog.Logger
|
||||
|
||||
// Set by Activate() when HDS session is available
|
||||
session *hds.Session
|
||||
streamID int
|
||||
seqNum int
|
||||
active bool
|
||||
start bool // waiting for first keyframe
|
||||
|
||||
// GOP buffer - accumulate moof+mdat pairs, flush on next keyframe
|
||||
fragBuf []byte
|
||||
|
||||
// Pre-built init segment (built when tracks connect)
|
||||
initData []byte
|
||||
initErr error
|
||||
initDone chan struct{} // closed when init is ready
|
||||
}
|
||||
|
||||
// NewHKSVConsumer creates a new HKSV consumer that muxes H264+AAC into fMP4
|
||||
// and sends fragments over an HDS DataStream session.
|
||||
func NewHKSVConsumer(log zerolog.Logger) *HKSVConsumer {
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecAAC},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &HKSVConsumer{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "hksv",
|
||||
Protocol: "hds",
|
||||
Medias: medias,
|
||||
},
|
||||
muxer: &mp4.Muxer{},
|
||||
done: make(chan struct{}),
|
||||
initDone: make(chan struct{}),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
// Reject late tracks after init segment is built (can't modify fMP4 header)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
c.log.Debug().Str("codec", track.Codec.Name).Msg("[hksv] ignoring late track (init already built)")
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
trackID := byte(len(c.Senders))
|
||||
|
||||
c.log.Debug().Str("codec", track.Codec.Name).Uint8("trackID", trackID).Msg("[hksv] AddTrack")
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
handler := core.NewSender(media, codec)
|
||||
|
||||
switch track.Codec.Name {
|
||||
case core.CodecH264:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !c.start {
|
||||
if !h264.IsKeyframe(packet.Payload) {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.start = true
|
||||
c.log.Debug().Int("payloadLen", len(packet.Payload)).Msg("[hksv] first keyframe")
|
||||
} else if h264.IsKeyframe(packet.Payload) && len(c.fragBuf) > 0 {
|
||||
// New keyframe = flush previous GOP as one mediaFragment
|
||||
c.flushFragment()
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = h264.RTPDepay(track.Codec, handler.Handler)
|
||||
} else {
|
||||
handler.Handler = h264.RepairAVCC(track.Codec, handler.Handler)
|
||||
}
|
||||
|
||||
case core.CodecAAC:
|
||||
handler.Handler = func(packet *rtp.Packet) {
|
||||
c.mu.Lock()
|
||||
if !c.active || !c.start {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
b := c.muxer.GetPayload(trackID, packet)
|
||||
c.fragBuf = append(c.fragBuf, b...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
handler.Handler = aac.RTPDepay(handler.Handler)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil // skip unsupported codecs
|
||||
}
|
||||
|
||||
c.muxer.AddTrack(codec)
|
||||
handler.HandleRTP(track)
|
||||
c.Senders = append(c.Senders, handler)
|
||||
|
||||
// Build init segment when all expected tracks are ready
|
||||
select {
|
||||
case <-c.initDone:
|
||||
// already built — ignore late tracks (init is immutable)
|
||||
default:
|
||||
if len(c.Senders) >= len(c.Medias) {
|
||||
c.buildInit()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildInit creates the init segment from currently connected tracks.
|
||||
// Must only be called once (closes initDone).
|
||||
func (c *HKSVConsumer) buildInit() {
|
||||
initData, err := c.muxer.GetInit()
|
||||
c.initData = initData
|
||||
c.initErr = err
|
||||
close(c.initDone)
|
||||
if err != nil {
|
||||
c.log.Error().Err(err).Msg("[hksv] GetInit failed")
|
||||
} else {
|
||||
c.log.Debug().Int("initSize", len(initData)).Int("tracks", len(c.Senders)).Msg("[hksv] init segment ready")
|
||||
}
|
||||
}
|
||||
|
||||
// Activate is called when the HDS session is ready (dataSend.open).
|
||||
// It sends the pre-built init segment and starts streaming.
|
||||
func (c *HKSVConsumer) Activate(session *hds.Session, streamID int) error {
|
||||
// Wait for init to be ready (should already be done if consumer was pre-started)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Build init with whatever tracks we have (audio may be missing)
|
||||
select {
|
||||
case <-c.initDone:
|
||||
default:
|
||||
if len(c.Senders) > 0 {
|
||||
c.log.Warn().Int("tracks", len(c.Senders)).Msg("[hksv] init timeout, building with available tracks")
|
||||
c.buildInit()
|
||||
} else {
|
||||
return errors.New("hksv: no tracks connected after timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.initErr != nil {
|
||||
return c.initErr
|
||||
}
|
||||
|
||||
c.log.Debug().Int("initSize", len(c.initData)).Msg("[hksv] sending init segment")
|
||||
|
||||
if err := session.SendMediaInit(streamID, c.initData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.log.Debug().Msg("[hksv] init segment sent OK")
|
||||
|
||||
// Enable live streaming (seqNum=2 because init used seqNum=1)
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
c.streamID = streamID
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushFragment sends the accumulated GOP buffer as a single mediaFragment.
|
||||
// Must be called while holding c.mu.
|
||||
func (c *HKSVConsumer) flushFragment() {
|
||||
fragment := c.fragBuf
|
||||
c.fragBuf = make([]byte, 0, len(fragment))
|
||||
|
||||
c.log.Debug().Int("fragSize", len(fragment)).Int("seq", c.seqNum).Msg("[hksv] flush fragment")
|
||||
|
||||
if err := c.session.SendMediaFragment(c.streamID, fragment, c.seqNum); err == nil {
|
||||
c.Send += len(fragment)
|
||||
}
|
||||
c.seqNum++
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) WriteTo(io.Writer) (int64, error) {
|
||||
<-c.done
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) Stop() error {
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.active = false
|
||||
c.mu.Unlock()
|
||||
return c.Connection.Stop()
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the consumer is stopped.
|
||||
func (c *HKSVConsumer) Done() <-chan struct{} {
|
||||
return c.done
|
||||
}
|
||||
|
||||
func (c *HKSVConsumer) String() string {
|
||||
return "hksv consumer"
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testLog = zerolog.Nop()
|
||||
|
||||
// newTestSessionPair creates connected HDS sessions for testing.
|
||||
func newTestSessionPair(t *testing.T) (accessory *hds.Session, controller *hds.Session) {
|
||||
t.Helper()
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
|
||||
c1, c2 := net.Pipe()
|
||||
t.Cleanup(func() { c1.Close(); c2.Close() })
|
||||
|
||||
accConn, err := hds.NewConn(c1, key, salt, false)
|
||||
require.NoError(t, err)
|
||||
ctrlConn, err := hds.NewConn(c2, key, salt, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
return hds.NewSession(accConn), hds.NewSession(ctrlConn)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_Creation(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
require.Equal(t, "hksv", c.FormatName)
|
||||
require.Equal(t, "hds", c.Protocol)
|
||||
require.Len(t, c.Medias, 2)
|
||||
require.Equal(t, core.KindVideo, c.Medias[0].Kind)
|
||||
require.Equal(t, core.KindAudio, c.Medias[1].Kind)
|
||||
require.Equal(t, core.CodecH264, c.Medias[0].Codecs[0].Name)
|
||||
require.Equal(t, core.CodecAAC, c.Medias[1].Codecs[0].Name)
|
||||
|
||||
require.NotNil(t, c.muxer)
|
||||
require.NotNil(t, c.done)
|
||||
require.NotNil(t, c.initDone)
|
||||
require.False(t, c.active)
|
||||
require.False(t, c.start)
|
||||
require.Equal(t, 0, c.seqNum)
|
||||
require.Nil(t, c.fragBuf)
|
||||
require.Nil(t, c.initData)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_SendsAndIncrements(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
// Manually set up the consumer as if Activate() was called
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
c.fragBuf = []byte("fake-fragment-data-here")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dataSend", msg.Protocol)
|
||||
require.Equal(t, "data", msg.Topic)
|
||||
require.True(t, msg.IsEvent)
|
||||
|
||||
packets, ok := msg.Body["packets"].([]any)
|
||||
require.True(t, ok)
|
||||
pkt := packets[0].(map[string]any)
|
||||
meta := pkt["metadata"].(map[string]any)
|
||||
|
||||
require.Equal(t, "mediaFragment", meta["dataType"])
|
||||
require.Equal(t, int64(2), meta["dataSequenceNumber"].(int64))
|
||||
require.Equal(t, true, meta["isLastDataChunk"])
|
||||
}()
|
||||
|
||||
c.mu.Lock()
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
|
||||
<-done
|
||||
|
||||
require.Equal(t, 3, c.seqNum, "seqNum should increment after flush")
|
||||
require.Empty(t, c.fragBuf, "fragBuf should be empty after flush")
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_MultipleFlushes(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
|
||||
var received []int64
|
||||
var mu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 3; i++ {
|
||||
msg, err := ctrl.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
packets := msg.Body["packets"].([]any)
|
||||
pkt := packets[0].(map[string]any)
|
||||
meta := pkt["metadata"].(map[string]any)
|
||||
mu.Lock()
|
||||
received = append(received, meta["dataSequenceNumber"].(int64))
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
c.mu.Lock()
|
||||
c.fragBuf = []byte("data")
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
<-done
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, []int64{2, 3, 4}, received)
|
||||
require.Equal(t, 5, c.seqNum)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_FlushFragment_EmptyBuffer(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.seqNum = 2
|
||||
|
||||
// flushFragment with empty/nil buffer should still increment seqNum
|
||||
// but send empty data (protocol layer handles it)
|
||||
// In practice, flushFragment is only called when fragBuf has data
|
||||
c.mu.Lock()
|
||||
c.fragBuf = nil
|
||||
initialSeq := c.seqNum
|
||||
c.mu.Unlock()
|
||||
|
||||
// No crash = pass (no session to write to, would panic on nil session)
|
||||
require.Equal(t, initialSeq, c.seqNum)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_BufferAccumulation(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
|
||||
data1 := []byte("chunk-1")
|
||||
data2 := []byte("chunk-2")
|
||||
data3 := []byte("chunk-3")
|
||||
|
||||
c.fragBuf = append(c.fragBuf, data1...)
|
||||
c.fragBuf = append(c.fragBuf, data2...)
|
||||
c.fragBuf = append(c.fragBuf, data3...)
|
||||
|
||||
require.Equal(t, len(data1)+len(data2)+len(data3), len(c.fragBuf))
|
||||
require.Equal(t, "chunk-1chunk-2chunk-3", string(c.fragBuf))
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_ActivateSeqNum(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
// Simulate init ready
|
||||
c.initData = []byte("fake-init")
|
||||
close(c.initDone)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// Read the init message
|
||||
msg, err := ctrl.ReadMessage()
|
||||
require.NoError(t, err)
|
||||
require.True(t, msg.IsEvent)
|
||||
|
||||
packets := msg.Body["packets"].([]any)
|
||||
pkt := packets[0].(map[string]any)
|
||||
meta := pkt["metadata"].(map[string]any)
|
||||
|
||||
require.Equal(t, "mediaInitialization", meta["dataType"])
|
||||
require.Equal(t, int64(1), meta["dataSequenceNumber"].(int64))
|
||||
}()
|
||||
|
||||
err := c.Activate(acc, 5)
|
||||
require.NoError(t, err)
|
||||
<-done
|
||||
|
||||
require.Equal(t, 2, c.seqNum, "seqNum should be 2 after activate (init uses 1)")
|
||||
require.True(t, c.active)
|
||||
require.Equal(t, 5, c.streamID)
|
||||
require.Equal(t, acc, c.session)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_ActivateTimeout(t *testing.T) {
|
||||
acc, _ := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
// Don't close initDone — simulate init never becoming ready
|
||||
|
||||
// Override the timeout for faster test
|
||||
err := func() error {
|
||||
select {
|
||||
case <-c.initDone:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
return errActivateTimeout
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
require.Error(t, err)
|
||||
_ = acc // prevent unused
|
||||
}
|
||||
|
||||
var errActivateTimeout = func() error {
|
||||
return &timeoutError{}
|
||||
}()
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (e *timeoutError) Error() string { return "activate timeout" }
|
||||
|
||||
func TestHKSVConsumer_ActivateWithError(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.initErr = &timeoutError{}
|
||||
close(c.initDone)
|
||||
|
||||
acc, _ := newTestSessionPair(t)
|
||||
err := c.Activate(acc, 1)
|
||||
require.Error(t, err)
|
||||
require.False(t, c.active)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_StopSafety(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
|
||||
// First stop
|
||||
err := c.Stop()
|
||||
require.NoError(t, err)
|
||||
require.False(t, c.active)
|
||||
|
||||
// Second stop — should not panic
|
||||
err = c.Stop()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_StopDeactivates(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.active = true
|
||||
c.start = true
|
||||
|
||||
_ = c.Stop()
|
||||
|
||||
require.False(t, c.active)
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_WriteToDone(t *testing.T) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
n, err := c.WriteTo(nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), n)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// WriteTo should block until done channel is closed
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("WriteTo returned before Stop")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
_ = c.Stop()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WriteTo did not return after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_GOPFlushIntegration(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
c.start = true // already started
|
||||
|
||||
// Simulate a sequence: buffer data, then flush
|
||||
frag1 := []byte("keyframe-1-data-plus-p-frames")
|
||||
frag2 := []byte("keyframe-2-data")
|
||||
|
||||
var received [][]byte
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 2; i++ {
|
||||
msg, err := ctrl.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
packets := msg.Body["packets"].([]any)
|
||||
pkt := packets[0].(map[string]any)
|
||||
data := pkt["data"].([]byte)
|
||||
received = append(received, data)
|
||||
}
|
||||
}()
|
||||
|
||||
// First GOP
|
||||
c.mu.Lock()
|
||||
c.fragBuf = append(c.fragBuf, frag1...)
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
|
||||
// Second GOP
|
||||
c.mu.Lock()
|
||||
c.fragBuf = append(c.fragBuf, frag2...)
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
|
||||
<-done
|
||||
|
||||
require.Len(t, received, 2)
|
||||
require.Equal(t, frag1, received[0])
|
||||
require.Equal(t, frag2, received[1])
|
||||
require.Equal(t, 4, c.seqNum) // 2 + 2 flushes
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_FlushClearsBuffer(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// drain messages
|
||||
for i := 0; i < 3; i++ {
|
||||
ctrl.ReadMessage()
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
c.mu.Lock()
|
||||
c.fragBuf = append(c.fragBuf, []byte("frame-data")...)
|
||||
prevLen := len(c.fragBuf)
|
||||
c.flushFragment()
|
||||
require.Empty(t, c.fragBuf, "fragBuf should be empty after flush")
|
||||
require.Greater(t, prevLen, 0, "had data before flush")
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
<-done
|
||||
require.Equal(t, 5, c.seqNum, "3 flushes from seqNum=2 → 5")
|
||||
}
|
||||
|
||||
func TestHKSVConsumer_SendTracking(t *testing.T) {
|
||||
acc, ctrl := newTestSessionPair(t)
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
|
||||
data := []byte("12345678") // 8 bytes
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ctrl.ReadMessage()
|
||||
}()
|
||||
|
||||
c.mu.Lock()
|
||||
c.fragBuf = append(c.fragBuf, data...)
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
|
||||
<-done
|
||||
require.Equal(t, 8, c.Send, "Send counter should track bytes sent")
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
func BenchmarkHKSVConsumer_FlushFragment(b *testing.B) {
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
accConn, _ := hds.NewConn(c1, key, salt, false)
|
||||
ctrlConn, _ := hds.NewConn(c2, key, salt, true)
|
||||
|
||||
acc := hds.NewSession(accConn)
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 512*1024) // must be > 256KB chunk size
|
||||
for {
|
||||
if _, err := ctrlConn.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
c := NewHKSVConsumer(testLog)
|
||||
c.session = acc
|
||||
c.streamID = 1
|
||||
c.seqNum = 2
|
||||
c.active = true
|
||||
|
||||
gopData := make([]byte, 4*1024*1024) // 4MB GOP
|
||||
|
||||
b.SetBytes(int64(len(gopData)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
c.mu.Lock()
|
||||
c.fragBuf = append(c.fragBuf[:0], gopData...)
|
||||
c.flushFragment()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHKSVConsumer_BufferAppend(b *testing.B) {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
frame := make([]byte, 1500) // typical frame fragment
|
||||
|
||||
b.SetBytes(int64(len(frame)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
c.fragBuf = append(c.fragBuf, frame...)
|
||||
if len(c.fragBuf) > 5*1024*1024 {
|
||||
c.fragBuf = c.fragBuf[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHKSVConsumer_CreateAndStop(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
c := NewHKSVConsumer(testLog)
|
||||
_ = c.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Example CLI application that exports an RTSP camera stream as a HomeKit
|
||||
// Secure Video (HKSV) camera using the pkg/hksv library.
|
||||
//
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./pkg/hksv/example -url rtsp://camera:554/stream
|
||||
// go run ./pkg/hksv/example -url rtsp://admin:pass@192.168.1.100:554/h264
|
||||
//
|
||||
// Then open the Home app on your iPhone/iPad, tap "+" → "Add Accessory",
|
||||
// and scan the QR code or enter the PIN manually (default: 270-41-991).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hksv"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/AlexxIT/go2rtc/pkg/rtsp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
streamURL := flag.String("url", "", "RTSP stream URL (required)")
|
||||
pin := flag.String("pin", "27041991", "HomeKit pairing PIN")
|
||||
port := flag.Int("port", 0, "HAP HTTP port (0 = auto)")
|
||||
motion := flag.String("motion", "detect", "Motion mode: detect, continuous, api")
|
||||
threshold := flag.Float64("threshold", 2.0, "Motion detection threshold (lower = more sensitive)")
|
||||
pairFile := flag.String("pairings", "pairings.json", "Pairings persistence file")
|
||||
flag.Parse()
|
||||
|
||||
if *streamURL == "" {
|
||||
fmt.Fprintln(os.Stderr, "Usage: hksv-camera -url rtsp://camera/stream")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger()
|
||||
|
||||
// 1. Connect to RTSP source
|
||||
client := rtsp.NewClient(*streamURL)
|
||||
if err := client.Dial(); err != nil {
|
||||
log.Fatal().Err(err).Msg("RTSP dial failed")
|
||||
}
|
||||
if err := client.Describe(); err != nil {
|
||||
log.Fatal().Err(err).Msg("RTSP describe failed")
|
||||
}
|
||||
|
||||
log.Info().Str("url", *streamURL).Int("tracks", len(client.Medias)).Msg("RTSP connected")
|
||||
|
||||
// Pre-setup all recvonly tracks so consumers can share receivers
|
||||
for _, media := range client.Medias {
|
||||
if media.Direction == core.DirectionRecvonly && len(media.Codecs) > 0 {
|
||||
if _, err := client.GetTrack(media, media.Codecs[0]); err != nil {
|
||||
log.Warn().Err(err).Str("media", media.String()).Msg("track setup failed")
|
||||
} else {
|
||||
log.Info().Str("media", media.String()).Msg("track ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Listen for HAP connections
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("listen failed")
|
||||
}
|
||||
actualPort := uint16(ln.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
// 3. Load saved pairings
|
||||
store := &filePairingStore{path: *pairFile}
|
||||
pairings := store.Load()
|
||||
|
||||
// 4. Create HKSV server
|
||||
srv, err := hksv.NewServer(hksv.Config{
|
||||
StreamName: "camera",
|
||||
Pin: *pin,
|
||||
HKSV: true,
|
||||
MotionMode: *motion,
|
||||
MotionThreshold: *threshold,
|
||||
Streams: &streamProvider{client: client, log: log},
|
||||
Store: store,
|
||||
Pairings: pairings,
|
||||
Logger: log,
|
||||
Port: actualPort,
|
||||
UserAgent: "hksv-example",
|
||||
Version: "1.0.0",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("server create failed")
|
||||
}
|
||||
|
||||
// 5. Start mDNS advertisement
|
||||
go func() {
|
||||
if err := mdns.Serve(mdns.ServiceHAP, []*mdns.ServiceEntry{srv.MDNSEntry()}); err != nil {
|
||||
log.Error().Err(err).Msg("mDNS failed")
|
||||
}
|
||||
}()
|
||||
|
||||
// 6. Start RTSP streaming (after everything is set up)
|
||||
go func() {
|
||||
if err := client.Start(); err != nil {
|
||||
log.Error().Err(err).Msg("RTSP stream ended")
|
||||
}
|
||||
}()
|
||||
|
||||
// 7. Start HTTP server for HAP protocol
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(hap.PathPairSetup, srv.Handle)
|
||||
mux.HandleFunc(hap.PathPairVerify, srv.Handle)
|
||||
go func() {
|
||||
if err := http.Serve(ln, mux); err != nil {
|
||||
log.Fatal().Err(err).Msg("HTTP server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
// Print server info
|
||||
info, _ := json.MarshalIndent(srv, "", " ")
|
||||
fmt.Fprintf(os.Stderr, "\nHomeKit camera ready on port %d\n%s\n\n", actualPort, info)
|
||||
|
||||
// Wait for shutdown signal
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Info().Msg("shutting down")
|
||||
_ = client.Stop()
|
||||
}
|
||||
|
||||
// streamProvider connects HKSV consumers to the RTSP producer.
|
||||
// It implements hksv.StreamProvider.
|
||||
type streamProvider struct {
|
||||
client *rtsp.Conn
|
||||
log zerolog.Logger
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *streamProvider) AddConsumer(_ string, cons core.Consumer) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var matched int
|
||||
|
||||
for _, consMedia := range cons.GetMedias() {
|
||||
if consMedia.Direction != core.DirectionSendonly {
|
||||
continue
|
||||
}
|
||||
for _, prodMedia := range p.client.Medias {
|
||||
prodCodec, consCodec := prodMedia.MatchMedia(consMedia)
|
||||
if prodCodec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
track, err := p.client.GetTrack(prodMedia, prodCodec)
|
||||
if err != nil {
|
||||
p.log.Warn().Err(err).Str("codec", prodCodec.Name).Msg("get track failed")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := cons.AddTrack(consMedia, consCodec, track); err != nil {
|
||||
p.log.Warn().Err(err).Str("codec", consCodec.Name).Msg("add track failed")
|
||||
continue
|
||||
}
|
||||
|
||||
matched++
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matched == 0 {
|
||||
return fmt.Errorf("no matching codecs between RTSP stream and consumer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *streamProvider) RemoveConsumer(_ string, _ core.Consumer) {}
|
||||
|
||||
// filePairingStore persists HomeKit pairings to a JSON file.
|
||||
type filePairingStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (s *filePairingStore) Load() []string {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var pairings []string
|
||||
_ = json.Unmarshal(data, &pairings)
|
||||
return pairings
|
||||
}
|
||||
|
||||
func (s *filePairingStore) SavePairings(_ string, pairings []string) error {
|
||||
data, err := json.Marshal(pairings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
)
|
||||
|
||||
// CalcName generates a HomeKit display name from a seed if name is empty.
|
||||
func CalcName(name, seed string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("go2rtc-%02X%02X", b[0], b[2])
|
||||
}
|
||||
|
||||
// CalcDeviceID generates a MAC-like device ID from a seed if deviceID is empty.
|
||||
func CalcDeviceID(deviceID, seed string) string {
|
||||
if deviceID != "" {
|
||||
if len(deviceID) >= 17 {
|
||||
return deviceID
|
||||
}
|
||||
seed = deviceID
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X:%02X:%02X:%02X:%02X:%02X", b[32], b[34], b[36], b[38], b[40], b[42])
|
||||
}
|
||||
|
||||
// CalcDevicePrivate generates an ed25519 private key from a seed if private is empty.
|
||||
func CalcDevicePrivate(private, seed string) []byte {
|
||||
if private != "" {
|
||||
if b, _ := hex.DecodeString(private); len(b) == ed25519.PrivateKeySize {
|
||||
return b
|
||||
}
|
||||
seed = private
|
||||
}
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return ed25519.NewKeyFromSeed(b[:ed25519.SeedSize])
|
||||
}
|
||||
|
||||
// CalcSetupID generates a setup ID from a seed.
|
||||
func CalcSetupID(seed string) string {
|
||||
b := sha512.Sum512([]byte(seed))
|
||||
return fmt.Sprintf("%02X%02X", b[44], b[46])
|
||||
}
|
||||
|
||||
// CalcCategoryID converts a category string to a HAP category constant.
|
||||
func CalcCategoryID(categoryID string) string {
|
||||
switch categoryID {
|
||||
case "bridge":
|
||||
return hap.CategoryBridge
|
||||
case "doorbell":
|
||||
return hap.CategoryDoorbell
|
||||
}
|
||||
if core.Atoi(categoryID) > 0 {
|
||||
return categoryID
|
||||
}
|
||||
return hap.CategoryCamera
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- CalcName ---
|
||||
|
||||
func TestCalcName_CustomName(t *testing.T) {
|
||||
require.Equal(t, "MyCamera", CalcName("MyCamera", "anything"))
|
||||
}
|
||||
|
||||
func TestCalcName_Generated(t *testing.T) {
|
||||
name := CalcName("", "camera1")
|
||||
require.Regexp(t, `^go2rtc-[0-9A-F]{4}$`, name)
|
||||
}
|
||||
|
||||
func TestCalcName_Deterministic(t *testing.T) {
|
||||
require.Equal(t, CalcName("", "seed"), CalcName("", "seed"))
|
||||
}
|
||||
|
||||
func TestCalcName_DifferentSeeds(t *testing.T) {
|
||||
require.NotEqual(t, CalcName("", "a"), CalcName("", "b"))
|
||||
}
|
||||
|
||||
// --- CalcDeviceID ---
|
||||
|
||||
var macRe = regexp.MustCompile(`^[0-9A-F]{2}(:[0-9A-F]{2}){5}$`)
|
||||
|
||||
func TestCalcDeviceID_Generated(t *testing.T) {
|
||||
id := CalcDeviceID("", "seed")
|
||||
require.Regexp(t, macRe, id)
|
||||
}
|
||||
|
||||
func TestCalcDeviceID_CustomFull(t *testing.T) {
|
||||
// Full MAC-length ID returned as-is
|
||||
require.Equal(t, "AA:BB:CC:DD:EE:FF", CalcDeviceID("AA:BB:CC:DD:EE:FF", "seed"))
|
||||
}
|
||||
|
||||
func TestCalcDeviceID_CustomShort(t *testing.T) {
|
||||
// Short custom ID used as seed instead
|
||||
id := CalcDeviceID("short", "seed")
|
||||
require.Regexp(t, macRe, id)
|
||||
// Should differ from empty seed because "short" is used as seed
|
||||
require.NotEqual(t, CalcDeviceID("", "seed"), id)
|
||||
}
|
||||
|
||||
func TestCalcDeviceID_Deterministic(t *testing.T) {
|
||||
require.Equal(t, CalcDeviceID("", "cam1"), CalcDeviceID("", "cam1"))
|
||||
}
|
||||
|
||||
// --- CalcDevicePrivate ---
|
||||
|
||||
func TestCalcDevicePrivate_Generated(t *testing.T) {
|
||||
key := CalcDevicePrivate("", "seed")
|
||||
require.Len(t, key, ed25519.PrivateKeySize)
|
||||
}
|
||||
|
||||
func TestCalcDevicePrivate_ValidHex(t *testing.T) {
|
||||
// Generate a key, encode to hex, pass back — should get same key
|
||||
original := CalcDevicePrivate("", "seed")
|
||||
hexStr := hex.EncodeToString(original)
|
||||
restored := CalcDevicePrivate(hexStr, "other-seed")
|
||||
require.Equal(t, original, restored)
|
||||
}
|
||||
|
||||
func TestCalcDevicePrivate_InvalidHex(t *testing.T) {
|
||||
// Invalid hex treated as seed
|
||||
key := CalcDevicePrivate("not-hex", "seed")
|
||||
require.Len(t, key, ed25519.PrivateKeySize)
|
||||
// "not-hex" is used as seed, not "seed"
|
||||
require.NotEqual(t, CalcDevicePrivate("", "seed"), key)
|
||||
}
|
||||
|
||||
func TestCalcDevicePrivate_ShortHex(t *testing.T) {
|
||||
// Valid hex but too short for ed25519 — treated as seed
|
||||
key := CalcDevicePrivate("abcd", "seed")
|
||||
require.Len(t, key, ed25519.PrivateKeySize)
|
||||
}
|
||||
|
||||
func TestCalcDevicePrivate_Deterministic(t *testing.T) {
|
||||
require.Equal(t, CalcDevicePrivate("", "x"), CalcDevicePrivate("", "x"))
|
||||
}
|
||||
|
||||
func TestCalcDevicePrivate_SignsCorrectly(t *testing.T) {
|
||||
// Verify the generated key is actually usable for signing
|
||||
key := ed25519.PrivateKey(CalcDevicePrivate("", "seed"))
|
||||
msg := []byte("test message")
|
||||
sig := ed25519.Sign(key, msg)
|
||||
pub := key.Public().(ed25519.PublicKey)
|
||||
require.True(t, ed25519.Verify(pub, msg, sig))
|
||||
}
|
||||
|
||||
// --- CalcSetupID ---
|
||||
|
||||
func TestCalcSetupID(t *testing.T) {
|
||||
id := CalcSetupID("seed")
|
||||
require.Regexp(t, `^[0-9A-F]{4}$`, id)
|
||||
}
|
||||
|
||||
func TestCalcSetupID_Deterministic(t *testing.T) {
|
||||
require.Equal(t, CalcSetupID("x"), CalcSetupID("x"))
|
||||
}
|
||||
|
||||
func TestCalcSetupID_DifferentSeeds(t *testing.T) {
|
||||
require.NotEqual(t, CalcSetupID("a"), CalcSetupID("b"))
|
||||
}
|
||||
|
||||
// --- CalcCategoryID ---
|
||||
|
||||
func TestCalcCategoryID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"", hap.CategoryCamera},
|
||||
{"camera", hap.CategoryCamera},
|
||||
{"bridge", hap.CategoryBridge},
|
||||
{"doorbell", hap.CategoryDoorbell},
|
||||
{"5", "5"},
|
||||
{"17", "17"},
|
||||
{"0", hap.CategoryCamera}, // Atoi("0") == 0, not > 0
|
||||
{"abc", hap.CategoryCamera}, // unknown string
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run("input="+tc.input, func(t *testing.T) {
|
||||
require.Equal(t, tc.expected, CalcCategoryID(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
// Package hksv provides a reusable HomeKit Secure Video server library.
|
||||
//
|
||||
// It implements HKSV recording (fMP4 over HDS DataStream), motion detection,
|
||||
// and integrates with the HAP protocol for HomeKit pairing and communication.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// srv, err := hksv.NewServer(hksv.Config{
|
||||
// StreamName: "camera1",
|
||||
// Pin: "27041991",
|
||||
// HKSV: true,
|
||||
// MotionMode: "detect",
|
||||
// Streams: myStreamProvider,
|
||||
// Logger: logger,
|
||||
// Port: 8080,
|
||||
// })
|
||||
// // Register srv.Handle as HTTP handler for HAP paths
|
||||
// // Advertise srv.MDNSEntry() via mDNS
|
||||
//
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/camera"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/tlv8"
|
||||
"github.com/AlexxIT/go2rtc/pkg/homekit"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// StreamProvider provides access to media streams.
|
||||
// The host application implements this to connect the HKSV library
|
||||
// to its own stream management system.
|
||||
type StreamProvider interface {
|
||||
// AddConsumer connects a consumer to the named stream.
|
||||
AddConsumer(streamName string, consumer core.Consumer) error
|
||||
// RemoveConsumer disconnects a consumer from the named stream.
|
||||
RemoveConsumer(streamName string, consumer core.Consumer)
|
||||
}
|
||||
|
||||
// PairingStore persists HAP pairing data.
|
||||
type PairingStore interface {
|
||||
SavePairings(streamName string, pairings []string) error
|
||||
}
|
||||
|
||||
// SnapshotProvider generates JPEG snapshots for HomeKit /resource requests.
|
||||
type SnapshotProvider interface {
|
||||
GetSnapshot(streamName string, width, height int) ([]byte, error)
|
||||
}
|
||||
|
||||
// LiveStreamHandler handles live-streaming requests (SetupEndpoints, SelectedStreamConfiguration).
|
||||
// Implementation is external because it depends on SRTP.
|
||||
type LiveStreamHandler interface {
|
||||
// SetupEndpoints handles a SetupEndpoints request (ch118).
|
||||
// Returns the response to store as characteristic value.
|
||||
SetupEndpoints(conn net.Conn, offer *camera.SetupEndpointsRequest) (any, error)
|
||||
|
||||
// GetEndpointsResponse returns the current endpoints response (for GET requests).
|
||||
GetEndpointsResponse() any
|
||||
|
||||
// StartStream starts RTP streaming with the given configuration (ch117 command=start).
|
||||
// The connTracker is used to register/unregister the live stream connection.
|
||||
StartStream(streamName string, conf *camera.SelectedStreamConfiguration, connTracker ConnTracker) error
|
||||
|
||||
// StopStream stops a stream matching the given session ID.
|
||||
StopStream(sessionID string, connTracker ConnTracker) error
|
||||
}
|
||||
|
||||
// ConnTracker allows the live stream handler to track connections on the server.
|
||||
type ConnTracker interface {
|
||||
AddConn(v any)
|
||||
DelConn(v any)
|
||||
}
|
||||
|
||||
// Config for creating an HKSV server.
|
||||
type Config struct {
|
||||
StreamName string
|
||||
Pin string // HomeKit pairing PIN (e.g., "27041991")
|
||||
Name string // mDNS display name (auto-generated if empty)
|
||||
DeviceID string // MAC-like device ID (auto-generated if empty)
|
||||
DevicePrivate string // ed25519 private key hex (auto-generated if empty)
|
||||
CategoryID string // "camera" or "doorbell"
|
||||
Pairings []string // pre-existing pairings
|
||||
ProxyURL string // if set, acts as transparent proxy (no local accessory)
|
||||
HKSV bool
|
||||
MotionMode string // "api", "continuous", "detect"
|
||||
MotionThreshold float64 // ratio threshold for "detect" mode (default 2.0)
|
||||
Speaker *bool // include Speaker service for 2-way audio (default false)
|
||||
UserAgent string // for mDNS TXTModel field
|
||||
Version string // for accessory firmware version
|
||||
|
||||
// Dependencies (injected by host)
|
||||
Streams StreamProvider
|
||||
Store PairingStore // optional, nil = no persistence
|
||||
Snapshots SnapshotProvider // optional, nil = no snapshots
|
||||
LiveStream LiveStreamHandler // optional, nil = HKSV only (no live streaming)
|
||||
Logger zerolog.Logger
|
||||
|
||||
// Network
|
||||
Port uint16 // HAP HTTP port
|
||||
}
|
||||
|
||||
// Server is a complete HKSV camera server.
|
||||
type Server struct {
|
||||
hap *hap.Server
|
||||
mdns *mdns.ServiceEntry
|
||||
log zerolog.Logger
|
||||
|
||||
pairings []string
|
||||
conns []any
|
||||
mu sync.Mutex
|
||||
|
||||
accessory *hap.Accessory
|
||||
setupID string
|
||||
stream string // stream name
|
||||
|
||||
proxyURL string // transparent proxy URL
|
||||
|
||||
// Injected dependencies
|
||||
streams StreamProvider
|
||||
store PairingStore
|
||||
snapshots SnapshotProvider
|
||||
liveStream LiveStreamHandler
|
||||
|
||||
// HKSV fields
|
||||
motionMode string
|
||||
motionThreshold float64
|
||||
motionDetector *MotionDetector
|
||||
hksvSession *hksvSession
|
||||
continuousMotion bool
|
||||
preparedConsumer *HKSVConsumer
|
||||
}
|
||||
|
||||
// NewServer creates a new HKSV server with the given configuration.
|
||||
func NewServer(cfg Config) (*Server, error) {
|
||||
if cfg.Pin == "" {
|
||||
cfg.Pin = "27041991"
|
||||
}
|
||||
|
||||
pin, err := hap.SanitizePin(cfg.Pin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hksv: invalid pin: %w", err)
|
||||
}
|
||||
|
||||
deviceID := CalcDeviceID(cfg.DeviceID, cfg.StreamName)
|
||||
name := CalcName(cfg.Name, deviceID)
|
||||
setupID := CalcSetupID(cfg.StreamName)
|
||||
|
||||
srv := &Server{
|
||||
stream: cfg.StreamName,
|
||||
pairings: cfg.Pairings,
|
||||
setupID: setupID,
|
||||
log: cfg.Logger,
|
||||
streams: cfg.Streams,
|
||||
store: cfg.Store,
|
||||
snapshots: cfg.Snapshots,
|
||||
liveStream: cfg.LiveStream,
|
||||
motionMode: cfg.MotionMode,
|
||||
motionThreshold: cfg.MotionThreshold,
|
||||
}
|
||||
|
||||
srv.hap = &hap.Server{
|
||||
Pin: pin,
|
||||
DeviceID: deviceID,
|
||||
DevicePrivate: CalcDevicePrivate(cfg.DevicePrivate, cfg.StreamName),
|
||||
GetClientPublic: srv.GetPair,
|
||||
}
|
||||
|
||||
categoryID := CalcCategoryID(cfg.CategoryID)
|
||||
|
||||
srv.mdns = &mdns.ServiceEntry{
|
||||
Name: name,
|
||||
Port: cfg.Port,
|
||||
Info: map[string]string{
|
||||
hap.TXTConfigNumber: "1",
|
||||
hap.TXTFeatureFlags: "0",
|
||||
hap.TXTDeviceID: deviceID,
|
||||
hap.TXTModel: cfg.UserAgent,
|
||||
hap.TXTProtoVersion: "1.1",
|
||||
hap.TXTStateNumber: "1",
|
||||
hap.TXTStatusFlags: hap.StatusNotPaired,
|
||||
hap.TXTCategory: categoryID,
|
||||
hap.TXTSetupHash: hap.SetupHash(setupID, deviceID),
|
||||
},
|
||||
}
|
||||
|
||||
srv.UpdateStatus()
|
||||
|
||||
if cfg.ProxyURL != "" {
|
||||
// Proxy mode: no local accessory
|
||||
srv.proxyURL = cfg.ProxyURL
|
||||
} else if cfg.HKSV {
|
||||
if srv.motionThreshold <= 0 {
|
||||
srv.motionThreshold = defaultThreshold
|
||||
}
|
||||
srv.log.Debug().Str("stream", cfg.StreamName).Str("motion", cfg.MotionMode).
|
||||
Float64("threshold", srv.motionThreshold).Msg("[hksv] HKSV mode")
|
||||
|
||||
if cfg.CategoryID == "doorbell" {
|
||||
srv.accessory = camera.NewHKSVDoorbellAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
} else {
|
||||
srv.accessory = camera.NewHKSVAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
}
|
||||
} else {
|
||||
srv.accessory = camera.NewAccessory("AlexxIT", "go2rtc", name, "-", cfg.Version)
|
||||
}
|
||||
|
||||
// Remove Speaker service unless explicitly enabled (default: disabled)
|
||||
if (cfg.Speaker == nil || !*cfg.Speaker) && srv.accessory != nil {
|
||||
filtered := srv.accessory.Services[:0]
|
||||
for _, svc := range srv.accessory.Services {
|
||||
if svc.Type != "113" { // 113 = Speaker
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
srv.accessory.Services = filtered
|
||||
srv.accessory.InitIID() // recalculate IIDs
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// MDNSEntry returns the mDNS service entry for advertisement.
|
||||
func (s *Server) MDNSEntry() *mdns.ServiceEntry {
|
||||
return s.mdns
|
||||
}
|
||||
|
||||
// Accessory returns the HAP accessory.
|
||||
func (s *Server) Accessory() *hap.Accessory {
|
||||
return s.accessory
|
||||
}
|
||||
|
||||
// StreamName returns the configured stream name.
|
||||
func (s *Server) StreamName() string {
|
||||
return s.stream
|
||||
}
|
||||
|
||||
func (s *Server) MarshalJSON() ([]byte, error) {
|
||||
v := struct {
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Paired int `json:"paired,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
SetupCode string `json:"setup_code,omitempty"`
|
||||
SetupID string `json:"setup_id,omitempty"`
|
||||
Conns []any `json:"connections,omitempty"`
|
||||
}{
|
||||
Name: s.mdns.Name,
|
||||
DeviceID: s.mdns.Info[hap.TXTDeviceID],
|
||||
CategoryID: s.mdns.Info[hap.TXTCategory],
|
||||
Paired: len(s.pairings),
|
||||
Conns: s.conns,
|
||||
}
|
||||
if v.Paired == 0 {
|
||||
v.SetupCode = s.hap.Pin
|
||||
v.SetupID = s.setupID
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// Handle processes an incoming HAP connection (called from your HTTP server).
|
||||
func (s *Server) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
// Fix reading from Body after Hijack.
|
||||
r.Body = io.NopCloser(rw)
|
||||
|
||||
switch r.RequestURI {
|
||||
case hap.PathPairSetup:
|
||||
id, key, err := s.hap.PairSetup(r, rw)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.AddPair(id, key, hap.PermissionAdmin)
|
||||
|
||||
case hap.PathPairVerify:
|
||||
id, key, err := s.hap.PairVerify(r, rw)
|
||||
if err != nil {
|
||||
s.log.Debug().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Str("client_id", id).Msgf("[hksv] %s: new conn", conn.RemoteAddr())
|
||||
|
||||
controller, err := hap.NewConn(conn, rw, key, false)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(controller)
|
||||
defer s.DelConn(controller)
|
||||
|
||||
// start motion on first Home Hub connection
|
||||
switch s.motionMode {
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
case "continuous":
|
||||
go s.prepareHKSVConsumer()
|
||||
go s.startContinuousMotion()
|
||||
}
|
||||
|
||||
var handler homekit.HandlerFunc
|
||||
|
||||
switch {
|
||||
case s.accessory != nil:
|
||||
handler = homekit.ServerHandler(s)
|
||||
case s.proxyURL != "":
|
||||
client, err := hap.Dial(s.proxyURL)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Caller().Send()
|
||||
return
|
||||
}
|
||||
handler = homekit.ProxyHandler(s, client.Conn)
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] handler started for %s", conn.RemoteAddr())
|
||||
|
||||
if err = handler(controller); err != nil {
|
||||
if errors.Is(err, io.EOF) || isClosedConnErr(err) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] %s: connection closed", conn.RemoteAddr())
|
||||
} else {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Caller().Send()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddConn registers a connection for tracking.
|
||||
func (s *Server) AddConn(v any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] add conn %s", connLabel(v))
|
||||
s.mu.Lock()
|
||||
s.conns = append(s.conns, v)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// DelConn unregisters a connection.
|
||||
func (s *Server) DelConn(v any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] del conn %s", connLabel(v))
|
||||
s.mu.Lock()
|
||||
if i := slices.Index(s.conns, v); i >= 0 {
|
||||
s.conns = slices.Delete(s.conns, i, i+1)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// connLabel returns a short human-readable label for a connection.
|
||||
func connLabel(v any) string {
|
||||
switch v := v.(type) {
|
||||
case *hap.Conn:
|
||||
return "hap " + v.RemoteAddr().String()
|
||||
case *hds.Conn:
|
||||
return "hds " + v.RemoteAddr().String()
|
||||
}
|
||||
if s, ok := v.(fmt.Stringer); ok {
|
||||
return s.String()
|
||||
}
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
|
||||
func (s *Server) UpdateStatus() {
|
||||
if len(s.pairings) == 0 {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusNotPaired
|
||||
} else {
|
||||
s.mdns.Info[hap.TXTStatusFlags] = hap.StatusPaired
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pairIndex(id string) int {
|
||||
id = "client_id=" + id
|
||||
for i, pairing := range s.pairings {
|
||||
if strings.HasPrefix(pairing, id) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *Server) GetPair(id string) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if i := s.pairIndex(id); i >= 0 {
|
||||
query, _ := url.ParseQuery(s.pairings[i])
|
||||
b, _ := hex.DecodeString(query.Get("client_public"))
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) AddPair(id string, public []byte, permissions byte) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] add pair id=%s public=%x perm=%d", id, public, permissions)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.pairIndex(id) < 0 {
|
||||
s.pairings = append(s.pairings, fmt.Sprintf(
|
||||
"client_id=%s&client_public=%x&permissions=%d", id, public, permissions,
|
||||
))
|
||||
s.UpdateStatus()
|
||||
s.savePairings()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) DelPair(id string) {
|
||||
s.log.Debug().Str("stream", s.stream).Msgf("[hksv] del pair id=%s", id)
|
||||
|
||||
s.mu.Lock()
|
||||
if i := s.pairIndex(id); i >= 0 {
|
||||
s.pairings = append(s.pairings[:i], s.pairings[i+1:]...)
|
||||
s.UpdateStatus()
|
||||
s.savePairings()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) savePairings() {
|
||||
if s.store != nil {
|
||||
if err := s.store.SavePairings(s.stream, s.pairings); err != nil {
|
||||
s.log.Error().Err(err).Msgf("[hksv] can't save %s pairings=%v", s.stream, s.pairings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetAccessories(_ net.Conn) []*hap.Accessory {
|
||||
s.log.Trace().Str("stream", s.stream).Msg("[hksv] GET /accessories")
|
||||
if s.log.Trace().Enabled() {
|
||||
if b, err := json.Marshal(s.accessory); err == nil {
|
||||
s.log.Trace().Str("stream", s.stream).Str("accessory", string(b)).Msg("[hksv] accessory JSON")
|
||||
}
|
||||
}
|
||||
return []*hap.Accessory{s.accessory}
|
||||
}
|
||||
|
||||
func (s *Server) GetCharacteristic(conn net.Conn, aid uint8, iid uint64) any {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] get char aid=%d iid=0x%x", aid, iid)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
s.log.Warn().Msgf("[hksv] get unknown characteristic: %d", iid)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.liveStream != nil {
|
||||
return s.liveStream.GetEndpointsResponse()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return char.Value
|
||||
}
|
||||
|
||||
func (s *Server) SetCharacteristic(conn net.Conn, aid uint8, iid uint64, value any) {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] set char aid=%d iid=0x%x value=%v", aid, iid, value)
|
||||
|
||||
char := s.accessory.GetCharacterByID(iid)
|
||||
if char == nil {
|
||||
s.log.Warn().Msgf("[hksv] set unknown characteristic: %d", iid)
|
||||
return
|
||||
}
|
||||
|
||||
switch char.Type {
|
||||
case camera.TypeSetupEndpoints:
|
||||
if s.liveStream == nil {
|
||||
return
|
||||
}
|
||||
var offer camera.SetupEndpointsRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &offer); err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := s.liveStream.SetupEndpoints(conn, &offer)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("[hksv] setup endpoints failed")
|
||||
return
|
||||
}
|
||||
// Keep the latest response in characteristic value for write-response (r=true)
|
||||
// and subsequent GET /characteristics reads.
|
||||
char.Value = resp
|
||||
|
||||
case camera.TypeSelectedStreamConfiguration:
|
||||
if s.liveStream == nil {
|
||||
return
|
||||
}
|
||||
var conf camera.SelectedStreamConfiguration
|
||||
if err := tlv8.UnmarshalBase64(value, &conf); err != nil {
|
||||
return
|
||||
}
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] stream id=%x cmd=%d", conf.Control.SessionID, conf.Control.Command)
|
||||
|
||||
switch conf.Control.Command {
|
||||
case camera.SessionCommandEnd:
|
||||
_ = s.liveStream.StopStream(conf.Control.SessionID, s)
|
||||
case camera.SessionCommandStart:
|
||||
_ = s.liveStream.StartStream(s.stream, &conf, s)
|
||||
}
|
||||
|
||||
case camera.TypeSetupDataStreamTransport:
|
||||
var req camera.SetupDataStreamTransportRequest
|
||||
if err := tlv8.UnmarshalBase64(value, &req); err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] parse ch131 failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Uint8("cmd", req.SessionCommandType).
|
||||
Uint8("transport", req.TransportType).Msg("[hksv] DataStream setup")
|
||||
|
||||
if req.SessionCommandType != 0 {
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] DataStream close request")
|
||||
if s.hksvSession != nil {
|
||||
s.hksvSession.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
accessoryKeySalt := core.RandString(32, 0)
|
||||
combinedSalt := req.ControllerKeySalt + accessoryKeySalt
|
||||
|
||||
ln, err := net.ListenTCP("tcp", nil)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] listen failed")
|
||||
return
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
resp := camera.SetupDataStreamTransportResponse{
|
||||
Status: 0,
|
||||
AccessoryKeySalt: accessoryKeySalt,
|
||||
}
|
||||
resp.TransportTypeSessionParameters.TCPListeningPort = uint16(port)
|
||||
|
||||
v, err := tlv8.MarshalBase64(resp)
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return
|
||||
}
|
||||
char.Value = v
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Int("port", port).Msg("[hksv] listening for HDS")
|
||||
|
||||
hapConn := conn.(*hap.Conn)
|
||||
go s.acceptHDS(hapConn, ln, combinedSalt)
|
||||
|
||||
case camera.TypeSelectedCameraRecordingConfiguration:
|
||||
s.log.Debug().Str("stream", s.stream).Str("motion", s.motionMode).Msg("[hksv] selected recording config")
|
||||
char.Value = value
|
||||
|
||||
switch s.motionMode {
|
||||
case "continuous":
|
||||
go s.startContinuousMotion()
|
||||
case "detect":
|
||||
go s.startMotionDetector()
|
||||
}
|
||||
|
||||
default:
|
||||
char.Value = value
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetImage(conn net.Conn, width, height int) []byte {
|
||||
s.log.Trace().Str("stream", s.stream).Msgf("[hksv] get image width=%d height=%d", width, height)
|
||||
|
||||
if s.snapshots == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := s.snapshots.GetSnapshot(s.stream, width, height)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("[hksv] snapshot failed")
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetMotionDetected triggers or clears the motion detected characteristic.
|
||||
func (s *Server) SetMotionDetected(detected bool) {
|
||||
if s.accessory == nil {
|
||||
s.log.Warn().Str("stream", s.stream).Msg("[hksv] SetMotionDetected: accessory is nil")
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("22") // MotionDetected
|
||||
if char == nil {
|
||||
s.log.Warn().Str("stream", s.stream).Msg("[hksv] SetMotionDetected: char 22 (MotionDetected) not found")
|
||||
return
|
||||
}
|
||||
char.Value = detected
|
||||
listeners := char.ListenerCount()
|
||||
err := char.NotifyListeners(nil)
|
||||
s.log.Debug().Str("stream", s.stream).Bool("motion", detected).
|
||||
Int("listeners", listeners).Err(err).Msg("[hksv] motion")
|
||||
}
|
||||
|
||||
// MotionDetected returns the current motion detected state.
|
||||
func (s *Server) MotionDetected() bool {
|
||||
if s.accessory == nil {
|
||||
return false
|
||||
}
|
||||
char := s.accessory.GetCharacter("22") // MotionDetected
|
||||
if char == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := char.Value.(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// TriggerDoorbell triggers a doorbell press event.
|
||||
func (s *Server) TriggerDoorbell() {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
char := s.accessory.GetCharacter("73") // ProgrammableSwitchEvent
|
||||
if char == nil {
|
||||
return
|
||||
}
|
||||
char.Value = 0 // SINGLE_PRESS
|
||||
_ = char.NotifyListeners(nil)
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] doorbell")
|
||||
}
|
||||
|
||||
// acceptHDS opens a TCP listener for the HDS DataStream connection from the Home Hub
|
||||
func (s *Server) acceptHDS(hapConn *hap.Conn, ln net.Listener, salt string) {
|
||||
defer ln.Close()
|
||||
|
||||
if tcpLn, ok := ln.(*net.TCPListener); ok {
|
||||
_ = tcpLn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
}
|
||||
|
||||
rawConn, err := ln.Accept()
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] accept failed")
|
||||
return
|
||||
}
|
||||
defer rawConn.Close()
|
||||
|
||||
hdsConn, err := hds.NewConn(rawConn, hapConn.SharedKey, salt, false)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] hds conn failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.AddConn(hdsConn)
|
||||
defer s.DelConn(hdsConn)
|
||||
|
||||
session := newHKSVSession(s, hapConn, hdsConn)
|
||||
|
||||
s.mu.Lock()
|
||||
s.hksvSession = session
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.hksvSession == session {
|
||||
s.hksvSession = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
session.Close()
|
||||
}()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] session started")
|
||||
|
||||
if err := session.Run(); err != nil {
|
||||
s.log.Debug().Err(err).Str("stream", s.stream).Msg("[hksv] session ended")
|
||||
}
|
||||
}
|
||||
|
||||
// prepareHKSVConsumer pre-starts a consumer and adds it to the stream.
|
||||
func (s *Server) prepareHKSVConsumer() {
|
||||
consumer := NewHKSVConsumer(s.log)
|
||||
|
||||
if err := s.streams.AddConsumer(s.stream, consumer); err != nil {
|
||||
s.log.Debug().Err(err).Str("stream", s.stream).Msg("[hksv] prepare consumer failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] consumer prepared")
|
||||
|
||||
s.mu.Lock()
|
||||
if s.preparedConsumer != nil {
|
||||
old := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
s.streams.RemoveConsumer(s.stream, old)
|
||||
_ = old.Stop()
|
||||
s.mu.Lock()
|
||||
}
|
||||
s.preparedConsumer = consumer
|
||||
s.mu.Unlock()
|
||||
|
||||
// Keep alive until used or timeout (60 seconds)
|
||||
select {
|
||||
case <-consumer.Done():
|
||||
// consumer was stopped (used or server closed)
|
||||
case <-time.After(60 * time.Second):
|
||||
s.mu.Lock()
|
||||
if s.preparedConsumer == consumer {
|
||||
s.preparedConsumer = nil
|
||||
s.mu.Unlock()
|
||||
s.streams.RemoveConsumer(s.stream, consumer)
|
||||
_ = consumer.Stop()
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] prepared consumer expired")
|
||||
} else {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) takePreparedConsumer() *HKSVConsumer {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
consumer := s.preparedConsumer
|
||||
s.preparedConsumer = nil
|
||||
return consumer
|
||||
}
|
||||
|
||||
func (s *Server) startMotionDetector() {
|
||||
s.mu.Lock()
|
||||
if s.motionDetector != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
det := NewMotionDetector(s.motionThreshold, s.SetMotionDetected, s.log)
|
||||
s.motionDetector = det
|
||||
s.mu.Unlock()
|
||||
|
||||
s.AddConn(det)
|
||||
|
||||
if err := s.streams.AddConsumer(s.stream, det); err != nil {
|
||||
s.log.Error().Err(err).Str("stream", s.stream).Msg("[hksv] motion detector add consumer failed")
|
||||
s.DelConn(det)
|
||||
s.mu.Lock()
|
||||
s.motionDetector = nil
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] motion detector started")
|
||||
|
||||
_, _ = det.WriteTo(nil) // blocks until Stop()
|
||||
|
||||
s.streams.RemoveConsumer(s.stream, det)
|
||||
s.DelConn(det)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.motionDetector == det {
|
||||
s.motionDetector = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] motion detector stopped")
|
||||
}
|
||||
|
||||
func (s *Server) stopMotionDetector() {
|
||||
s.mu.Lock()
|
||||
det := s.motionDetector
|
||||
s.mu.Unlock()
|
||||
if det != nil {
|
||||
_ = det.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) startContinuousMotion() {
|
||||
s.mu.Lock()
|
||||
if s.continuousMotion {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.continuousMotion = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Debug().Str("stream", s.stream).Msg("[hksv] continuous motion started")
|
||||
|
||||
// delay to allow Home Hub to subscribe to events
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
s.SetMotionDetected(true)
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if s.accessory == nil {
|
||||
return
|
||||
}
|
||||
s.SetMotionDetected(true)
|
||||
}
|
||||
}
|
||||
|
||||
// isClosedConnErr checks if the error is a "use of closed network connection" error.
|
||||
// This happens when the remote side (e.g., iPhone) closes the TCP connection.
|
||||
func isClosedConnErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "use of closed network connection")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
motionWarmupFrames = 30
|
||||
defaultThreshold = 2.0
|
||||
motionAlphaFast = 0.1
|
||||
motionAlphaSlow = 0.02
|
||||
motionHoldTime = 30 * time.Second
|
||||
motionCooldown = 5 * time.Second
|
||||
motionDefaultFPS = 30.0
|
||||
|
||||
// recalibrate FPS and emit trace log every N frames (~5s at 30fps)
|
||||
motionTraceFrames = 150
|
||||
)
|
||||
|
||||
// MotionDetector implements core.Consumer for P-frame based motion detection.
|
||||
// It analyzes H.264 P-frame sizes using an EMA baseline and triggers a callback
|
||||
// when the frame size exceeds the baseline by the configured threshold.
|
||||
type MotionDetector struct {
|
||||
core.Connection
|
||||
done chan struct{}
|
||||
log zerolog.Logger
|
||||
|
||||
// algorithm state (accessed only from Sender goroutine — no mutex needed)
|
||||
threshold float64
|
||||
triggerLevel int // pre-computed: int(baseline * threshold)
|
||||
baseline float64
|
||||
initialized bool
|
||||
frameCount int
|
||||
|
||||
// frame-based timing (calibrated periodically, no time.Now() in per-frame hot path)
|
||||
holdBudget int // motionHoldTime converted to frames
|
||||
cooldownBudget int // motionCooldown converted to frames
|
||||
remainingHold int // frames left until hold expires (active motion)
|
||||
remainingCooldown int // frames left until cooldown expires (after OFF)
|
||||
|
||||
// motion state
|
||||
motionActive bool
|
||||
|
||||
// periodic FPS recalibration
|
||||
lastFPSCheck time.Time
|
||||
lastFPSFrame int
|
||||
|
||||
// for testing: injectable time and callback
|
||||
now func() time.Time
|
||||
OnMotion func(bool) `json:"-"` // callback when motion state changes
|
||||
}
|
||||
|
||||
// NewMotionDetector creates a new motion detector with the given threshold and callback.
|
||||
// If threshold <= 0, the default of 2.0 is used.
|
||||
// onMotion is called when motion state changes (true=detected, false=ended).
|
||||
func NewMotionDetector(threshold float64, onMotion func(bool), log zerolog.Logger) *MotionDetector {
|
||||
if threshold <= 0 {
|
||||
threshold = defaultThreshold
|
||||
}
|
||||
medias := []*core.Media{
|
||||
{
|
||||
Kind: core.KindVideo,
|
||||
Direction: core.DirectionSendonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecH264},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &MotionDetector{
|
||||
Connection: core.Connection{
|
||||
ID: core.NewID(),
|
||||
FormatName: "motion",
|
||||
Protocol: "detect",
|
||||
Medias: medias,
|
||||
},
|
||||
threshold: threshold,
|
||||
done: make(chan struct{}),
|
||||
now: time.Now,
|
||||
OnMotion: onMotion,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MotionDetector) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
|
||||
m.log.Debug().Str("codec", track.Codec.Name).Msg("[hksv] motion: add track")
|
||||
|
||||
codec := track.Codec.Clone()
|
||||
sender := core.NewSender(media, codec)
|
||||
|
||||
sender.Handler = func(packet *rtp.Packet) {
|
||||
m.handlePacket(packet)
|
||||
}
|
||||
|
||||
if track.Codec.IsRTP() {
|
||||
sender.Handler = h264.RTPDepay(track.Codec, sender.Handler)
|
||||
} else {
|
||||
sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler)
|
||||
}
|
||||
|
||||
sender.HandleRTP(track)
|
||||
m.Senders = append(m.Senders, sender)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MotionDetector) calibrate() {
|
||||
m.holdBudget = int(motionHoldTime.Seconds() * motionDefaultFPS)
|
||||
m.cooldownBudget = int(motionCooldown.Seconds() * motionDefaultFPS)
|
||||
m.triggerLevel = int(m.baseline * m.threshold)
|
||||
m.lastFPSCheck = m.now()
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
m.log.Debug().
|
||||
Float64("baseline", m.baseline).
|
||||
Int("holdFrames", m.holdBudget).Int("cooldownFrames", m.cooldownBudget).
|
||||
Msg("[hksv] motion: warmup complete")
|
||||
}
|
||||
|
||||
func (m *MotionDetector) handlePacket(packet *rtp.Packet) {
|
||||
payload := packet.Payload
|
||||
if len(payload) < 5 {
|
||||
return
|
||||
}
|
||||
|
||||
// skip keyframes — always large, not informative for motion
|
||||
if h264.IsKeyframe(payload) {
|
||||
return
|
||||
}
|
||||
|
||||
size := len(payload)
|
||||
m.frameCount++
|
||||
|
||||
if m.frameCount <= motionWarmupFrames {
|
||||
fsize := float64(size)
|
||||
if !m.initialized {
|
||||
m.baseline = fsize
|
||||
m.initialized = true
|
||||
} else {
|
||||
m.baseline += motionAlphaFast * (fsize - m.baseline)
|
||||
}
|
||||
if m.frameCount == motionWarmupFrames {
|
||||
m.calibrate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if m.triggerLevel <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// integer comparison — no float division needed
|
||||
triggered := size > m.triggerLevel
|
||||
|
||||
if !m.motionActive {
|
||||
// idle path: decrement cooldown, check for trigger, update baseline
|
||||
if m.remainingCooldown > 0 {
|
||||
m.remainingCooldown--
|
||||
}
|
||||
|
||||
if triggered && m.remainingCooldown <= 0 {
|
||||
m.motionActive = true
|
||||
m.remainingHold = m.holdBudget
|
||||
m.log.Debug().
|
||||
Float64("ratio", float64(size)/m.baseline).
|
||||
Msg("[hksv] motion: ON")
|
||||
m.setMotion(true)
|
||||
}
|
||||
|
||||
// update baseline only if still idle (trigger frame doesn't pollute baseline)
|
||||
if !m.motionActive {
|
||||
fsize := float64(size)
|
||||
m.baseline += motionAlphaSlow * (fsize - m.baseline)
|
||||
m.triggerLevel = int(m.baseline * m.threshold)
|
||||
}
|
||||
} else {
|
||||
// active motion path: pure integer arithmetic, zero time.Now() calls
|
||||
if triggered {
|
||||
m.remainingHold = m.holdBudget
|
||||
} else {
|
||||
m.remainingHold--
|
||||
if m.remainingHold <= 0 {
|
||||
m.motionActive = false
|
||||
m.remainingCooldown = m.cooldownBudget
|
||||
m.log.Debug().Msg("[hksv] motion: OFF (hold expired)")
|
||||
m.setMotion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// periodic: recalibrate FPS and emit trace log
|
||||
if m.frameCount%motionTraceFrames == 0 {
|
||||
now := m.now()
|
||||
frames := m.frameCount - m.lastFPSFrame
|
||||
if frames > 0 {
|
||||
if elapsed := now.Sub(m.lastFPSCheck); elapsed > time.Millisecond {
|
||||
fps := float64(frames) / elapsed.Seconds()
|
||||
m.holdBudget = int(motionHoldTime.Seconds() * fps)
|
||||
m.cooldownBudget = int(motionCooldown.Seconds() * fps)
|
||||
}
|
||||
}
|
||||
m.lastFPSCheck = now
|
||||
m.lastFPSFrame = m.frameCount
|
||||
|
||||
m.log.Trace().
|
||||
Float64("baseline", m.baseline).Float64("ratio", float64(size)/m.baseline).
|
||||
Bool("active", m.motionActive).Msg("[hksv] motion: status")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MotionDetector) setMotion(detected bool) {
|
||||
if m.OnMotion != nil {
|
||||
m.OnMotion(detected)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MotionDetector) String() string {
|
||||
return "motion detector"
|
||||
}
|
||||
|
||||
func (m *MotionDetector) WriteTo(io.Writer) (int64, error) {
|
||||
<-m.done
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *MotionDetector) Stop() error {
|
||||
select {
|
||||
case <-m.done:
|
||||
default:
|
||||
if m.motionActive {
|
||||
m.motionActive = false
|
||||
m.log.Debug().Msg("[hksv] motion: OFF (stop)")
|
||||
m.setMotion(false)
|
||||
}
|
||||
close(m.done)
|
||||
}
|
||||
return m.Connection.Stop()
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/h264"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// makeAVCC creates a fake AVCC packet with the given NAL type and total size.
|
||||
func makeAVCC(nalType byte, totalSize int) []byte {
|
||||
if totalSize < 5 {
|
||||
totalSize = 5
|
||||
}
|
||||
b := make([]byte, totalSize)
|
||||
binary.BigEndian.PutUint32(b[:4], uint32(totalSize-4))
|
||||
b[4] = nalType
|
||||
return b
|
||||
}
|
||||
|
||||
func makePFrame(size int) *rtp.Packet {
|
||||
return &rtp.Packet{Payload: makeAVCC(h264.NALUTypePFrame, size)}
|
||||
}
|
||||
|
||||
func makeIFrame(size int) *rtp.Packet {
|
||||
return &rtp.Packet{Payload: makeAVCC(h264.NALUTypeIFrame, size)}
|
||||
}
|
||||
|
||||
type mockClock struct {
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *mockClock) now() time.Time { return c.t }
|
||||
|
||||
func (c *mockClock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||
|
||||
type motionRecorder struct {
|
||||
calls []bool
|
||||
}
|
||||
|
||||
func (r *motionRecorder) onMotion(detected bool) {
|
||||
r.calls = append(r.calls, detected)
|
||||
}
|
||||
|
||||
func (r *motionRecorder) lastCall() (bool, bool) {
|
||||
if len(r.calls) == 0 {
|
||||
return false, false
|
||||
}
|
||||
return r.calls[len(r.calls)-1], true
|
||||
}
|
||||
|
||||
func newTestDetector() (*MotionDetector, *mockClock, *motionRecorder) {
|
||||
rec := &motionRecorder{}
|
||||
det := NewMotionDetector(0, rec.onMotion, zerolog.Nop())
|
||||
clock := &mockClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
det.now = clock.now
|
||||
return det, clock, rec
|
||||
}
|
||||
|
||||
// warmup feeds the detector with small P-frames to build baseline.
|
||||
func warmup(det *MotionDetector, clock *mockClock, size int) {
|
||||
for i := 0; i < motionWarmupFrames; i++ {
|
||||
det.handlePacket(makePFrame(size))
|
||||
clock.advance(33 * time.Millisecond) // ~30fps
|
||||
}
|
||||
}
|
||||
|
||||
// warmupWithBudgets performs warmup then sets test-friendly hold/cooldown budgets.
|
||||
func warmupWithBudgets(det *MotionDetector, clock *mockClock, size, hold, cooldown int) {
|
||||
warmup(det, clock, size)
|
||||
det.holdBudget = hold
|
||||
det.cooldownBudget = cooldown
|
||||
}
|
||||
|
||||
func TestMotionDetector_NoMotion(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// feed same-size P-frames — no motion
|
||||
for i := 0; i < 100; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatalf("expected no motion calls, got %d: %v", len(rec.calls), rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_MotionDetected(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// large P-frame triggers motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
last, ok := rec.lastCall()
|
||||
if !ok || !last {
|
||||
t.Fatal("expected motion detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_HoldTime(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
// send 20 non-triggered frames — still active (< holdBudget=30)
|
||||
for i := 0; i < 20; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
if len(rec.calls) != 1 {
|
||||
t.Fatalf("expected only ON call during hold, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// send 15 more (total 35 > holdBudget=30) — should turn OFF
|
||||
for i := 0; i < 15; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
last, _ := rec.lastCall()
|
||||
if last {
|
||||
t.Fatal("expected motion OFF after hold budget exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_Cooldown(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger and expire motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
for i := 0; i < 30; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
if len(rec.calls) != 2 || rec.calls[1] != false {
|
||||
t.Fatalf("expected ON then OFF, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// try to trigger again immediately — should be blocked by cooldown
|
||||
det.handlePacket(makePFrame(5000))
|
||||
if len(rec.calls) != 2 {
|
||||
t.Fatalf("expected cooldown to block re-trigger, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// send frames to expire cooldown (blocked trigger consumed 1 decrement)
|
||||
for i := 0; i < 5; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// now re-trigger should work
|
||||
det.handlePacket(makePFrame(5000))
|
||||
if len(rec.calls) != 3 || !rec.calls[2] {
|
||||
t.Fatalf("expected motion ON after cooldown, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_SkipsKeyframes(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// huge keyframe should not trigger motion
|
||||
det.handlePacket(makeIFrame(50000))
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatal("keyframes should not trigger motion")
|
||||
}
|
||||
|
||||
// verify baseline didn't change
|
||||
det.handlePacket(makePFrame(500))
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatal("baseline should be unaffected by keyframes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_Warmup(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
// during warmup, even large frames should not trigger
|
||||
for i := 0; i < motionWarmupFrames; i++ {
|
||||
det.handlePacket(makePFrame(5000))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
}
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatal("warmup should not trigger motion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_BaselineFreeze(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
baselineBefore := det.baseline
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
// feed large frames during motion — baseline should not change
|
||||
for i := 0; i < 50; i++ {
|
||||
det.handlePacket(makePFrame(5000))
|
||||
}
|
||||
|
||||
if det.baseline != baselineBefore {
|
||||
t.Fatalf("baseline changed during motion: %f -> %f", baselineBefore, det.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_CustomThreshold(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
det.threshold = 1.5
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// 1.6x — below default 2.0 but above custom 1.5
|
||||
det.handlePacket(makePFrame(800))
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatalf("expected motion ON with custom threshold 1.5, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_CustomThresholdNoFalsePositive(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
det.threshold = 3.0
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// 2.5x — above default 2.0 but below custom 3.0
|
||||
det.handlePacket(makePFrame(1250))
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatalf("expected no motion with high threshold 3.0, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_HoldTimeExtended(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
// trigger motion
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
// send 25 non-triggered frames (remainingHold 30→5)
|
||||
for i := 0; i < 25; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// re-trigger — remainingHold resets to 30
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
// send 25 more non-triggered (remainingHold 30→5)
|
||||
for i := 0; i < 25; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// should still be ON
|
||||
if len(rec.calls) != 1 {
|
||||
t.Fatalf("expected hold time to be extended by re-trigger, got %v", rec.calls)
|
||||
}
|
||||
|
||||
// send 10 more to exhaust hold
|
||||
for i := 0; i < 10; i++ {
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
last, _ := rec.lastCall()
|
||||
if last {
|
||||
t.Fatal("expected motion OFF after extended hold expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_SmallPayloadIgnored(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
det.handlePacket(&rtp.Packet{Payload: []byte{1, 2, 3, 4}})
|
||||
det.handlePacket(&rtp.Packet{Payload: nil})
|
||||
det.handlePacket(&rtp.Packet{Payload: []byte{}})
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatalf("small payloads should be ignored, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_BaselineAdapts(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
baselineAfterWarmup := det.baseline
|
||||
|
||||
// feed gradually larger frames — baseline should drift up
|
||||
for i := 0; i < 200; i++ {
|
||||
det.handlePacket(makePFrame(700))
|
||||
}
|
||||
|
||||
if det.baseline <= baselineAfterWarmup {
|
||||
t.Fatalf("baseline should adapt upward: before=%f after=%f", baselineAfterWarmup, det.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_DoubleStopSafe(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
det.handlePacket(makePFrame(5000))
|
||||
|
||||
_ = det.Stop()
|
||||
_ = det.Stop() // second stop should not panic
|
||||
|
||||
if len(rec.calls) != 2 {
|
||||
t.Fatalf("expected ON+OFF, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_StopWithoutMotion(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
rec := &motionRecorder{}
|
||||
det.OnMotion = rec.onMotion
|
||||
_ = det.Stop()
|
||||
|
||||
if len(rec.calls) != 0 {
|
||||
t.Fatalf("stop without motion should not call onMotion, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_StopClearsMotion(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
det.handlePacket(makePFrame(5000))
|
||||
if len(rec.calls) != 1 || !rec.calls[0] {
|
||||
t.Fatal("expected motion ON")
|
||||
}
|
||||
|
||||
_ = det.Stop()
|
||||
|
||||
if len(rec.calls) != 2 || rec.calls[1] != false {
|
||||
t.Fatalf("expected Stop to clear motion, got %v", rec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_WarmupBaseline(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
for i := 0; i < motionWarmupFrames; i++ {
|
||||
size := 400 + (i%5)*50
|
||||
det.handlePacket(makePFrame(size))
|
||||
clock.advance(33 * time.Millisecond)
|
||||
}
|
||||
|
||||
if det.baseline < 400 || det.baseline > 600 {
|
||||
t.Fatalf("baseline should be in 400-600 range, got %f", det.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_MultipleCycles(t *testing.T) {
|
||||
det, clock, rec := newTestDetector()
|
||||
|
||||
warmupWithBudgets(det, clock, 500, 30, 5)
|
||||
|
||||
for cycle := 0; cycle < 3; cycle++ {
|
||||
det.handlePacket(makePFrame(5000)) // trigger ON
|
||||
for i := 0; i < 30; i++ { // expire hold
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
for i := 0; i < 6; i++ { // expire cooldown
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
}
|
||||
|
||||
if len(rec.calls) != 6 {
|
||||
t.Fatalf("expected 6 calls (3 cycles), got %d: %v", len(rec.calls), rec.calls)
|
||||
}
|
||||
for i, v := range rec.calls {
|
||||
expected := i%2 == 0
|
||||
if v != expected {
|
||||
t.Fatalf("call[%d] = %v, expected %v", i, v, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_TriggerLevel(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
expected := int(det.baseline * det.threshold)
|
||||
if det.triggerLevel != expected {
|
||||
t.Fatalf("triggerLevel = %d, expected %d", det.triggerLevel, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_DefaultFPSCalibration(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// calibrate uses default 30fps
|
||||
expectedHold := int(motionHoldTime.Seconds() * motionDefaultFPS)
|
||||
expectedCooldown := int(motionCooldown.Seconds() * motionDefaultFPS)
|
||||
if det.holdBudget != expectedHold {
|
||||
t.Fatalf("holdBudget = %d, expected %d", det.holdBudget, expectedHold)
|
||||
}
|
||||
if det.cooldownBudget != expectedCooldown {
|
||||
t.Fatalf("cooldownBudget = %d, expected %d", det.cooldownBudget, expectedCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionDetector_FPSRecalibration(t *testing.T) {
|
||||
det, clock, _ := newTestDetector()
|
||||
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// initial budgets use default 30fps
|
||||
initialHold := det.holdBudget
|
||||
|
||||
// send motionTraceFrames frames with 100ms intervals → FPS=10
|
||||
for i := 0; i < motionTraceFrames; i++ {
|
||||
clock.advance(100 * time.Millisecond)
|
||||
det.handlePacket(makePFrame(500))
|
||||
}
|
||||
|
||||
// after recalibration, holdBudget should reflect ~10fps (±5% due to warmup tail)
|
||||
expectedHold := int(motionHoldTime.Seconds() * 10.0) // ~300
|
||||
if det.holdBudget < expectedHold-20 || det.holdBudget > expectedHold+20 {
|
||||
t.Fatalf("holdBudget after recalibration = %d, expected ~%d (was %d)", det.holdBudget, expectedHold, initialHold)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_HandlePacket(b *testing.B) {
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
|
||||
pkt := makePFrame(600)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
det.handlePacket(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_WithKeyframes(b *testing.B) {
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
|
||||
pFrame := makePFrame(600)
|
||||
iFrame := makeIFrame(10000)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if i%30 == 0 {
|
||||
det.handlePacket(iFrame)
|
||||
} else {
|
||||
det.handlePacket(pFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_MotionActive(b *testing.B) {
|
||||
det, clock, _ := newTestDetector()
|
||||
warmup(det, clock, 500)
|
||||
|
||||
// trigger motion and keep it active
|
||||
det.handlePacket(makePFrame(5000))
|
||||
pkt := makePFrame(5000)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
det.handlePacket(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMotionDetector_Warmup(b *testing.B) {
|
||||
pkt := makePFrame(500)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
det := NewMotionDetector(0, func(bool) {}, zerolog.Nop())
|
||||
det.now = time.Now
|
||||
for j := 0; j < motionWarmupFrames; j++ {
|
||||
det.handlePacket(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// hksvSession manages the HDS DataStream connection for HKSV recording
|
||||
type hksvSession struct {
|
||||
server *Server
|
||||
hapConn *hap.Conn
|
||||
hdsConn *hds.Conn
|
||||
session *hds.Session
|
||||
log zerolog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
consumer *HKSVConsumer
|
||||
}
|
||||
|
||||
func newHKSVSession(srv *Server, hapConn *hap.Conn, hdsConn *hds.Conn) *hksvSession {
|
||||
session := hds.NewSession(hdsConn)
|
||||
hs := &hksvSession{
|
||||
server: srv,
|
||||
hapConn: hapConn,
|
||||
hdsConn: hdsConn,
|
||||
session: session,
|
||||
log: srv.log,
|
||||
}
|
||||
session.OnDataSendOpen = hs.handleOpen
|
||||
session.OnDataSendClose = hs.handleClose
|
||||
return hs
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Run() error {
|
||||
return hs.session.Run()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) Close() {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
_ = hs.session.Close()
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleOpen(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[hksv] dataSend open")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
|
||||
// Try to use the pre-started consumer from pair-verify
|
||||
consumer := hs.server.takePreparedConsumer()
|
||||
if consumer != nil {
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Msg("[hksv] using prepared consumer")
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
// Activate: set the HDS session and send init + start streaming
|
||||
if err := consumer.Activate(hs.session, streamID); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] activate failed")
|
||||
hs.stopRecording()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: create new consumer (will be slow ~3s)
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Msg("[hksv] no prepared consumer, creating new")
|
||||
consumer = NewHKSVConsumer(hs.log)
|
||||
|
||||
if err := hs.server.streams.AddConsumer(hs.server.stream, consumer); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] add consumer failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
hs.consumer = consumer
|
||||
hs.server.AddConn(consumer)
|
||||
|
||||
go func() {
|
||||
if err := consumer.Activate(hs.session, streamID); err != nil {
|
||||
hs.log.Error().Err(err).Str("stream", hs.server.stream).Msg("[hksv] activate failed")
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) handleClose(streamID int) error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
hs.log.Debug().Str("stream", hs.server.stream).Int("streamID", streamID).Msg("[hksv] dataSend close")
|
||||
|
||||
if hs.consumer != nil {
|
||||
hs.stopRecording()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hs *hksvSession) stopRecording() {
|
||||
consumer := hs.consumer
|
||||
hs.consumer = nil
|
||||
|
||||
hs.server.streams.RemoveConsumer(hs.server.stream, consumer)
|
||||
_ = consumer.Stop()
|
||||
hs.server.DelConn(consumer)
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Author: Sergei "svk" Krashevich <svk@svk.su>
|
||||
package hksv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap/hds"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestHKSVSession creates a test hksvSession with connected HDS pairs.
|
||||
// Returns the session, controller-side HDS session, and the server.
|
||||
func newTestHKSVSession(t *testing.T, streams *mockStreamProvider) (*hksvSession, *hds.Session, *Server) {
|
||||
t.Helper()
|
||||
|
||||
if streams == nil {
|
||||
streams = newMockStreamProvider()
|
||||
}
|
||||
srv := newTestServer(t, func(c *Config) {
|
||||
c.Streams = streams
|
||||
})
|
||||
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
t.Cleanup(func() { c1.Close(); c2.Close() })
|
||||
|
||||
accConn, err := hds.NewConn(c1, key, salt, false)
|
||||
require.NoError(t, err)
|
||||
ctrlConn, err := hds.NewConn(c2, key, salt, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctrl := hds.NewSession(ctrlConn)
|
||||
|
||||
// nil hapConn is fine — handleOpen/handleClose don't use it
|
||||
hs := newHKSVSession(srv, nil, accConn)
|
||||
|
||||
return hs, ctrl, srv
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// handleOpen
|
||||
// ====================================================================
|
||||
|
||||
func TestSession_HandleOpen_CreatesConsumer(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, srv := newTestHKSVSession(t, streams)
|
||||
|
||||
// Drain controller side messages
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := hs.handleOpen(1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Consumer should be created and added to stream
|
||||
hs.mu.Lock()
|
||||
consumer := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, consumer)
|
||||
|
||||
// Consumer should be added to stream provider
|
||||
require.Equal(t, 1, streams.count("test-camera"))
|
||||
|
||||
// Consumer should be tracked in server connections
|
||||
srv.mu.Lock()
|
||||
require.Contains(t, srv.conns, consumer)
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestSession_HandleOpen_UsesPreparedConsumer(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, srv := newTestHKSVSession(t, streams)
|
||||
|
||||
// Pre-prepare a consumer
|
||||
prepared := NewHKSVConsumer(zerolog.Nop())
|
||||
prepared.initData = []byte("fake-init")
|
||||
close(prepared.initDone)
|
||||
srv.preparedConsumer = prepared
|
||||
|
||||
// Drain controller side
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := hs.handleOpen(1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should use the prepared consumer
|
||||
hs.mu.Lock()
|
||||
consumer := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.Equal(t, prepared, consumer)
|
||||
|
||||
// preparedConsumer should be cleared
|
||||
require.Nil(t, srv.takePreparedConsumer())
|
||||
}
|
||||
|
||||
func TestSession_HandleOpen_StreamError(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
streams.addErr = errors.New("stream offline")
|
||||
hs, _, _ := newTestHKSVSession(t, streams)
|
||||
|
||||
err := hs.handleOpen(1)
|
||||
require.NoError(t, err) // handleOpen returns nil even on error
|
||||
|
||||
hs.mu.Lock()
|
||||
require.Nil(t, hs.consumer, "consumer should not be set on stream error")
|
||||
hs.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestSession_HandleOpen_ReplacesExistingConsumer(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, _ := newTestHKSVSession(t, streams)
|
||||
|
||||
// Drain controller side
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// First open
|
||||
_ = hs.handleOpen(1)
|
||||
hs.mu.Lock()
|
||||
first := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, first)
|
||||
|
||||
// Second open should stop the first consumer
|
||||
_ = hs.handleOpen(2)
|
||||
hs.mu.Lock()
|
||||
second := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, second)
|
||||
require.NotEqual(t, first, second)
|
||||
|
||||
// First consumer should be stopped
|
||||
select {
|
||||
case <-first.Done():
|
||||
// OK
|
||||
default:
|
||||
t.Fatal("first consumer should be stopped when replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// handleClose
|
||||
// ====================================================================
|
||||
|
||||
func TestSession_HandleClose_StopsRecording(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, srv := newTestHKSVSession(t, streams)
|
||||
|
||||
// Drain controller
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_ = hs.handleOpen(1)
|
||||
hs.mu.Lock()
|
||||
consumer := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, consumer)
|
||||
|
||||
_ = hs.handleClose(1)
|
||||
|
||||
// Consumer should be stopped and removed
|
||||
hs.mu.Lock()
|
||||
require.Nil(t, hs.consumer)
|
||||
hs.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-consumer.Done():
|
||||
default:
|
||||
t.Fatal("consumer should be stopped after handleClose")
|
||||
}
|
||||
|
||||
require.Equal(t, 0, streams.count("test-camera"))
|
||||
|
||||
srv.mu.Lock()
|
||||
require.NotContains(t, srv.conns, consumer)
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestSession_HandleClose_NoConsumer(t *testing.T) {
|
||||
hs, _, _ := newTestHKSVSession(t, nil)
|
||||
// Should not panic when no consumer
|
||||
err := hs.handleClose(1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Close
|
||||
// ====================================================================
|
||||
|
||||
func TestSession_Close_StopsActiveRecording(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, _ := newTestHKSVSession(t, streams)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_ = hs.handleOpen(1)
|
||||
hs.mu.Lock()
|
||||
consumer := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
|
||||
hs.Close()
|
||||
|
||||
select {
|
||||
case <-consumer.Done():
|
||||
default:
|
||||
t.Fatal("Close should stop active consumer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_Close_NoActiveRecording(t *testing.T) {
|
||||
hs, _, _ := newTestHKSVSession(t, nil)
|
||||
// Should not panic
|
||||
hs.Close()
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Full Session Lifecycle (integration)
|
||||
// ====================================================================
|
||||
|
||||
func TestSession_FullLifecycle(t *testing.T) {
|
||||
// Simulates: open → stream → close → re-open → close
|
||||
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, srv := newTestHKSVSession(t, streams)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// First recording session
|
||||
_ = hs.handleOpen(1)
|
||||
hs.mu.Lock()
|
||||
c1 := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, c1)
|
||||
require.Equal(t, 1, streams.count("test-camera"))
|
||||
|
||||
// End first recording
|
||||
_ = hs.handleClose(1)
|
||||
require.Equal(t, 0, streams.count("test-camera"))
|
||||
|
||||
// Second recording session (re-open)
|
||||
_ = hs.handleOpen(2)
|
||||
hs.mu.Lock()
|
||||
c2 := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
require.NotNil(t, c2)
|
||||
require.NotEqual(t, c1, c2, "should be a new consumer")
|
||||
require.Equal(t, 1, streams.count("test-camera"))
|
||||
|
||||
// Final close
|
||||
hs.Close()
|
||||
require.Equal(t, 0, streams.count("test-camera"))
|
||||
|
||||
// Verify server cleanup
|
||||
srv.mu.Lock()
|
||||
require.Empty(t, srv.conns)
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// stopRecording
|
||||
// ====================================================================
|
||||
|
||||
func TestStopRecording_FullCleanup(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, srv := newTestHKSVSession(t, streams)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_ = hs.handleOpen(1)
|
||||
hs.mu.Lock()
|
||||
consumer := hs.consumer
|
||||
hs.mu.Unlock()
|
||||
|
||||
// Verify consumer is tracked
|
||||
srv.mu.Lock()
|
||||
require.Contains(t, srv.conns, consumer)
|
||||
srv.mu.Unlock()
|
||||
require.Equal(t, 1, streams.count("test-camera"))
|
||||
|
||||
// Stop recording
|
||||
hs.mu.Lock()
|
||||
hs.stopRecording()
|
||||
hs.mu.Unlock()
|
||||
|
||||
// Verify full cleanup
|
||||
hs.mu.Lock()
|
||||
require.Nil(t, hs.consumer)
|
||||
hs.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-consumer.Done():
|
||||
default:
|
||||
t.Fatal("consumer should be stopped")
|
||||
}
|
||||
|
||||
require.Equal(t, 0, streams.count("test-camera"))
|
||||
|
||||
srv.mu.Lock()
|
||||
require.NotContains(t, srv.conns, consumer)
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Concurrent Session Operations
|
||||
// ====================================================================
|
||||
|
||||
func TestSession_ConcurrentOpenClose(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
hs, ctrl, _ := newTestHKSVSession(t, streams)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
if _, err := ctrl.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
if n%2 == 0 {
|
||||
_ = hs.handleOpen(n)
|
||||
} else {
|
||||
_ = hs.handleClose(n)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Clean close at the end
|
||||
hs.Close()
|
||||
|
||||
// Verify no leaked consumers
|
||||
require.Eventually(t, func() bool {
|
||||
return streams.count("test-camera") == 0
|
||||
}, 2*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Server acceptHDS integration (partial)
|
||||
// ====================================================================
|
||||
|
||||
func TestServer_AcceptHDS_Lifecycle(t *testing.T) {
|
||||
// Test the session stored in server is properly managed
|
||||
|
||||
streams := newMockStreamProvider()
|
||||
srv := newTestServer(t, func(c *Config) {
|
||||
c.Streams = streams
|
||||
})
|
||||
|
||||
key := []byte(core.RandString(16, 0))
|
||||
salt := core.RandString(32, 0)
|
||||
c1, c2 := net.Pipe()
|
||||
defer c2.Close()
|
||||
|
||||
accConn, err := hds.NewConn(c1, key, salt, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
hs := newHKSVSession(srv, nil, accConn)
|
||||
|
||||
srv.mu.Lock()
|
||||
srv.hksvSession = hs
|
||||
srv.mu.Unlock()
|
||||
|
||||
// Verify session is set
|
||||
srv.mu.Lock()
|
||||
require.NotNil(t, srv.hksvSession)
|
||||
srv.mu.Unlock()
|
||||
|
||||
// Cleanup: session removal
|
||||
srv.mu.Lock()
|
||||
if srv.hksvSession == hs {
|
||||
srv.hksvSession = nil
|
||||
}
|
||||
srv.mu.Unlock()
|
||||
hs.Close()
|
||||
|
||||
srv.mu.Lock()
|
||||
require.Nil(t, srv.hksvSession)
|
||||
srv.mu.Unlock()
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// prepareHKSVConsumer integration
|
||||
// ====================================================================
|
||||
|
||||
func TestPrepareHKSVConsumer_Flow(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
srv := newTestServer(t, func(c *Config) {
|
||||
c.MotionMode = "continuous"
|
||||
c.Streams = streams
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
srv.prepareHKSVConsumer()
|
||||
}()
|
||||
|
||||
// Wait for consumer to be prepared
|
||||
require.Eventually(t, func() bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
return srv.preparedConsumer != nil
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
// Take the prepared consumer
|
||||
consumer := srv.takePreparedConsumer()
|
||||
require.NotNil(t, consumer)
|
||||
|
||||
// Stop it (this triggers done channel → goroutine exits)
|
||||
_ = consumer.Stop()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestPrepareHKSVConsumer_StreamError(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
streams.addErr = errors.New("no stream")
|
||||
srv := newTestServer(t, func(c *Config) {
|
||||
c.Streams = streams
|
||||
})
|
||||
|
||||
srv.prepareHKSVConsumer()
|
||||
|
||||
require.Nil(t, srv.preparedConsumer)
|
||||
}
|
||||
|
||||
func TestPrepareHKSVConsumer_ReplacesOld(t *testing.T) {
|
||||
streams := newMockStreamProvider()
|
||||
srv := newTestServer(t, func(c *Config) {
|
||||
c.Streams = streams
|
||||
})
|
||||
|
||||
// Start first prepare
|
||||
done1 := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done1)
|
||||
srv.prepareHKSVConsumer()
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
return srv.preparedConsumer != nil
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
srv.mu.Lock()
|
||||
first := srv.preparedConsumer
|
||||
srv.mu.Unlock()
|
||||
|
||||
// Start second prepare — should replace the first
|
||||
done2 := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done2)
|
||||
srv.prepareHKSVConsumer()
|
||||
}()
|
||||
|
||||
// Wait for replacement
|
||||
require.Eventually(t, func() bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
return srv.preparedConsumer != nil && srv.preparedConsumer != first
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
// First consumer should be stopped
|
||||
select {
|
||||
case <-first.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first consumer should be stopped")
|
||||
}
|
||||
|
||||
<-done1
|
||||
|
||||
// Clean up
|
||||
srv.mu.Lock()
|
||||
c := srv.preparedConsumer
|
||||
srv.mu.Unlock()
|
||||
if c != nil {
|
||||
_ = c.Stop()
|
||||
}
|
||||
<-done2
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Benchmarks
|
||||
// ====================================================================
|
||||
|
||||
func BenchmarkServer_AddDelConn(b *testing.B) {
|
||||
streams := newMockStreamProvider()
|
||||
srv, _ := NewServer(Config{
|
||||
StreamName: "bench",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
Streams: streams,
|
||||
Logger: zerolog.Nop(),
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
conn := i
|
||||
srv.AddConn(conn)
|
||||
srv.DelConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServer_AddDelPair(b *testing.B) {
|
||||
streams := newMockStreamProvider()
|
||||
srv, _ := NewServer(Config{
|
||||
StreamName: "bench",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
Streams: streams,
|
||||
Logger: zerolog.Nop(),
|
||||
})
|
||||
|
||||
pub := []byte{1, 2, 3, 4}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
id := assert.AnError.Error() // just a string
|
||||
srv.AddPair(id, pub, hap.PermissionAdmin)
|
||||
srv.DelPair(id)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServer_SetMotionDetected(b *testing.B) {
|
||||
streams := newMockStreamProvider()
|
||||
srv, _ := NewServer(Config{
|
||||
StreamName: "bench",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
Streams: streams,
|
||||
Logger: zerolog.Nop(),
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
srv.SetMotionDetected(i%2 == 0)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServer_MarshalJSON(b *testing.B) {
|
||||
streams := newMockStreamProvider()
|
||||
srv, _ := NewServer(Config{
|
||||
StreamName: "bench",
|
||||
Pin: "27041991",
|
||||
HKSV: true,
|
||||
Streams: streams,
|
||||
Logger: zerolog.Nop(),
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = srv.MarshalJSON()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -88,7 +88,7 @@ func (r *reader) RoundTrip(_ *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func (r *reader) getSegment() ([]byte, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
if r.playlist == nil {
|
||||
if wait := time.Second - time.Since(r.lastTime); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
|
||||
@@ -26,6 +26,8 @@ type Consumer struct {
|
||||
videoSession *srtp.Session
|
||||
audioSession *srtp.Session
|
||||
audioRTPTime byte
|
||||
|
||||
backTrack *core.Receiver // backchannel audio (HomeKit viewer → camera)
|
||||
}
|
||||
|
||||
func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
||||
@@ -44,6 +46,13 @@ func NewConsumer(conn net.Conn, server *srtp.Server) *Consumer {
|
||||
{Name: core.CodecOpus},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: core.KindAudio,
|
||||
Direction: core.DirectionRecvonly,
|
||||
Codecs: []*core.Codec{
|
||||
{Name: core.CodecOpus},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &Consumer{
|
||||
Connection: core.Connection{
|
||||
@@ -130,6 +139,26 @@ func (c *Consumer) SetConfig(conf *camera.SelectedStreamConfiguration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Consumer) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
|
||||
if codec.Kind() != core.KindAudio {
|
||||
return nil, core.ErrCantGetTrack
|
||||
}
|
||||
|
||||
c.backTrack = core.NewReceiver(media, codec)
|
||||
|
||||
c.audioSession.OnReadRTP = func(packet *rtp.Packet) {
|
||||
c.backTrack.WriteRTP(packet)
|
||||
c.Recv += len(packet.Payload)
|
||||
}
|
||||
|
||||
c.Receivers = append(c.Receivers, c.backTrack)
|
||||
return c.backTrack, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Consumer) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
|
||||
var session *srtp.Session
|
||||
if codec.Kind() == core.KindVideo {
|
||||
|
||||
+43
-2
@@ -68,14 +68,55 @@ func ServerHandler(server Server) HandlerFunc {
|
||||
AID uint8 `json:"aid"`
|
||||
IID uint64 `json:"iid"`
|
||||
Value any `json:"value"`
|
||||
Event any `json:"ev"`
|
||||
R *bool `json:"r,omitempty"`
|
||||
} `json:"characteristics"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, char := range v.Value {
|
||||
server.SetCharacteristic(conn, char.AID, char.IID, char.Value)
|
||||
var writeResponses []hap.JSONCharacter
|
||||
findChar := func(aid uint8, iid uint64) *hap.Character {
|
||||
accs := server.GetAccessories(conn)
|
||||
for _, acc := range accs {
|
||||
if acc.AID != aid {
|
||||
continue
|
||||
}
|
||||
return acc.GetCharacterByID(iid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range v.Value {
|
||||
if c.Value != nil {
|
||||
server.SetCharacteristic(conn, c.AID, c.IID, c.Value)
|
||||
}
|
||||
if c.Event != nil {
|
||||
// subscribe/unsubscribe to events
|
||||
if char := findChar(c.AID, c.IID); char != nil {
|
||||
if ev, ok := c.Event.(bool); ok && ev {
|
||||
char.AddListener(conn)
|
||||
} else {
|
||||
char.RemoveListener(conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.R != nil && *c.R {
|
||||
// write-response: return updated value
|
||||
if char := findChar(c.AID, c.IID); char != nil {
|
||||
writeResponses = append(writeResponses, hap.JSONCharacter{
|
||||
AID: c.AID,
|
||||
IID: c.IID,
|
||||
Status: 0,
|
||||
Value: char.Value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeResponses) > 0 {
|
||||
return makeResponse(hap.MimeJSON, hap.JSONCharacters{Value: writeResponses})
|
||||
}
|
||||
|
||||
res := &http.Response{
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/core"
|
||||
"github.com/AlexxIT/go2rtc/pkg/tcp"
|
||||
"github.com/pion/rtp"
|
||||
webp "github.com/skrashevich/go-webp"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
@@ -49,6 +52,12 @@ func (c *Producer) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if isWebP(body) {
|
||||
if converted, err2 := webpToJPEG(body); err2 == nil {
|
||||
body = converted
|
||||
}
|
||||
}
|
||||
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{Timestamp: core.Now90000()},
|
||||
Payload: body,
|
||||
@@ -74,6 +83,12 @@ func (c *Producer) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if isWebP(body) {
|
||||
if converted, err2 := webpToJPEG(body); err2 == nil {
|
||||
body = converted
|
||||
}
|
||||
}
|
||||
|
||||
c.Recv += len(body)
|
||||
|
||||
pkt = &rtp.Packet{
|
||||
@@ -90,3 +105,23 @@ func (c *Producer) Stop() error {
|
||||
c.closed = true
|
||||
return c.Connection.Stop()
|
||||
}
|
||||
|
||||
// isWebP returns true if data starts with RIFF....WEBP magic bytes.
|
||||
func isWebP(data []byte) bool {
|
||||
return len(data) >= 12 &&
|
||||
data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P'
|
||||
}
|
||||
|
||||
// webpToJPEG decodes WebP bytes and re-encodes as JPEG.
|
||||
func webpToJPEG(data []byte) ([]byte, error) {
|
||||
img, err := webp.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err = jpeg.Encode(&buf, img, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ func DecodeAtom(b []byte) (any, error) {
|
||||
|
||||
case MoofTrafTfhd:
|
||||
rd := bits.NewReader(data)
|
||||
_ = rd.ReadByte() // version
|
||||
_ = rd.ReadUint8() // version
|
||||
flags := rd.ReadUint24()
|
||||
|
||||
atom := &AtomTfhd{
|
||||
@@ -145,7 +145,7 @@ func DecodeAtom(b []byte) (any, error) {
|
||||
|
||||
case MoofTrafTrun:
|
||||
rd := bits.NewReader(data)
|
||||
_ = rd.ReadByte() // version
|
||||
_ = rd.ReadUint8() // version
|
||||
flags := rd.ReadUint24()
|
||||
samples := rd.ReadUint32()
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func GetLiveStream(id string) (string, error) {
|
||||
}
|
||||
|
||||
if !v.Success {
|
||||
return "", fmt.Errorf("ivideon: can't get live_stream: " + v.Message)
|
||||
return "", fmt.Errorf("ivideon: can't get live_stream: %s", v.Message)
|
||||
}
|
||||
|
||||
return v.Result.URL, nil
|
||||
|
||||
@@ -47,7 +47,7 @@ func MakeTables(q byte) (lqt, cqt []byte) {
|
||||
lqt = make([]byte, 64)
|
||||
cqt = make([]byte, 64)
|
||||
|
||||
for i := 0; i < 64; i++ {
|
||||
for i := range 64 {
|
||||
lq := (int(jpeg_luma_quantizer[i])*factor + 50) / 100
|
||||
cq := (int(jpeg_chroma_quantizer[i])*factor + 50) / 100
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ func (d *Demuxer) Demux(data2 []byte) (trackID uint32, packets []*core.Packet) {
|
||||
n := len(trun.SamplesDuration)
|
||||
packets = make([]*core.Packet, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
for i := range n {
|
||||
duration := trun.SamplesDuration[i]
|
||||
size := trun.SamplesSize[i]
|
||||
|
||||
|
||||
+10
-10
@@ -129,7 +129,7 @@ func (m *Muxer) writePMT(wr *bits.Writer) {
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
wr.WriteByte(pes.StreamType) // Stream type
|
||||
wr.WriteUint8(pes.StreamType) // Stream type
|
||||
wr.WriteBits8(0b111, 3) // Reserved bits (all to 1)
|
||||
wr.WriteBits16(pid, 13) // Elementary PID
|
||||
wr.WriteBits8(0b1111, 4) // Reserved bits (all to 1)
|
||||
@@ -148,7 +148,7 @@ func (m *Muxer) writePES(wr *bits.Writer, pid uint16, pes *PES) {
|
||||
const flagAdaptation = 0b00100000
|
||||
const flagPayload = 0b00010000
|
||||
|
||||
wr.WriteByte(SyncByte)
|
||||
wr.WriteUint8(SyncByte)
|
||||
|
||||
if pes.Size != 0 {
|
||||
pid |= flagPUSI // Payload unit start indicator (PUSI)
|
||||
@@ -159,17 +159,17 @@ func (m *Muxer) writePES(wr *bits.Writer, pid uint16, pes *PES) {
|
||||
counter := byte(pes.Sequence) & 0xF
|
||||
|
||||
if size := len(pes.Payload); size < PacketSize-4 {
|
||||
wr.WriteByte(flagAdaptation | flagPayload | counter) // adaptation + payload
|
||||
wr.WriteUint8(flagAdaptation | flagPayload | counter) // adaptation + payload
|
||||
|
||||
// for 183 payload will be zero
|
||||
adSize := PacketSize - 4 - 1 - byte(size)
|
||||
wr.WriteByte(adSize)
|
||||
wr.WriteUint8(adSize)
|
||||
wr.WriteBytes(make([]byte, adSize)...)
|
||||
|
||||
wr.WriteBytes(pes.Payload...)
|
||||
pes.Payload = nil
|
||||
} else {
|
||||
wr.WriteByte(flagPayload | counter) // only payload
|
||||
wr.WriteUint8(flagPayload | counter) // only payload
|
||||
|
||||
wr.WriteBytes(pes.Payload[:PacketSize-4]...)
|
||||
pes.Payload = pes.Payload[PacketSize-4:]
|
||||
@@ -177,7 +177,7 @@ func (m *Muxer) writePES(wr *bits.Writer, pid uint16, pes *PES) {
|
||||
}
|
||||
|
||||
func (m *Muxer) writeHeader(wr *bits.Writer, pid uint16) {
|
||||
wr.WriteByte(SyncByte)
|
||||
wr.WriteUint8(SyncByte)
|
||||
|
||||
wr.WriteBit(0) // Transport error indicator (TEI)
|
||||
wr.WriteBit(1) // Payload unit start indicator (PUSI)
|
||||
@@ -191,9 +191,9 @@ func (m *Muxer) writeHeader(wr *bits.Writer, pid uint16) {
|
||||
}
|
||||
|
||||
func (m *Muxer) writePSIHeader(wr *bits.Writer, tableID byte, size uint16) {
|
||||
wr.WriteByte(0) // Pointer field
|
||||
wr.WriteUint8(0) // Pointer field
|
||||
|
||||
wr.WriteByte(tableID) // Table ID
|
||||
wr.WriteUint8(tableID) // Table ID
|
||||
|
||||
wr.WriteBit(1) // Section syntax indicator
|
||||
wr.WriteBit(0) // Private bit
|
||||
@@ -206,8 +206,8 @@ func (m *Muxer) writePSIHeader(wr *bits.Writer, tableID byte, size uint16) {
|
||||
wr.WriteBits8(0, 5) // Version number
|
||||
wr.WriteBit(1) // Current/next indicator
|
||||
|
||||
wr.WriteByte(0) // Section number
|
||||
wr.WriteByte(0) // Last section number
|
||||
wr.WriteUint8(0) // Section number
|
||||
wr.WriteUint8(0) // Last section number
|
||||
}
|
||||
|
||||
func (m *Muxer) WriteTail(wr *bits.Writer) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user