Merge pull request #2147 from skrashevich/beta

dev branch: merge from skrashevich/go2rtc:beta
This commit is contained in:
Sergey Krashevich
2026-03-11 12:02:11 +03:00
committed by GitHub
157 changed files with 13173 additions and 1215 deletions
+8 -35
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -21,4 +21,9 @@ website/.vitepress/dist
node_modules
package-lock.json
CLAUDE.md
CLAUDE.md
*/**/CLAUDE.md
.claude*
.ruff*
.omc
+85 -3
View File
@@ -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 |
|:-:|:-:|
| ![Streams](website/images/screenshots/01-streams-dashboard-dark.png) | ![Add Stream](website/images/screenshots/02-add-stream-dark.png) |
| Stream list with status, actions, and system monitoring | Quick setup for dozens of protocols and integrations |
| Stream Info | Stream Links |
|:-:|:-:|
| ![Info](website/images/screenshots/03-stream-info-dark.png) | ![Links](website/images/screenshots/04-stream-links-dark.png) |
| Producers and consumers details | Direct URLs for all supported formats |
| Config Editor | Logs |
|:-:|:-:|
| ![Config](website/images/screenshots/05-config-editor-dark.png) | ![Logs](website/images/screenshots/06-logs-dark.png) |
| YAML configuration with syntax highlighting | Real-time log viewer with auto-update |
<details>
<summary>Light theme</summary>
| Streams Dashboard | Add Stream |
|:-:|:-:|
| ![Streams](website/images/screenshots/01-streams-dashboard-light.png) | ![Add Stream](website/images/screenshots/02-add-stream-light.png) |
| Stream Info | Stream Links |
|:-:|:-:|
| ![Info](website/images/screenshots/03-stream-info-light.png) | ![Links](website/images/screenshots/04-stream-links-light.png) |
| Config Editor | Logs |
|:-:|:-:|
| ![Config](website/images/screenshots/05-config-editor-light.png) | ![Logs](website/images/screenshots/06-logs-light.png) |
</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.
![go2rtc webui config](website/images/webui-config.png)
![go2rtc webui config](website/images/screenshots/05-config-editor-dark.png)
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.
![go2rtc webui net](website/images/webui-net.png)
![go2rtc webui net](website/images/screenshots/05-network-topology-dark.png)
## 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
View File
@@ -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
View File
@@ -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
+407
View File
@@ -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)
}
})
}
+105
View File
@@ -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"
+12
View File
@@ -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
+13 -4
View File
@@ -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"]
+13 -5
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
module pinggy
go 1.25
go 1.26
require (
github.com/Pinggy-io/pinggy-go/pinggy v0.6.9 // indirect
+2 -4
View File
@@ -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
+5 -48
View File
@@ -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=
+29
View File
@@ -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
View File
@@ -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
}
+36
View File
@@ -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")
})
}
}
+283
View File
@@ -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
}
}
+16
View File
@@ -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,
}
}
+104
View File
@@ -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
}
+116
View File
@@ -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)
}
+84
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !linux && !darwin && !windows
package api
func getMemoryInfo() (total, used uint64) {
return 0, 0
}
func getCPUUsage() float64 {
return 0
}
+77
View File
@@ -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
}
+4
View File
@@ -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()
+29
View File
@@ -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")
}
+1 -1
View File
@@ -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)
}
+5
View File
@@ -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")
+26
View File
@@ -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)
}
+2 -2
View File
@@ -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).
+1 -1
View File
@@ -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 {
+103
View File
@@ -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.31.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.53.0 | Wind and shadows produce medium P-frames, need margin |
| Busy street / complex scene | 3.05.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**
+4
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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 {
+287
View File
@@ -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 ""
}
+99
View File
@@ -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
}
-405
View File
@@ -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
}
+8
View File
@@ -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 {
+30
View File
@@ -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)
})
}
}
+1 -1
View File
@@ -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,
})
+4
View File
@@ -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)
}
+16
View File
@@ -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())
}
+68
View File
@@ -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)
})
}
+49
View File
@@ -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")
}
+19
View File
@@ -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
}
+3
View File
@@ -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)
+160
View File
@@ -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)
}
+16
View File
@@ -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()
}
+1 -1
View File
@@ -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
}
+8
View File
@@ -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()
+30
View File
@@ -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)
})
}
}
+4
View File
@@ -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)
+26
View File
@@ -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)
}
+4
View File
@@ -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)
+26
View File
@@ -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)
}
+4
View File
@@ -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()
}
+3
View File
@@ -43,5 +43,8 @@
}
}
]
},
"dependencies": {
"playwright": "^1.58.2"
}
}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
-14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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'
+1 -1
View File
@@ -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)
+13
View File
@@ -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"`
}
+237
View File
@@ -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,
},
},
}
}
+4
View File
@@ -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
View File
@@ -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]))
+416
View File
@@ -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
}
+298
View File
@@ -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
}
+486
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
+692
View File
@@ -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)
+257
View File
@@ -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"
}
+460
View File
@@ -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()
}
}
+210
View File
@@ -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)
}
+65
View File
@@ -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
}
+137
View File
@@ -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))
})
}
}
+820
View File
@@ -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
+243
View File
@@ -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()
}
+510
View File
@@ -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)
}
}
}
+117
View File
@@ -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)
}
+606
View File
@@ -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
View File
@@ -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)
+29
View File
@@ -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
View File
@@ -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{
+35
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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