27 Commits

Author SHA1 Message Date
eduard256 fc4f52ed92 Drop redundant line under Umbrel badge 2026-04-24 11:30:59 +00:00
eduard256 a0b762c076 Switch Umbrel badge to default light variant 2026-04-24 11:30:22 +00:00
eduard256 0f406359c0 Add Umbrel install section to README 2026-04-24 11:25:19 +00:00
eduard256 015d2f335d Add Podman section to README with required capabilities 2026-04-24 11:15:34 +00:00
eduard256 568e41e72b Update add_protocol_strix skill with B1/B2 split and handoffs
Split Type B into B1 (pairing-based like homekit) and B2 (cloud-auth like
xiaomi/tapo/nest/ring/roborock/tuya). Document the xiaomi copy-from-go2rtc
template with stateless token flow. Add version note about Frigate stable
shipping go2rtc 1.9.10 without xiaomi support. Reference xiaomi and homekit
modules as golden templates. Replace commit step with handoff to
add_generate_strix and add_probe_detector_strix.
2026-04-18 12:32:01 +00:00
eduard256 102d2aa225 Add add_generate_strix skill
Skill for registering new protocol extractors with the Strix config
generator. Narrow in scope: adds ONE extractor + tests per protocol,
never touches pkg/generate/ internals. References xiaomi as the golden
reference for both the extractor pattern and the 16 mandatory test
scenarios.
2026-04-18 12:24:26 +00:00
eduard256 397f9dd78b Add end-to-end tests for Frigate config writer
Covers every writer in pkg/generate/writer.go through the public Generate
entry-point: inputs/roles, needMP4 (bubble), ffmpeg, live, detect
(including the Objects auto-enable branch), objects, motion, record
(retain, alerts, detections, fractional days), snapshots, audio,
birdseye, onvif (host-gated emission, default port, credentials,
autotracking, required zones), ptz (only inside onvif), notifications,
ui, Frigate/Go2RTC overrides, Name override, extractIP fallbacks,
Added line numbers, and top-level/camera section ordering.
2026-04-18 11:53:47 +00:00
eduard256 f4d414124b Fix stray ONVIF block and Go2RTC sub-stream rename desync
- www/config.html: stop prefilling the ONVIF host with the camera IP so
  the onvif block is only emitted when the user opts in.
- pkg/generate/config.go: apply Go2RTC.SubStreamName before deriving the
  Frigate restream path so go2rtc.streams and ffmpeg.inputs stay in sync
  on rename (matches the existing main-stream order).
2026-04-18 11:53:37 +00:00
eduard256 e5769cd1cf Add Xiaomi version-warning modal about go2rtc v1.9.13 2026-04-18 11:53:31 +00:00
eduard256 3a48e23100 Nest credential sections under go2rtc in frigate config
Frigate rejects unknown top-level keys (extra="forbid" on root config),
but its RestreamConfig (go2rtc: block) allows extra keys. Move credential
sections under go2rtc: with 2/4 space indentation.

- writeCredentials emits "  xiaomi:" + "    \"<key>\": <value>"
- upsertSection matches 2-space section header + 4-space key regex
- insertNewSection places new nested sections after streams: block
- findStreamInsertPoint stops at sibling headers (2-space) inside go2rtc:
- Add xiaomi_test.go with 16 scenarios covering new config, addToConfig
  merging, token refresh, dedup, sort order, malformed URLs, special chars,
  go2rtc override, mixed protocols, and section order
2026-04-18 08:49:04 +00:00
eduard256 12780d7803 Add credential extraction registry for generate
Protocols like Xiaomi need credentials (tokens) in a separate top-level
YAML section, not in the stream URL itself. Introduce a registry pattern
mirroring streams.HandleFunc / tester.RegisterSource:

- pkg/generate/registry.go: ExtractFunc + RegisterExtract
- Extractors clean the URL (strip ?token=...) and return section/key/value
- writeCredentials emits sorted sections between go2rtc: and cameras:
- upsertCredentials in addToConfig merges into existing sections:
  * replaces value if key exists (token refresh)
  * inserts in sorted order if new
  * creates new top-level section before cameras: if missing

Xiaomi registers its extractor from internal/xiaomi/xiaomi.go. Adding
Tapo/Ring/Roborock later is one line + a small function in their
internal/*/ module -- zero changes in pkg/generate/.
2026-04-18 08:36:48 +00:00
eduard256 8294736bcb Embed icons directory in static assets
Frontend needs access to icons (e.g. mihome.webp for Xiaomi page).
The //go:embed directive was limited to *.html only, so binary
assets in www/icons/ were never served.
2026-04-18 08:07:35 +00:00
eduard256 26e54a56db Add Xiaomi camera frontend flow
- xiaomi.html: 6-state machine (loading, login, captcha, verify, region, notfound)
- index.html: navigateXiaomi redirects type=xiaomi probes to xiaomi.html
- icons/mihome.webp: 944B Mi Home logo for hero section
- Flow: detect Xiaomi via miIO probe -> login Mi Cloud -> pick region
  -> fetch device list -> match camera by IP -> create.html?url=xiaomi://...
2026-04-17 21:26:25 +00:00
eduard256 90b3583af7 Add Xiaomi Mi Cloud integration
Port internal/xiaomi from go2rtc with stateless adaptation:
- Token passed via URL query (?token=) instead of persistent config
- tester.RegisterSource replaces streams.HandleFunc
- Stream handler extracts token from URL and populates in-memory cloud cache
- Device list endpoint embeds url-encoded token into each camera URL
- Auth flow (login/captcha/verify) unchanged from upstream
2026-04-17 21:01:54 +00:00
eduard256 ccb100fcd0 Add Xiaomi miIO probe detector
Detect stock Xiaomi/Mijia devices via miIO hello packet on UDP:54321.
Response magic 0x2131 uniquely identifies miIO devices.

Detector priority: ONVIF > HomeKit > Xiaomi > standard.
2026-04-17 20:35:02 +00:00
eduard256 b3e3e8ab1a Update install command to use process substitution 2026-04-16 19:00:43 +00:00
eduard256 7748002ae7 Fix install.sh to work when piped via curl | bash 2026-04-16 18:58:44 +00:00
eduard256 e675ec4b05 Move install.sh to repo root 2026-04-16 18:57:17 +00:00
eduard256 bc1aae77fa Add dark theme for whiptail (NEWT_COLORS) 2026-04-16 18:54:06 +00:00
eduard256 998775d199 Add Linux navigator and wire it into install.sh 2026-04-16 18:47:54 +00:00
eduard256 d8334c448c Fix local-in-pipe error, add LXC creation header 2026-04-16 18:00:33 +00:00
eduard256 54447bfa87 Remove test script 2026-04-16 17:51:32 +00:00
eduard256 9176c390f2 Add real worker execution to Proxmox navigator 2026-04-16 17:51:09 +00:00
eduard256 67fdb75dc6 Add Proxmox navigator with whiptail UI 2026-04-16 17:45:08 +00:00
eduard256 6fbd03a3d4 Fix install.sh detection parser and sync detect.sh 2026-04-16 17:06:07 +00:00
eduard256 6bb5f6f843 Add system detection worker script 2026-04-16 16:55:48 +00:00
eduard256 a64e41492d Replace monolithic install.sh with modular script architecture
- Remove old single-file installer
- Add worker scripts with JSON event streaming protocol:
  - scripts/prepare.sh: system prep, Docker/Compose installation
  - scripts/strix.sh: deploy Strix standalone via Docker Compose
  - scripts/strix-frigate.sh: deploy Strix + Frigate with HW autodetect
  - scripts/proxmox-lxc-create.sh: create Ubuntu LXC on Proxmox
- Add scripts/install.sh: animated frontend with owl display
- Update docker-compose.frigate.yml: host networking, internal API port,
  expanded device comments, GPU image hints
2026-04-16 16:50:40 +00:00
20 changed files with 3354 additions and 111 deletions
+224
View File
@@ -0,0 +1,224 @@
---
name: add_generate_strix
description: Register a new protocol extractor for the Strix config generator. Use when adding support for Tuya, Tapo, Nest, Ring, Roborock and other camera protocols that need credentials in a separate YAML section of frigate-config.yaml. This skill adds the glue; it does NOT add the protocol itself -- that's /add_protocol_strix.
disable-model-invocation: true
argument-hint: [protocol-name]
---
# Add Generate Extractor to Strix
You are registering a new protocol with the Strix config generator so that `/api/generate` can produce a valid `frigate-config.yaml` for cameras of that protocol.
The protocol name is provided as argument (e.g. `/add_generate_strix tuya`). If no argument, use AskUserQuestion to ask which protocol.
This skill is NARROW. It adds ONE extractor function plus tests. Do NOT modify `pkg/generate/` internals (registry.go, config.go, writer.go, insert.go) -- they are protocol-agnostic by design. If you are tempted to change them, stop and ask the user.
## Repositories
- Strix: current working directory (`/home/user/Strix`)
- go2rtc: `/home/user/go2rtc` (reference for YAML section format)
---
## STEP 0: Study the xiaomi reference
Before writing anything, read these files completely. Xiaomi is the golden reference for this skill. Every new protocol copies its structure.
- `internal/xiaomi/xiaomi.go` -- how `extractForConfig` is written and where `generate.RegisterExtract` is called inside `Init()`
- `pkg/generate/xiaomi_test.go` -- the 16 test scenarios that every new protocol must pass
- `pkg/generate/registry.go` -- the `ExtractFunc` contract (do not modify, only understand)
Then glance at these to understand what NOT to touch:
- `pkg/generate/config.go` -- how `runExtract` is called for main/sub/go2rtc-override URLs
- `pkg/generate/writer.go` -- how `writeCredentials` nests sections under `go2rtc:`
- `pkg/generate/insert.go` -- `upsertCredentials` merges into existing configs
---
## STEP 1: Study the protocol in go2rtc
Read these files in the go2rtc repo:
- `internal/<proto>/README.md` -- authoritative YAML format
- `internal/<proto>/<proto>.go` -- how go2rtc unmarshals the credentials section (look for `yaml:"<proto>"` in a struct tag)
- `pkg/<proto>/` -- URL scheme: what goes into userinfo, what into query, what into path
You need to answer exactly three questions:
1. **Where does the secret live in the URL?**
- `?token=X` in query (xiaomi, nest, ring)
- `userinfo` password (tapo, roborock)
- Custom (read the module)
2. **What is the YAML section name?** Usually the same as the URL scheme (`tuya:`, `tapo:`). Confirm from go2rtc's `yaml:` struct tag.
3. **What is the YAML key format?** Examples:
- xiaomi: `"<userID>"` (quoted, matches `ci` field in device)
- tapo: `<user>@<host>`
- roborock: `<username>`
- nest: `"<userID>"`
Write these three answers down. They drive the extractor.
---
## STEP 2: Verify the internal module already exists
Check `internal/<proto>/<proto>.go` exists and has a working `Init()` with `tester.RegisterSource` or similar. If the module does NOT exist, stop and tell the user to run `/add_protocol_strix <proto>` first. This skill only adds the generate hook to an existing module.
---
## STEP 3: Add the extractor to `internal/<proto>/<proto>.go`
Open `internal/<proto>/<proto>.go`.
**3a. Add import** (if not present):
```go
import "github.com/eduard256/strix/pkg/generate"
```
**3b. Register the extractor at the END of `Init()`:**
```go
generate.RegisterExtract("<proto>", extractForConfig)
```
**3c. Add the function.** Use the exact xiaomi style. Place it directly after `Init()`.
### Template: secret in query (xiaomi-like)
```go
// extractForConfig strips ?<secret>=... from <proto>:// URL and returns
// <key> + <token> for a go2rtc:<section>: block.
// ex. <proto>://<user>:<region>@<ip>?...&<secret>=T
// -> <proto>://<user>:<region>@<ip>?..., "<section>", "<user>", "T"
func extractForConfig(rawURL string) (cleaned, section, key, value string) {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL, "", "", ""
}
q := u.Query()
token := q.Get("<secret>")
if token == "" {
return rawURL, "", "", ""
}
q.Del("<secret>")
u.RawQuery = q.Encode()
return u.String(), "<section>", u.User.Username(), token
}
```
### Template: secret in userinfo password (tapo-like)
```go
func extractForConfig(rawURL string) (cleaned, section, key, value string) {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL, "", "", ""
}
pw, ok := u.User.Password()
if !ok || pw == "" {
return rawURL, "", "", ""
}
// ex. tapo: key = "admin@192.168.1.100"
key = u.User.Username() + "@" + u.Host
// URL stays as-is -- go2rtc reads credentials from userinfo directly
return rawURL, "<section>", key, pw
}
```
### Rules for the extractor (strict)
- MUST return `(rawURL, "", "", "")` on any parse error or missing secret. Never return an empty `cleaned`.
- MUST NOT log, MUST NOT touch filesystem, MUST NOT call APIs.
- MUST be deterministic -- same input always returns same output.
- MUST NOT URL-encode the token value. `writeCredentials` emits it raw; YAML parses `V1:abc+/=` fine.
- Keep it under 20 lines. If it grows, you're doing too much.
---
## STEP 4: Write `pkg/generate/<proto>_test.go`
Copy the structure from `pkg/generate/xiaomi_test.go` exactly. Change:
- `registerXiaomi` -> `register<Proto>` (use a new `sync.Once` per file -- do NOT share `registerOnce` across files)
- `xurl(...)` -> `<proto>url(...)` -- builds a URL in this protocol's format
- Test function names: `TestXiaomi_*` -> `Test<Proto>_*`
- All string literals that reference `xiaomi://`, `"acc1"`, `V1:TOK_A` -> equivalents for this protocol
### The 16 tests to write (all MUST pass)
All scenarios from `xiaomi_test.go` are relevant and must be present:
| # | Test | What it verifies |
|---|---|---|
| 1 | NewConfig_SingleCamera | Nested `go2rtc:\n <section>:\n <key>: <value>` with correct indentation. URL in streams has no secret left. |
| 2 | SameAccount_TokenNotDuplicated | Two cameras, same key -> exactly one entry in the section. |
| 3 | TwoAccounts_SortedKeys | Two keys in the section appear in ASCII-sorted order. |
| 4 | TokenRefresh_OverwritesValue | Re-adding a camera with a new token replaces the stored value, exactly one key remains. |
| 5 | MainAndSub_SameAccount_OneToken | Main + Sub with identical credentials -> one key. |
| 6 | MainAndSub_DifferentAccounts | Main + Sub with two accounts -> two keys. |
| 7 | Scale_10Cameras_3Accounts | 10 cameras across 3 accounts sequentially added -> exactly 3 keys at the end, most-recent values. |
| 8 | URLWithoutToken_NoSection | URL missing the secret -> no `<section>:` header written (check `"\n <section>:\n"`, not the URL scheme substring). |
| 9 | MalformedURL_DoesNotPanic | `<proto>://%%%bad` does not crash. |
| 10 | TokenSpecialChars_PreservedRaw | Secret with `+`, `/`, `=`, `:` is emitted verbatim. |
| 11 | Go2RTCOverride_PassesThroughExtractor | `req.Go2RTC.MainStreamSource` with this protocol is also extracted. |
| 12 | AddToConfig_NoExistingSection | Start from rtsp-only config, add this protocol -> new `<section>:` block created under `go2rtc:`. |
| 13 | AddToConfig_ExistingSection | Start from a config that already has `<section>:` -> new key merged, one header. |
| 14 | CustomName_URLStillClean | `req.Name` set, URL still has no secret in streams. |
| 15 | MixedProtocols | rtsp + this protocol together -- rtsp URL untouched, secret extracted. |
| 16 | SectionOrder | Order: go2rtc -> streams -> `<section>` -> cameras -> version. |
### Common pitfalls in tests
- `assertNotContains(cfg, "<section>:")` is WRONG when the URL scheme contains the same substring. Use `"\n <section>:\n"` instead (nested form) to avoid matching `<proto>://`.
- For protocols where `extractForConfig` returns `rawURL` unchanged (userinfo template), the "URL cleaning" assertion `assertNotContains(cfg, "token=")` does not apply -- skip or adjust.
- Use `registerOnce` with `sync.Once` so running the whole package test suite twice does not duplicate-register the extractor.
---
## STEP 5: Build and run the tests
```bash
cd /home/user/Strix
go build ./...
go test ./pkg/generate/ -v -run Test<Proto>
```
Every test must pass. If any fails:
- Re-read the corresponding xiaomi test and the difference in your version.
- Re-read the extractor function -- usually the bug is there, not in generate internals.
- Do NOT "fix" `pkg/generate/` to make tests pass. The generator has 16 passing tests for xiaomi already -- your protocol must fit the same contract.
---
## STEP 6: Sanity check the full generator output
Run once with a realistic URL manually (Bash + `go run` inline tool) and eyeball the YAML. Confirm:
- `go2rtc:` is top-level
- `streams:` and `<section>:` are both siblings under `go2rtc:` with 2-space indent
- Keys under `<section>:` use 4-space indent
- No duplicate headers
- Secret appears verbatim (no `%2F`, no `%3D`)
Do NOT commit. Leave changes staged for the user to review and commit manually.
---
## ABSOLUTES -- DO NOT VIOLATE
1. **Never modify `pkg/generate/registry.go`, `config.go`, `writer.go`, `insert.go`.** If you think you need to, the protocol probably doesn't fit the extractor contract -- stop and discuss with the user.
2. **Never add a new extractor to `pkg/generate/`.** Extractors live in `internal/<proto>/`, the test file is the only thing in `pkg/generate/`.
3. **Never modify `pkg/<proto>/`.** That code comes from go2rtc; we don't own it.
4. **Never write tests in `internal/<proto>/`.** All generator tests go to `pkg/generate/<proto>_test.go`.
5. **Never call `generate.RegisterExtract` from inside a test.** Use `sync.Once` + a helper like `register<Proto>()` inside the test file.
6. **Never commit.** Leave the changes for the user.
7. **Never skip tests.** All 16 scenarios are mandatory -- they caught real regressions during development.
+135 -76
View File
@@ -13,23 +13,30 @@ The protocol name is provided as argument (e.g. `/add_protocol_strix bubble`). I
## Repositories
- Strix: `/home/user/Strix`
- Strix: current working directory (`/home/user/Strix`)
- go2rtc: `/home/user/go2rtc` (reference implementation, read-only)
- StrixCamDB: issues at https://github.com/eduard256/StrixCamDB/issues (for database updates)
## Related skills (know when to hand off)
- `/add_generate_strix <proto>` -- register a credentials extractor for the Frigate config generator. Run AFTER this skill if the protocol has tokens/passwords that must go into a separate YAML section of `frigate-config.yaml`.
- `/add_probe_detector_strix <proto>` -- add a device-type detector to `/api/probe` so the frontend auto-routes a matching IP to your new protocol page.
---
## STEP 0: Understand the existing RTSP implementation (REFERENCE)
## STEP 0: Understand the existing implementations (REFERENCE)
Before doing anything, read these files completely to understand the patterns:
```
/home/user/Strix/pkg/tester/source.go -- handler registry + RTSP reference implementation
/home/user/Strix/pkg/tester/worker.go -- how handlers are called, screenshot logic
/home/user/Strix/pkg/tester/session.go -- session data structures
/home/user/Strix/pkg/camdb/streams.go -- URL builder, placeholder replacement
/home/user/Strix/internal/test/test.go -- API layer for tester
/home/user/Strix/internal/search/search.go -- search API (rarely needs changes)
pkg/tester/source.go -- handler registry + RTSP reference (Type A)
pkg/tester/worker.go -- how handlers are called, screenshot logic
pkg/tester/session.go -- session data structures
pkg/camdb/streams.go -- URL builder, placeholder replacement
internal/test/test.go -- API layer for tester
internal/search/search.go -- search API (rarely needs changes)
internal/xiaomi/xiaomi.go -- golden reference for Type B with cloud auth + token URLs
internal/homekit/homekit.go -- reference for Type B with pairing-based custom source blocks
```
### How RTSP works (the reference pattern)
@@ -99,11 +106,13 @@ go2rtc already implements most camera protocols. Study the implementation:
| What | Where |
|------|-------|
| Protocol client logic | `/home/user/go2rtc/pkg/{protocol}/` |
| Module registration | `/home/user/go2rtc/internal/{protocol}/` |
| Core interfaces | `/home/user/go2rtc/pkg/core/core.go` |
| Stream handler registry | `/home/user/go2rtc/internal/streams/handlers.go` |
| Keyframe capture | `/home/user/go2rtc/pkg/magic/keyframe.go` |
| Protocol client logic | `../go2rtc/pkg/{protocol}/` |
| Module registration | `../go2rtc/internal/{protocol}/` |
| Core interfaces | `../go2rtc/pkg/core/core.go` |
| Stream handler registry | `../go2rtc/internal/streams/handlers.go` |
| Keyframe capture | `../go2rtc/pkg/magic/keyframe.go` |
**Version note:** cloud-auth protocols like `xiaomi` require go2rtc >= 1.9.13. Frigate `stable` still ships with go2rtc 1.9.10; Frigate `dev`/0.18+ upgrades to 1.9.13+. A user on Frigate stable cannot stream a xiaomi camera even if Strix generates a perfect config -- the go2rtc binary inside Frigate will log `unsupported scheme`. Mention this in your handoff.
### Protocol map in go2rtc
@@ -161,24 +170,36 @@ Most protocols follow this exact pattern: `pkg/{protocol}.Dial(url)` returns `co
Use AskUserQuestion to discuss with the user. Determine the protocol type:
### Type A: Standard URL-based protocol (rtsp, rtmp, bubble, dvrip, http, etc.)
### Type A: Standard URL-based protocol (rtsp, rtmp, bubble, dvrip, http)
- Has URL scheme (e.g. `bubble://host:port/path`)
- Has URL scheme (ex. `bubble://host:port/path`)
- URLs stored in StrixCamDB database
- Flow: user searches camera -> gets URL templates -> URLs built with credentials -> sent to tester
- Needs: stream handler in tester + default port in URL builder + database issue
- Hands off to: no other skill needed (credentials live in userinfo, captured by the URL itself)
### Type B: Custom/discovery protocol (homekit, onvif, etc.)
### Type B1: Custom pairing / discovery (homekit)
- Does NOT use standard URL templates from database
- Has custom discovery or authentication flow
- Data comes from probe endpoint or direct user input, NOT from camera search
- Needs: source handler in tester with custom logic, possibly probe endpoint update
- Does NOT need database issue
- Does NOT use URL templates from database
- mDNS discovery + multi-step pairing (PIN, PSK, etc.)
- Custom frontend page, custom API endpoint, custom source block in `/api/test`
- Data comes from `/api/probe` or direct user input
- Needs: `SourceBlockHandler` registration, pairing endpoint, dedicated HTML page
- Reference: `internal/homekit/homekit.go`, `www/homekit.html`
- Hands off to: `/add_probe_detector_strix` (if detectable by IP)
### Type B2: Cloud-auth with token URL (xiaomi, tapo, nest, ring, roborock, tuya)
- Has URL scheme (`xiaomi://userID:region@IP?did=X&model=Y&token=T`)
- Credentials come from a cloud API (Mi Cloud, Tapo Cloud, etc.) not from local discovery
- Stateless design: token is extracted server-side, embedded in URL, then consumed by a generator extractor that moves it to a dedicated YAML section
- Copy `internal/<proto>/<proto>.go` from go2rtc (adapt imports), add API endpoint for the cloud login flow, build a dedicated HTML page mirroring `www/xiaomi.html`
- Reference: `internal/xiaomi/xiaomi.go` (copy template), `www/xiaomi.html` (UI template)
- Hands off to: `/add_generate_strix <proto>` for the YAML credentials extractor, `/add_probe_detector_strix <proto>` if detectable by IP
### Type C: HTTP sub-protocol (mjpeg, jpeg snapshot, hls)
- Uses http:// or https:// URL scheme
- Uses `http://` or `https://` URL scheme
- Already has URLs in database (same as HTTP)
- Needs special handling in tester based on Content-Type response
- Needs: stream handler that detects content type and handles accordingly
@@ -331,40 +352,70 @@ func {scheme}Handler(rawURL string) (core.Producer, error) {
}
```
### For custom protocols (Type B -- homekit, onvif, etc.)
### For Type B2 -- cloud-auth protocols (xiaomi, tapo, nest, ring, roborock, tuya)
These protocols do NOT go through the standard URL -> handler flow. They need a **source handler** that receives custom parameters and produces results directly.
Use xiaomi as the golden reference. These protocols fit the normal `tester.RegisterSource` contract -- their URL scheme IS routable, you just have to do cloud auth first and embed the resulting token in the URL.
The current architecture uses `SourceHandler func(rawURL string) (core.Producer, error)` for standard protocols. For custom protocols, you need to:
**1. Copy `internal/<proto>/<proto>.go` from go2rtc** into Strix. Change imports:
```go
// go2rtc:
"github.com/AlexxIT/go2rtc/internal/api"
"github.com/AlexxIT/go2rtc/internal/app"
"github.com/AlexxIT/go2rtc/internal/streams"
// strix:
"github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app"
"github.com/eduard256/strix/pkg/tester"
```
Replace `streams.HandleFunc("<proto>", ...)` with `tester.RegisterSource("<proto>", ...)`. Drop `app.LoadConfig`/`app.PatchConfig` calls -- Strix is stateless, tokens live only in memory + URL (see xiaomi for the pattern).
**2. Stream handler extracts token from URL query** and seeds the in-memory cache:
```go
tester.RegisterSource("<proto>", func(rawURL string) (core.Producer, error) {
u, _ := url.Parse(rawURL)
// seed in-memory tokens cache from the URL so cloud-auth'd functions work
if token := u.Query().Get("token"); token != "" && u.User != nil {
...
}
if u.User != nil {
rawURL, _ = getCameraURL(u) // cloud call for p2p keys
}
return <proto>.Dial(rawURL)
})
```
**3. Cloud auth API endpoint** -- 4-step flow (username/password -> captcha -> 2FA -> success):
```go
api.HandleFunc("api/<proto>", apiHandler)
```
See `internal/xiaomi/xiaomi.go` for the exact switch on GET/POST and the 401+JSON-with-captcha/verify_phone response shape. The frontend mirrors this across several state transitions.
**4. Register with `main.go`:**
```go
modules := []module{
...
{"<proto>", <proto>.Init},
}
```
**5. Build a frontend page `www/<proto>.html`** mirroring `www/xiaomi.html`. It has 6 states: loading, login, captcha, verify, region picker, not found. Also update `www/index.html`'s `navigateXiaomi`-style router to handle this protocol's probe type.
**6. Register credentials extractor with the config generator.** Do this in THE SAME `Init()` by calling `/add_generate_strix <proto>` (or hand off to that skill). The extractor strips `?token=...` from the URL and moves it into a top-level section under `go2rtc:` in the generated Frigate config.
### For Type B1 -- pairing-based protocols (homekit)
These don't fit the URL scheme contract -- data comes from a mDNS discovery plus a user-entered PIN. They use `SourceBlockHandler` instead of `SourceHandler`:
**1. Define block handler in `pkg/tester/source.go`:**
1. Extend the POST /api/test request to accept custom source blocks
2. Handle them separately from the `streams` array
Current request format:
```json
{
"sources": {
"streams": ["rtsp://...", "http://..."]
}
}
```
Extended format for custom protocols:
```json
{
"sources": {
"streams": ["rtsp://...", "http://..."],
"homekit": {"device_id": "AA:BB:CC", "pin": "123-45-678"},
"onvif": {"host": "192.168.1.100", "username": "admin", "password": "pass"}
}
}
```
To implement this:
1. Define a source handler type in `/home/user/Strix/pkg/tester/source.go`:
```go
// SourceBlockHandler processes a custom source block, writes results directly to session
type SourceBlockHandler func(data json.RawMessage, s *Session)
var sourceHandlers = map[string]SourceBlockHandler{}
@@ -374,15 +425,24 @@ func RegisterSourceBlock(name string, handler SourceBlockHandler) {
}
```
2. Update `/home/user/Strix/internal/test/test.go` `apiTestCreate()` to parse and dispatch custom source blocks.
**2. Update `internal/test/test.go:apiTestCreate()`** to parse and dispatch custom source blocks alongside `sources.streams`.
3. Write the handler for your protocol. It receives raw JSON and a Session, and is responsible for:
- Parsing its own parameters
- Running its own discovery/test logic
- Adding Results to the Session
- Calling `s.AddTested()` for progress tracking
**3. Extended request format:**
**IMPORTANT**: Before implementing a custom protocol, discuss the approach with the user. Custom protocols are rare and need careful design.
```json
{
"sources": {
"streams": ["rtsp://...", "http://..."],
"homekit": {"device_id": "AA:BB:CC", "pin": "123-45-678"}
}
}
```
**4. Write the block handler** -- parses its params, runs pairing, calls `s.AddResult(...)` and `s.AddTested(...)` directly.
Reference: `internal/homekit/homekit.go`, `www/homekit.html`.
**IMPORTANT**: Before starting Type B1, discuss the approach with the user -- pairing flows are rare and each one is custom.
---
@@ -423,19 +483,14 @@ curl -s -X POST http://localhost:4567/api/test \
---
## STEP 7: Commit and push
## STEP 7: Hand off to related skills
```bash
cd /home/user/Strix
git add -A
git commit -m "Add {protocol} protocol support
Once the tester handler works and the test returns a screenshot, the protocol is NOT fully wired yet. Check what else is needed:
- Register {protocol} stream handler using go2rtc pkg/{protocol}
- Add default port {port} for {protocol} scheme
- {any other changes}"
- **Does the URL carry credentials (tokens, passwords)?** Run `/add_generate_strix <proto>` to register the extractor that moves them into a top-level section of the generated Frigate config. Without this, `frigate-config.yaml` embeds the full URL with the token, and a user pasting the config into Frigate directly will leak the secret (plus `go2rtc:xiaomi` section won't populate).
- **Is the device detectable by IP probe?** Run `/add_probe_detector_strix <proto>` so that `/api/probe?ip=X` returns `type: "<proto>"` and the frontend auto-routes to the protocol page.
git push origin develop
```
Do NOT commit -- leave changes staged for the user to review.
---
@@ -568,14 +623,18 @@ Many protocols use `pkg/tcp` for low-level connection:
## CHECKLIST BEFORE FINISHING
- [ ] Read all existing protocol handlers in `source.go`
- [ ] Read go2rtc pkg/ and internal/ for this protocol
- [ ] Determined protocol type (A/B/C)
- [ ] Read all existing protocol handlers in `pkg/tester/source.go`
- [ ] Read xiaomi (`internal/xiaomi/xiaomi.go`) AND homekit (`internal/homekit/homekit.go`) as references
- [ ] Read go2rtc `pkg/` and `internal/` for this protocol
- [ ] Determined protocol type (A / B1 / B2 / C)
- [ ] For Type A: created StrixCamDB issue (protocol + placeholders if needed)
- [ ] For Type A: added default port to `defaultPorts` in `streams.go` (if not already there)
- [ ] Added handler registration in `source.go` init() or new file
- [ ] Handler follows RTSP pattern: Dial -> return Producer
- [ ] Added handler registration (or full `internal/<proto>/` module for B2)
- [ ] Handler follows established pattern (RTSP for A, xiaomi for B2, homekit for B1)
- [ ] Error messages prefixed with protocol name
- [ ] Connections closed on error
- [ ] Code compiles: `go build ./...`
- [ ] Committed and pushed to develop
- [ ] `go build ./...` compiles
- [ ] For B2: frontend page created (`www/<proto>.html`) and `www/index.html` router updated
- [ ] For B2: `/add_generate_strix <proto>` run to register the credentials extractor
- [ ] For detectable protocols: `/add_probe_detector_strix <proto>` run
- [ ] Changes LEFT STAGED (not committed -- user will review)
+1 -3
View File
@@ -147,11 +147,9 @@ Or run with `--privileged` if you prefer.
### Umbrel
<a href="https://apps.umbrel.com/app/strix">
<img src="https://apps.umbrel.com/api/app/strix/badge-light.svg" alt="Install on Umbrel" height="60">
<img src="https://apps.umbrel.com/badge-light.svg" alt="Install on Umbrel" height="60">
</a>
Install in one click from the [Umbrel App Store](https://apps.umbrel.com/app/strix).
### Binary
Download from [GitHub Releases](https://github.com/eduard256/Strix/releases). No dependencies except `ffmpeg` for screenshot conversion.
+24 -11
View File
@@ -1,5 +1,14 @@
# Strix + Frigate
# Usage: docker compose -f docker-compose.frigate.yml up -d
# Usage:
# curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.frigate.yml
# mkdir -p frigate/config frigate/storage
# docker compose -f docker-compose.frigate.yml up -d
#
# Strix UI: http://YOUR_IP:4567
# Frigate UI: http://YOUR_IP:8971
# Frigate API: http://YOUR_IP:5000 (internal, no auth)
# go2rtc UI: http://YOUR_IP:1984 (built into Frigate)
# RTSP restream: rtsp://YOUR_IP:8554
services:
strix:
@@ -9,22 +18,31 @@ services:
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
STRIX_FRIGATE_URL: "http://localhost:8971"
STRIX_FRIGATE_URL: "http://localhost:5000"
# STRIX_GO2RTC_URL: "http://localhost:1984"
# STRIX_LOG_LEVEL: debug
depends_on:
- frigate
frigate:
condition: service_started
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable
privileged: true
network_mode: host
restart: unless-stopped
stop_grace_period: 30s
shm_size: "512mb"
# Uncomment devices for your hardware:
# devices:
# - /dev/bus/usb:/dev/bus/usb # USB Coral
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
# - /dev/dri/renderD128:/dev/dri/renderD128 # Intel/AMD GPU
# - /dev/bus/usb:/dev/bus/usb # USB Coral
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
# - /dev/dri:/dev/dri # Intel/AMD GPU
# - /dev/accel:/dev/accel # Intel NPU
# - /dev/video11:/dev/video11 # Raspberry Pi 4
# For Nvidia GPU use image: ghcr.io/blakeblackshear/frigate:stable-tensorrt
# For AMD GPU use image: ghcr.io/blakeblackshear/frigate:stable-rocm
# For Rockchip use image: ghcr.io/blakeblackshear/frigate:stable-rk
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate/config:/config
@@ -33,10 +51,5 @@ services:
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "8971:8971"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
environment:
FRIGATE_RTSP_PASSWORD: "password"
+16 -1
View File
@@ -51,6 +51,14 @@ func Init() {
return ""
})
// Xiaomi detector (miIO hello on UDP:54321)
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.Xiaomi != nil {
return "xiaomi"
}
return ""
})
api.HandleFunc("api/probe", apiProbe)
}
@@ -129,12 +137,19 @@ func runProbe(parent context.Context, ip string) *probe.Response {
resp.Probes.ONVIF = r
mu.Unlock()
})
run(func() {
r, _ := probe.ProbeXiaomi(fastCtx, ip)
mu.Lock()
resp.Probes.Xiaomi = r
mu.Unlock()
})
wg.Wait()
// determine reachable
resp.Reachable = (resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0) ||
resp.Probes.MDNS != nil
resp.Probes.MDNS != nil ||
resp.Probes.Xiaomi != nil
// determine type
resp.Type = "standard"
+399
View File
@@ -0,0 +1,399 @@
package xiaomi
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/xiaomi"
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
"github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app"
"github.com/eduard256/strix/pkg/generate"
"github.com/eduard256/strix/pkg/tester"
"github.com/rs/zerolog"
)
func Init() {
log = app.GetLogger("xiaomi")
tester.RegisterSource("xiaomi", func(rawURL string) (core.Producer, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
// stateless: token comes from URL query, not from persistent config
if token := u.Query().Get("token"); token != "" && u.User != nil {
userID := u.User.Username()
cloudsMu.Lock()
if tokens == nil {
tokens = map[string]string{userID: token}
} else {
tokens[userID] = token
}
cloudsMu.Unlock()
}
if u.User != nil {
rawURL, err = getCameraURL(u)
if err != nil {
return nil, err
}
}
log.Debug().Msgf("xiaomi: dial %s", rawURL)
return xiaomi.Dial(rawURL)
})
api.HandleFunc("api/xiaomi", apiXiaomi)
generate.RegisterExtract("xiaomi", extractForConfig)
}
// extractForConfig strips ?token=... from xiaomi:// URL and returns
// userID + token for a top-level `xiaomi:` yaml section.
// ex. xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X&token=V1:...
// -> xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X, "xiaomi", "4161148305", "V1:..."
func extractForConfig(rawURL string) (cleaned, section, key, value string) {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL, "", "", ""
}
q := u.Query()
token := q.Get("token")
if token == "" {
return rawURL, "", "", ""
}
q.Del("token")
u.RawQuery = q.Encode()
return u.String(), "xiaomi", u.User.Username(), token
}
var log zerolog.Logger
var tokens map[string]string
var clouds map[string]*xiaomi.Cloud
var cloudsMu sync.Mutex
func getCloud(userID string) (*xiaomi.Cloud, error) {
cloudsMu.Lock()
defer cloudsMu.Unlock()
if cloud := clouds[userID]; cloud != nil {
return cloud, nil
}
cloud := xiaomi.NewCloud(AppXiaomiHome)
if err := cloud.LoginWithToken(userID, tokens[userID]); err != nil {
return nil, err
}
if clouds == nil {
clouds = map[string]*xiaomi.Cloud{userID: cloud}
} else {
clouds[userID] = cloud
}
return cloud, nil
}
func cloudRequest(userID, region, apiURL, params string) ([]byte, error) {
cloud, err := getCloud(userID)
if err != nil {
return nil, err
}
return cloud.Request(GetBaseURL(region), apiURL, params, nil)
}
func cloudUserRequest(user *url.Userinfo, apiURL, params string) ([]byte, error) {
userID := user.Username()
region, _ := user.Password()
return cloudRequest(userID, region, apiURL, params)
}
func getCameraURL(url *url.URL) (string, error) {
model := url.Query().Get("model")
// It is not known which models need to be awakened.
// Probably all the doorbells and all the battery cameras.
if strings.Contains(model, ".cateye.") {
_ = wakeUpCamera(url)
}
// The getMissURL request has a fallback to getP2PURL.
// But for known models we can save one request to the cloud.
if xiaomi.IsLegacy(model) {
return getLegacyURL(url)
}
return getMissURL(url)
}
func getLegacyURL(url *url.URL) (string, error) {
query := url.Query()
clientPublic, clientPrivate, err := crypto.GenerateKey()
if err != nil {
return "", err
}
params := fmt.Sprintf(`{"did":"%s","toSignAppData":"%x"}`, query.Get("did"), clientPublic)
userID := url.User.Username()
region, _ := url.User.Password()
res, err := cloudRequest(userID, region, "/device/devicepass", params)
if err != nil {
return "", err
}
var v struct {
UID string `json:"p2p_id"`
Password string `json:"password"`
PublicKey string `json:"p2p_dev_public_key"`
Sign string `json:"signForAppData"`
}
if err = json.Unmarshal(res, &v); err != nil {
return "", err
}
query.Set("uid", v.UID)
if v.Sign != "" {
query.Set("client_public", hex.EncodeToString(clientPublic))
query.Set("client_private", hex.EncodeToString(clientPrivate))
query.Set("device_public", v.PublicKey)
query.Set("sign", v.Sign)
} else {
query.Set("password", v.Password)
}
url.RawQuery = query.Encode()
return url.String(), nil
}
func getMissURL(url *url.URL) (string, error) {
clientPublic, clientPrivate, err := crypto.GenerateKey()
if err != nil {
return "", err
}
query := url.Query()
params := fmt.Sprintf(
`{"app_pubkey":"%x","did":"%s","support_vendors":"TUTK_CS2_MTP"}`,
clientPublic, query.Get("did"),
)
res, err := cloudUserRequest(url.User, "/v2/device/miss_get_vendor", params)
if err != nil {
if strings.Contains(err.Error(), "no available vendor support") {
return getLegacyURL(url)
}
return "", err
}
var v struct {
Vendor struct {
ID byte `json:"vendor"`
Params struct {
UID string `json:"p2p_id"`
} `json:"vendor_params"`
} `json:"vendor"`
PublicKey string `json:"public_key"`
Sign string `json:"sign"`
}
if err = json.Unmarshal(res, &v); err != nil {
return "", err
}
query.Set("client_public", hex.EncodeToString(clientPublic))
query.Set("client_private", hex.EncodeToString(clientPrivate))
query.Set("device_public", v.PublicKey)
query.Set("sign", v.Sign)
query.Set("vendor", getVendorName(v.Vendor.ID))
if v.Vendor.ID == 1 {
query.Set("uid", v.Vendor.Params.UID)
}
url.RawQuery = query.Encode()
return url.String(), nil
}
func getVendorName(i byte) string {
switch i {
case 1:
return "tutk"
case 3:
return "agora"
case 4:
return "cs2"
case 6:
return "mtp"
}
return fmt.Sprintf("%d", i)
}
func wakeUpCamera(url *url.URL) error {
const params = `{"id":1,"method":"wakeup","params":{"video":"1"}}`
did := url.Query().Get("did")
_, err := cloudUserRequest(url.User, "/home/rpc/"+did, params)
return err
}
func apiXiaomi(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
apiDeviceList(w, r)
case "POST":
apiAuth(w, r)
}
}
func apiDeviceList(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
user := query.Get("id")
if user == "" {
cloudsMu.Lock()
users := make([]string, 0, len(tokens))
for s := range tokens {
users = append(users, s)
}
cloudsMu.Unlock()
api.ResponseJSON(w, users)
return
}
err := func() error {
region := query.Get("region")
res, err := cloudRequest(user, region, "/v2/home/device_list_page", "{}")
if err != nil {
return err
}
var v struct {
List []*Device `json:"list"`
}
log.Trace().Str("user", user).Msgf("[xiaomi] devices list: %s", res)
if err = json.Unmarshal(res, &v); err != nil {
return err
}
cloudsMu.Lock()
token := tokens[user]
cloudsMu.Unlock()
type source struct {
Name string `json:"name"`
Info string `json:"info"`
URL string `json:"url"`
}
var items []*source
for _, device := range v.List {
if !device.HasCamera() {
continue
}
items = append(items, &source{
Name: device.Name,
Info: fmt.Sprintf("ip: %s, mac: %s", device.IP, device.MAC),
URL: fmt.Sprintf("xiaomi://%s:%s@%s?did=%s&model=%s&token=%s",
user, region, device.IP, device.Did, device.Model, url.QueryEscape(token)),
})
}
api.ResponseJSON(w, items)
return nil
}()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
type Device struct {
Did string `json:"did"`
Name string `json:"name"`
Model string `json:"model"`
MAC string `json:"mac"`
IP string `json:"localip"`
}
func (d *Device) HasCamera() bool {
return strings.Contains(d.Model, ".camera.") ||
strings.Contains(d.Model, ".cateye.") ||
strings.Contains(d.Model, ".feeder.")
}
var auth *xiaomi.Cloud
func apiAuth(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.Form.Get("username")
password := r.Form.Get("password")
captcha := r.Form.Get("captcha")
verify := r.Form.Get("verify")
var err error
switch {
case username != "" || password != "":
auth = xiaomi.NewCloud(AppXiaomiHome)
err = auth.Login(username, password)
case captcha != "":
err = auth.LoginWithCaptcha(captcha)
case verify != "":
err = auth.LoginWithVerify(verify)
default:
http.Error(w, "wrong request", http.StatusBadRequest)
return
}
if err == nil {
userID, token := auth.UserToken()
auth = nil
cloudsMu.Lock()
if tokens == nil {
tokens = map[string]string{userID: token}
} else {
tokens[userID] = token
}
cloudsMu.Unlock()
}
if err != nil {
var login *xiaomi.LoginError
if errors.As(err, &login) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(err)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
const AppXiaomiHome = "xiaomiio"
func GetBaseURL(region string) string {
switch region {
case "de", "i2", "ru", "sg", "us":
return "https://" + region + ".api.io.mi.com/app"
}
return "https://api.io.mi.com/app"
}
+2
View File
@@ -10,6 +10,7 @@ import (
"github.com/eduard256/strix/internal/probe"
"github.com/eduard256/strix/internal/search"
"github.com/eduard256/strix/internal/test"
"github.com/eduard256/strix/internal/xiaomi"
)
// version is set at build time via ldflags:
@@ -35,6 +36,7 @@ func main() {
{"frigate", frigate.Init},
{"go2rtc", go2rtc.Init},
{"homekit", homekit.Init},
{"xiaomi", xiaomi.Init},
}
for _, m := range modules {
+48 -11
View File
@@ -54,10 +54,16 @@ func buildInfo(req *Request) *cameraInfo {
streamBase = sanitized
}
mainSource, mainSection, mainKey, mainValue := runExtract(req.MainStream)
info := &cameraInfo{
CameraName: base,
MainStreamName: streamBase + "_main",
MainSource: req.MainStream,
MainSource: mainSource,
}
if mainSection != "" {
info.addCredential(mainSection, mainKey, mainValue)
}
if req.Name != "" {
@@ -70,7 +76,11 @@ func buildInfo(req *Request) *cameraInfo {
info.MainStreamName = req.Go2RTC.MainStreamName
}
if req.Go2RTC.MainStreamSource != "" {
info.MainSource = req.Go2RTC.MainStreamSource
src, section, key, value := runExtract(req.Go2RTC.MainStreamSource)
info.MainSource = src
if section != "" {
info.addCredential(section, key, value)
}
}
}
@@ -95,21 +105,33 @@ func buildInfo(req *Request) *cameraInfo {
if req.Name != "" {
subName = req.Name + "_sub"
}
subSource := req.SubStream
subSource, subSection, subKey, subValue := runExtract(req.SubStream)
if subSection != "" {
info.addCredential(subSection, subKey, subValue)
}
// apply go2rtc overrides BEFORE deriving subPath -- otherwise a rename
// would desync go2rtc.streams (new name) and frigate inputs (old name).
if req.Go2RTC != nil {
if req.Go2RTC.SubStreamName != "" {
subName = req.Go2RTC.SubStreamName
}
if req.Go2RTC.SubStreamSource != "" {
src, section, key, value := runExtract(req.Go2RTC.SubStreamSource)
subSource = src
if section != "" {
info.addCredential(section, key, value)
}
}
}
subPath := "rtsp://127.0.0.1:8554/" + subName
if needMP4[subScheme] {
subPath += "?mp4"
}
subInputArgs := "preset-rtsp-restream"
if req.Go2RTC != nil {
if req.Go2RTC.SubStreamName != "" {
subName = req.Go2RTC.SubStreamName
}
if req.Go2RTC.SubStreamSource != "" {
subSource = req.Go2RTC.SubStreamSource
}
}
if req.Frigate != nil {
if req.Frigate.SubStreamPath != "" {
subPath = req.Frigate.SubStreamPath
@@ -136,6 +158,10 @@ func newConfig(info *cameraInfo, req *Request) string {
b.WriteString("go2rtc:\n streams:\n")
writeStreamLines(&b, info)
writeCredentials(&b, info.Credentials)
if len(info.Credentials) == 0 {
b.WriteByte('\n')
}
b.WriteString("cameras:\n")
writeCameraBlock(&b, info, req)
@@ -156,6 +182,17 @@ type cameraInfo struct {
SubSource string
SubPath string
SubInputArgs string
Credentials map[string]map[string]string // section -> key -> value
}
func (c *cameraInfo) addCredential(section, key, value string) {
if c.Credentials == nil {
c.Credentials = map[string]map[string]string{}
}
if c.Credentials[section] == nil {
c.Credentials[section] = map[string]string{}
}
c.Credentials[section][key] = value
}
func urlScheme(rawURL string) string {
+190
View File
@@ -3,6 +3,7 @@ package generate
import (
"fmt"
"regexp"
"sort"
"strings"
)
@@ -14,6 +15,7 @@ var (
reStreamName = regexp.MustCompile(`^\s{4}'?(\w[\w-]*)'?:`)
reStreamContent = regexp.MustCompile(`^\s{4,}`)
reNextSection = regexp.MustCompile(`^[a-z#]`)
reSibling = regexp.MustCompile(`^ \w`) // sibling under go2rtc: (ex. ` xiaomi:`)
reCameraBody = regexp.MustCompile(`^\s{2,}\S`)
reVersion = regexp.MustCompile(`^version:`)
)
@@ -64,6 +66,9 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er
}
result = append(result, rest[split:]...)
// upsert credential sections (xiaomi, tapo, ...) before cameras:
result, added = upsertCredentials(result, info.Credentials, added)
config := strings.Join(result, "\n")
addedLines := make([]int, 0, len(added))
@@ -134,6 +139,13 @@ func findStreamInsertPoint(lines []string) int {
continue
}
if in {
// stop at sibling under go2rtc: (ex. ` xiaomi:`)
if reSibling.MatchString(line) && !reStreamsHeader.MatchString(line) {
if last >= 0 {
return last + 1
}
return headerIdx + 1
}
if reStreamContent.MatchString(line) {
last = i
} else if reNextSection.MatchString(line) {
@@ -156,6 +168,184 @@ func findStreamInsertPoint(lines []string) int {
return -1
}
// upsertCredentials merges creds into credential sections nested under go2rtc:.
// For each section: if a matching line ` "<key>":` exists -- replace its
// value; else insert in sorted order. If the section itself doesn't exist --
// create a new nested block inside go2rtc: (after streams: block).
func upsertCredentials(lines []string, creds map[string]map[string]string, added map[int]bool) ([]string, map[int]bool) {
if len(creds) == 0 {
return lines, added
}
sections := make([]string, 0, len(creds))
for s := range creds {
sections = append(sections, s)
}
sort.Strings(sections)
for _, section := range sections {
lines, added = upsertSection(lines, section, creds[section], added)
}
return lines, added
}
// ex. ` "4161148305": V1:xxx` -- 4-space indent under nested section
var reCredKey = regexp.MustCompile(`^\s{4}"([^"]+)":`)
func upsertSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) {
// section header is nested under go2rtc:, ex. ` xiaomi:`
reHeader := regexp.MustCompile(`^ ` + regexp.QuoteMeta(section) + `:\s*$`)
headerIdx := -1
for i, line := range lines {
if reHeader.MatchString(line) {
headerIdx = i
break
}
}
if headerIdx == -1 {
return insertNewSection(lines, section, kv, added)
}
// section exists -- find end (blank line, top-level header, or sibling 2-space key)
end := len(lines)
for i := headerIdx + 1; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" || reTopLevel.MatchString(line) {
end = i
break
}
// sibling under go2rtc: has 2-space indent, not 4
if len(line) >= 2 && line[0] == ' ' && line[1] == ' ' && (len(line) == 2 || line[2] != ' ') {
end = i
break
}
}
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
newLine := fmt.Sprintf(" %q: %s", k, kv[k])
// try replace -- no length change, just mark modified line
replaced := false
for i := headerIdx + 1; i < end; i++ {
if m := reCredKey.FindStringSubmatch(lines[i]); m != nil && m[1] == k {
if lines[i] != newLine {
lines[i] = newLine
added[i] = true
}
replaced = true
break
}
}
if replaced {
continue
}
// insert in sorted order within section
insertAt := headerIdx + 1
for i := headerIdx + 1; i < end; i++ {
if m := reCredKey.FindStringSubmatch(lines[i]); m != nil {
if m[1] < k {
insertAt = i + 1
} else {
break
}
} else {
insertAt = i + 1
}
}
lines = append(lines[:insertAt], append([]string{newLine}, lines[insertAt:]...)...)
added = shiftAdded(added, insertAt)
added[insertAt] = true
end++
}
return lines, added
}
// insertNewSection adds a new nested section under go2rtc:, after the streams:
// block but before any sibling go2rtc key or top-level header.
func insertNewSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) {
// find end of streams: block inside go2rtc:
insertAt := findGo2RTCInsertPoint(lines)
if insertAt < 0 {
return lines, added
}
block := []string{" " + section + ":"}
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
block = append(block, fmt.Sprintf(" %q: %s", k, kv[k]))
}
lines = append(lines[:insertAt], append(block, lines[insertAt:]...)...)
added = shiftAdded(added, insertAt)
for i := range block {
added[insertAt+i] = true
}
return lines, added
}
// findGo2RTCInsertPoint returns the line index where a new nested section
// under go2rtc: should be inserted -- after the last non-blank content line
// of the go2rtc: block.
func findGo2RTCInsertPoint(lines []string) int {
reGo2RTCHeader := regexp.MustCompile(`^go2rtc:\s*$`)
headerIdx := -1
for i, line := range lines {
if reGo2RTCHeader.MatchString(line) {
headerIdx = i
break
}
}
if headerIdx == -1 {
return -1
}
last := headerIdx
for i := headerIdx + 1; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "" {
continue
}
if reTopLevel.MatchString(line) {
break
}
last = i
}
return last + 1
}
// shiftAdded moves all marks at index >= from by +1. Also used with from=len(lines)
// as a no-op shift (just return same map).
func shiftAdded(added map[int]bool, from int) map[int]bool {
out := make(map[int]bool, len(added))
for i := range added {
if i >= from {
out[i+1] = true
} else {
out[i] = true
}
}
return out
}
func findCameraInsertPoint(lines []string) int {
in := false
last := -1
+24
View File
@@ -0,0 +1,24 @@
package generate
import "strings"
// ExtractFunc cleans rawURL (ex. strips ?token=...) and returns a top-level
// YAML section name + key/value to upsert into the config.
// Returns empty section if the URL has nothing to extract -- cleaned URL
// is still used as-is.
type ExtractFunc func(rawURL string) (cleaned, section, key, value string)
var extractors = map[string]ExtractFunc{}
func RegisterExtract(scheme string, fn ExtractFunc) {
extractors[scheme] = fn
}
func runExtract(rawURL string) (cleaned, section, key, value string) {
if i := strings.IndexByte(rawURL, ':'); i > 0 {
if fn := extractors[rawURL[:i]]; fn != nil {
return fn(rawURL)
}
}
return rawURL, "", "", ""
}
+35
View File
@@ -2,6 +2,7 @@ package generate
import (
"fmt"
"sort"
"strings"
)
@@ -13,7 +14,41 @@ func writeStreamLines(b *strings.Builder, info *cameraInfo) {
fmt.Fprintf(b, " '%s':\n", info.SubStreamName)
fmt.Fprintf(b, " - %s\n", info.SubSource)
}
}
// writeCredentials writes credential sections (xiaomi, tapo, ring, ...) as
// nested keys under go2rtc:, populated by registered ExtractFunc handlers.
// Sorted by section, then by key. Frigate only allows known keys at root,
// so credentials must live inside go2rtc: (which allows extra keys).
// ex.
// go2rtc:
// streams: { ... }
// xiaomi:
// "4161148305": V1:9d2w...
func writeCredentials(b *strings.Builder, creds map[string]map[string]string) {
if len(creds) == 0 {
return
}
sections := make([]string, 0, len(creds))
for s := range creds {
sections = append(sections, s)
}
sort.Strings(sections)
for _, section := range sections {
fmt.Fprintf(b, " %s:\n", section)
keys := make([]string, 0, len(creds[section]))
for k := range creds[section] {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(b, " %q: %s\n", k, creds[section][k])
}
}
b.WriteByte('\n')
}
+877
View File
@@ -0,0 +1,877 @@
package generate
import (
"strings"
"testing"
)
// End-to-end tests for writer.go: every writeX function is exercised through
// the public Generate entry-point so the tests survive internal refactoring.
// Shared helpers (mustGen / assertContains / assertNotContains / countOccurrences)
// are defined in xiaomi_test.go.
// baseRTSP is a neutral main-stream URL that does not trigger any extractor
// (registry), does not trigger needMP4, and has a stable IP for name derivation.
const baseRTSP = "rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101"
const baseSubRTSP = "rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/102"
// --- writeInput (roles) -------------------------------------------------------
// Without sub: a single input carries both detect and record roles.
func TestWriter_Input_SingleRoleCombined(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertContains(t, cfg, " inputs:\n")
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
assertContains(t, cfg, " input_args: preset-rtsp-restream\n")
assertContains(t, cfg, " roles:\n - detect\n - record\n")
if n := countOccurrences(cfg, "- path:"); n != 1 {
t.Errorf("expected 1 input, got %d\n%s", n, cfg)
}
}
// With sub: sub gets detect, main gets record -- sub must come FIRST in inputs list.
func TestWriter_Input_SubGetsDetectMainGetsRecord(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
})
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_sub\n")
assertContains(t, cfg, " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
subIdx := strings.Index(cfg, "rtsp://127.0.0.1:8554/10_0_20_10_sub")
mainIdx := strings.Index(cfg, "rtsp://127.0.0.1:8554/10_0_20_10_main\n input_args")
if !(subIdx > 0 && mainIdx > 0 && subIdx < mainIdx) {
t.Errorf("expected sub path to appear before main path in inputs:\n%s", cfg)
}
// sub has only detect role, main has only record
detectBlock := " - path: rtsp://127.0.0.1:8554/10_0_20_10_sub\n" +
" input_args: preset-rtsp-restream\n" +
" roles:\n - detect\n"
recordBlock := " - path: rtsp://127.0.0.1:8554/10_0_20_10_main\n" +
" input_args: preset-rtsp-restream\n" +
" roles:\n - record\n"
assertContains(t, cfg, detectBlock)
assertContains(t, cfg, recordBlock)
}
// --- needMP4 ------------------------------------------------------------------
// bubble:// MUST produce a restream path ending in ?mp4 -- Frigate bubble bug.
func TestWriter_NeedMP4_Bubble(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: "bubble://admin:pw@10.0.20.50:80/bubble/live?ch=0&stream=0",
})
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_50_main?mp4\n")
}
// Same for sub stream.
func TestWriter_NeedMP4_BubbleSub(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: "bubble://admin:pw@10.0.20.10:80/bubble/live?ch=0&stream=1",
})
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_sub?mp4\n")
// main is rtsp, MUST NOT have ?mp4
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
assertNotContains(t, cfg, "10_0_20_10_main?mp4")
}
// rtsp:// and other non-listed schemes MUST NOT append ?mp4.
func TestWriter_NeedMP4_RTSPNotAppended(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, "?mp4")
}
// --- writeFFmpegGlobal --------------------------------------------------------
// FFmpeg == nil -> no hwaccel_args, no gpu.
func TestWriter_FFmpeg_Nil(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, "hwaccel_args:")
assertNotContains(t, cfg, "gpu:")
}
// HWAccel="auto" is a sentinel -- it means "let Frigate decide", don't emit.
func TestWriter_FFmpeg_HWAccelAutoSkipped(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
FFmpeg: &FFmpegConfig{HWAccel: "auto"},
})
assertNotContains(t, cfg, "hwaccel_args:")
}
// Explicit preset is written verbatim.
func TestWriter_FFmpeg_HWAccelExplicit(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
FFmpeg: &FFmpegConfig{HWAccel: "preset-vaapi", GPU: 1},
})
assertContains(t, cfg, " hwaccel_args: preset-vaapi\n")
assertContains(t, cfg, " gpu: 1\n")
}
// GPU 0 (default) must not be written; >0 is.
func TestWriter_FFmpeg_GPUZeroSkipped(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
FFmpeg: &FFmpegConfig{HWAccel: "preset-vaapi"},
})
assertContains(t, cfg, "hwaccel_args: preset-vaapi")
assertNotContains(t, cfg, "gpu:")
}
// --- writeLive ----------------------------------------------------------------
// No sub + no Live config -> no live: block at all.
func TestWriter_Live_NoSubNoLive_Absent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " live:\n")
}
// Sub present -> live.streams with Main + Sub stream labels.
func TestWriter_Live_WithSub_StreamsMap(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
})
assertContains(t, cfg, " live:\n streams:\n")
assertContains(t, cfg, " Main Stream: 10_0_20_10_main\n")
assertContains(t, cfg, " Sub Stream: 10_0_20_10_sub\n")
}
// Live.Height / Live.Quality override defaults.
func TestWriter_Live_HeightAndQuality(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Live: &LiveConfig{Height: 720, Quality: 6},
})
assertContains(t, cfg, " live:\n")
assertContains(t, cfg, " height: 720\n")
assertContains(t, cfg, " quality: 6\n")
}
// Live with zero values omits those fields.
func TestWriter_Live_ZeroValuesOmitted(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Live: &LiveConfig{},
})
assertNotContains(t, cfg, " height: 0\n")
assertNotContains(t, cfg, " quality: 0\n")
}
// --- writeDetect --------------------------------------------------------------
// Detect == nil -> default is enabled: true (Frigate needs explicit detect block).
func TestWriter_Detect_NilDefaultsToEnabled(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertContains(t, cfg, " detect:\n enabled: true\n")
}
// Explicit enabled: false is written.
func TestWriter_Detect_ExplicitDisabled(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Detect: &DetectConfig{Enabled: false},
})
assertContains(t, cfg, " detect:\n enabled: false\n")
}
// FPS/Width/Height > 0 are written.
func TestWriter_Detect_CustomFPSWidthHeight(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Detect: &DetectConfig{Enabled: true, FPS: 10, Width: 1920, Height: 1080},
})
assertContains(t, cfg, " detect:\n enabled: true\n")
assertContains(t, cfg, " fps: 10\n")
assertContains(t, cfg, " width: 1920\n")
assertContains(t, cfg, " height: 1080\n")
}
// Zero values inside Detect are omitted (not written as "fps: 0" etc).
func TestWriter_Detect_ZeroValuesOmitted(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Detect: &DetectConfig{Enabled: true},
})
assertNotContains(t, cfg, " fps: 0\n")
assertNotContains(t, cfg, " width: 0\n")
assertNotContains(t, cfg, " height: 0\n")
}
// Setting Objects auto-enables detect even if Detect was nil (see Generate).
func TestWriter_Detect_ObjectsAutoEnable(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Objects: []string{"car"},
})
assertContains(t, cfg, " detect:\n enabled: true\n")
assertContains(t, cfg, " objects:\n track:\n - car\n")
}
// Objects + Detect{Enabled:false} -> Generate flips Enabled to true.
func TestWriter_Detect_ObjectsOverridesDisabledDetect(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Objects: []string{"dog"},
Detect: &DetectConfig{Enabled: false},
})
assertContains(t, cfg, " detect:\n enabled: true\n")
}
// --- writeObjects -------------------------------------------------------------
// Empty Objects list -> default ["person"].
func TestWriter_Objects_DefaultPerson(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertContains(t, cfg, " objects:\n track:\n - person\n")
}
// Explicit list preserves order.
func TestWriter_Objects_ExplicitListPreservesOrder(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Objects: []string{"car", "person", "cat"},
})
assertContains(t, cfg, " objects:\n track:\n - car\n - person\n - cat\n")
}
// --- writeMotion --------------------------------------------------------------
// Motion == nil -> block absent.
func TestWriter_Motion_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " motion:\n")
}
// Motion{} -> block with enabled: false.
func TestWriter_Motion_DisabledStillWritesBlock(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Motion: &MotionConfig{Enabled: false},
})
assertContains(t, cfg, " motion:\n enabled: false\n")
}
// Threshold / ContourArea > 0 are written.
func TestWriter_Motion_WithThresholdAndContour(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Motion: &MotionConfig{Enabled: true, Threshold: 30, ContourArea: 10},
})
assertContains(t, cfg, " motion:\n enabled: true\n")
assertContains(t, cfg, " threshold: 30\n")
assertContains(t, cfg, " contour_area: 10\n")
}
// Zero values inside Motion are omitted.
func TestWriter_Motion_ZeroOmitted(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Motion: &MotionConfig{Enabled: true},
})
assertNotContains(t, cfg, "threshold: 0")
assertNotContains(t, cfg, "contour_area: 0")
}
// --- writeRecord --------------------------------------------------------------
// Record == nil -> default enabled: true.
func TestWriter_Record_NilDefaultEnabled(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
// per-camera record block (top-level "record:\n enabled: true" also exists -- that's global)
assertContains(t, cfg, " record:\n enabled: true\n")
}
// Record.Enabled: false is written.
func TestWriter_Record_Disabled(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: false},
})
assertContains(t, cfg, " record:\n enabled: false\n")
}
// Only retain days -> retain block with days only.
func TestWriter_Record_RetainDaysOnly(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, RetainDays: 7},
})
assertContains(t, cfg, " record:\n enabled: true\n retain:\n days: 7\n")
assertNotContains(t, cfg, " mode:")
}
// Retain mode only (no days).
func TestWriter_Record_RetainModeOnly(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, Mode: "motion"},
})
assertContains(t, cfg, " retain:\n mode: motion\n")
assertNotContains(t, cfg, " days:")
}
// Fractional retain days -> %g formatting (0.5, not 5e-01).
func TestWriter_Record_FractionalRetainDays(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, RetainDays: 0.5},
})
assertContains(t, cfg, " days: 0.5\n")
}
// Alerts block: AlertsDays + PreCapture + PostCapture.
func TestWriter_Record_AlertsBlock(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, AlertsDays: 14, PreCapture: 5, PostCapture: 10},
})
assertContains(t, cfg, " alerts:\n")
assertContains(t, cfg, " retain:\n days: 14\n")
assertContains(t, cfg, " pre_capture: 5\n")
assertContains(t, cfg, " post_capture: 10\n")
}
// Only PreCapture -> alerts block appears with only pre_capture.
func TestWriter_Record_OnlyPreCaptureStillEmitsAlerts(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, PreCapture: 5},
})
assertContains(t, cfg, " alerts:\n")
assertContains(t, cfg, " pre_capture: 5\n")
assertNotContains(t, cfg, " days:")
}
// DetectionDays writes a separate detections block.
func TestWriter_Record_DetectionsDays(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{Enabled: true, DetectionDays: 30},
})
assertContains(t, cfg, " detections:\n retain:\n days: 30\n")
}
// All fields combined -- retain, alerts, detections all present.
func TestWriter_Record_AllFieldsCombined(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Record: &RecordConfig{
Enabled: true, RetainDays: 7, Mode: "all",
AlertsDays: 14, PreCapture: 5, PostCapture: 10,
DetectionDays: 30,
},
})
assertContains(t, cfg, " retain:\n days: 7\n mode: all\n")
assertContains(t, cfg, " alerts:\n retain:\n days: 14\n pre_capture: 5\n post_capture: 10\n")
assertContains(t, cfg, " detections:\n retain:\n days: 30\n")
}
// --- writeSnapshots -----------------------------------------------------------
func TestWriter_Snapshots_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " snapshots:\n")
}
func TestWriter_Snapshots_DisabledAbsent(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Snapshots: &BoolConfig{Enabled: false},
})
assertNotContains(t, cfg, " snapshots:\n")
}
func TestWriter_Snapshots_Enabled(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Snapshots: &BoolConfig{Enabled: true},
})
assertContains(t, cfg, " snapshots:\n enabled: true\n")
}
// --- writeAudio ---------------------------------------------------------------
func TestWriter_Audio_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " audio:\n")
}
func TestWriter_Audio_DisabledAbsent(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Audio: &AudioConfig{Enabled: false},
})
assertNotContains(t, cfg, " audio:\n")
}
func TestWriter_Audio_EnabledNoFilters(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Audio: &AudioConfig{Enabled: true},
})
assertContains(t, cfg, " audio:\n enabled: true\n")
assertNotContains(t, cfg, " filters:\n")
}
func TestWriter_Audio_WithFilters(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Audio: &AudioConfig{Enabled: true, Filters: []string{"speech", "bark"}},
})
assertContains(t, cfg, " filters:\n - speech\n - bark\n")
}
// --- writeBirdseye ------------------------------------------------------------
func TestWriter_Birdseye_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " birdseye:\n")
}
// Enabled:false is still written (birdseye may be globally on, per-camera off).
func TestWriter_Birdseye_DisabledExplicit(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Birdseye: &BirdseyeConfig{Enabled: false},
})
assertContains(t, cfg, " birdseye:\n enabled: false\n")
}
func TestWriter_Birdseye_EnabledWithMode(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Birdseye: &BirdseyeConfig{Enabled: true, Mode: "motion"},
})
assertContains(t, cfg, " birdseye:\n enabled: true\n mode: motion\n")
}
func TestWriter_Birdseye_EmptyModeOmitted(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Birdseye: &BirdseyeConfig{Enabled: true},
})
assertContains(t, cfg, " birdseye:\n enabled: true\n")
assertNotContains(t, cfg, " mode:")
}
// --- writeONVIF ---------------------------------------------------------------
// THIS IS THE BUG THE USER JUST FIXED: no ONVIF in request -> no block in config.
// (host comes from the frontend; if empty, block must not appear.)
func TestWriter_ONVIF_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " onvif:\n")
}
// Empty host -> block skipped even if ONVIFConfig is non-nil.
func TestWriter_ONVIF_EmptyHostAbsent(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{User: "admin"},
})
assertNotContains(t, cfg, " onvif:\n")
}
// Host without port -> default port 80.
func TestWriter_ONVIF_DefaultPort80(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
})
assertContains(t, cfg, " onvif:\n host: 10.0.20.10\n port: 80\n")
}
// Explicit port overrides default.
func TestWriter_ONVIF_ExplicitPort(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10", Port: 2020},
})
assertContains(t, cfg, " port: 2020\n")
assertNotContains(t, cfg, " port: 80\n")
}
// User set -> user + password lines (password lands even if empty -- by design).
func TestWriter_ONVIF_UserPassword(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10", User: "admin", Password: "s3cret"},
})
assertContains(t, cfg, " user: admin\n")
assertContains(t, cfg, " password: s3cret\n")
}
// No user -> no user/password lines.
func TestWriter_ONVIF_NoUserNoCredentials(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
})
assertNotContains(t, cfg, " user:")
assertNotContains(t, cfg, " password:")
}
// Autotracking enabled -> autotracking.enabled: true.
func TestWriter_ONVIF_Autotracking(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10", AutoTracking: true},
})
assertContains(t, cfg, " autotracking:\n enabled: true\n")
}
// Autotracking + required_zones -> nested list.
func TestWriter_ONVIF_AutotrackingRequiredZones(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{
Host: "10.0.20.10", AutoTracking: true,
RequiredZones: []string{"driveway", "yard"},
},
})
assertContains(t, cfg, " required_zones:\n - driveway\n - yard\n")
}
// required_zones without autotracking -> NOT written.
func TestWriter_ONVIF_RequiredZonesWithoutAutotracking(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{
Host: "10.0.20.10",
RequiredZones: []string{"driveway"},
},
})
assertNotContains(t, cfg, "required_zones:")
}
// --- writePTZ (only written inside onvif block) -------------------------------
// PTZ without ONVIF -> nothing written (writer is nested inside writeONVIF).
func TestWriter_PTZ_WithoutONVIFNotWritten(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
PTZ: &PTZConfig{Enabled: true, Presets: map[string]string{"home": "TOKEN1"}},
})
assertNotContains(t, cfg, " onvif:\n")
assertNotContains(t, cfg, " presets:\n")
}
// PTZ with ONVIF -> ptz.presets nested under onvif.
func TestWriter_PTZ_WithONVIF(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
PTZ: &PTZConfig{Enabled: true, Presets: map[string]string{"home": "TOKEN1"}},
})
assertContains(t, cfg, " ptz:\n presets:\n home: TOKEN1\n")
}
// Empty PTZ.Presets -> no ptz block even if ONVIF present.
func TestWriter_PTZ_EmptyPresetsNoBlock(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
PTZ: &PTZConfig{Enabled: true},
})
assertNotContains(t, cfg, " ptz:")
}
// --- writeNotifications -------------------------------------------------------
func TestWriter_Notifications_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " notifications:\n")
}
func TestWriter_Notifications_DisabledAbsent(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Notifications: &BoolConfig{Enabled: false},
})
assertNotContains(t, cfg, " notifications:\n")
}
func TestWriter_Notifications_Enabled(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Notifications: &BoolConfig{Enabled: true},
})
assertContains(t, cfg, " notifications:\n enabled: true\n")
}
// --- writeUI ------------------------------------------------------------------
func TestWriter_UI_NilAbsent(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
assertNotContains(t, cfg, " ui:\n")
}
// Dashboard:true is the default -> block emitted (because req.UI != nil) but no `dashboard: false`.
func TestWriter_UI_DashboardTrueDefault(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
UI: &UIConfig{Dashboard: true},
})
assertContains(t, cfg, " ui:\n")
assertNotContains(t, cfg, " dashboard:")
}
// Dashboard:false is written (hide from dashboard).
func TestWriter_UI_DashboardFalse(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
UI: &UIConfig{Dashboard: false},
})
assertContains(t, cfg, " ui:\n dashboard: false\n")
}
// Order > 0 written; Order 0 skipped.
func TestWriter_UI_OrderWritten(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
UI: &UIConfig{Order: 5, Dashboard: true},
})
assertContains(t, cfg, " order: 5\n")
}
func TestWriter_UI_OrderZeroSkipped(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
UI: &UIConfig{Dashboard: true},
})
assertNotContains(t, cfg, " order:")
}
// --- Frigate overrides --------------------------------------------------------
func TestWriter_FrigateOverride_MainStreamPath(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Frigate: &FrigateOverride{
MainStreamPath: "rtsp://10.0.0.5:8554/custom_main",
},
})
assertContains(t, cfg, "- path: rtsp://10.0.0.5:8554/custom_main\n")
assertNotContains(t, cfg, "- path: rtsp://127.0.0.1:8554/10_0_20_10_main\n")
}
func TestWriter_FrigateOverride_MainStreamInputArgs(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Frigate: &FrigateOverride{MainStreamInputArgs: "-rtsp_transport tcp -timeout 5000000"},
})
assertContains(t, cfg, " input_args: -rtsp_transport tcp -timeout 5000000\n")
assertNotContains(t, cfg, " input_args: preset-rtsp-restream\n")
}
func TestWriter_FrigateOverride_SubStreamPathAndArgs(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
Frigate: &FrigateOverride{
SubStreamPath: "rtsp://10.0.0.5:8554/custom_sub",
SubStreamInputArgs: "preset-rtsp-udp",
},
})
assertContains(t, cfg, "- path: rtsp://10.0.0.5:8554/custom_sub\n")
assertContains(t, cfg, " input_args: preset-rtsp-udp\n")
}
// --- Go2RTC overrides ---------------------------------------------------------
func TestWriter_Go2RTCOverride_MainStreamName(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Go2RTC: &Go2RTCOverride{MainStreamName: "front_door"},
})
assertContains(t, cfg, " 'front_door':\n - rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101\n")
// Frigate input path must follow the renamed stream.
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/front_door\n")
}
func TestWriter_Go2RTCOverride_MainStreamSource(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
Go2RTC: &Go2RTCOverride{MainStreamSource: "ffmpeg:file.mp4#video=h264"},
})
assertContains(t, cfg, " - ffmpeg:file.mp4#video=h264\n")
assertNotContains(t, cfg, " - rtsp://admin:pw@10.0.20.10:554/Streaming/Channels/101\n")
}
func TestWriter_Go2RTCOverride_SubStreamName(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
Go2RTC: &Go2RTCOverride{SubStreamName: "front_door_low"},
})
assertContains(t, cfg, " 'front_door_low':\n")
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/front_door_low\n")
// live.streams must use the renamed sub
assertContains(t, cfg, " Sub Stream: front_door_low\n")
}
// --- Name override ------------------------------------------------------------
func TestWriter_Name_OverrideChangesCameraAndStreamNames(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
Name: "porch",
})
assertContains(t, cfg, " porch:\n")
assertContains(t, cfg, " 'porch_main':\n")
assertContains(t, cfg, " 'porch_sub':\n")
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/porch_main\n")
assertContains(t, cfg, "- path: rtsp://127.0.0.1:8554/porch_sub\n")
}
// --- extractIP / buildInfo fallbacks ------------------------------------------
// URL without any parseable host -> camera/stream default names.
func TestWriter_BuildInfo_NoHostFallback(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: "rtsp:///nohost/stream"})
assertContains(t, cfg, " camera:\n")
assertContains(t, cfg, " 'stream_main':\n")
}
// Hostname (non-IP) is used as-is without dot-sanitization via reIPv4.
func TestWriter_BuildInfo_HostnameUsed(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: "rtsp://cam.local:554/stream"})
// hostname = "cam.local" -> sanitized "cam_local"
assertContains(t, cfg, " camera_cam_local:\n")
assertContains(t, cfg, " 'cam_local_main':\n")
}
// --- Generate entry-point errors ----------------------------------------------
func TestGenerate_EmptyMainStreamErrors(t *testing.T) {
_, err := Generate(&Request{})
if err == nil {
t.Fatal("expected error for empty MainStream")
}
if !strings.Contains(err.Error(), "mainStream required") {
t.Errorf("unexpected error: %v", err)
}
}
// Response.Added: fresh config -> all lines are new (1..N).
func TestGenerate_Added_FreshConfigAllLines(t *testing.T) {
resp, err := Generate(&Request{MainStream: baseRTSP})
if err != nil {
t.Fatal(err)
}
totalLines := strings.Count(resp.Config, "\n") + 1
if len(resp.Added) != totalLines {
t.Errorf("expected Added to cover all %d lines, got %d", totalLines, len(resp.Added))
}
// strictly increasing 1..N
for i, n := range resp.Added {
if n != i+1 {
t.Errorf("Added[%d] = %d, want %d", i, n, i+1)
break
}
}
}
// Response.Added: adding to existing config -> only new lines are flagged,
// and their indices (1-based) point to lines actually present in Config.
func TestGenerate_Added_IncrementalConfigOnlyNewLines(t *testing.T) {
c1, err := Generate(&Request{MainStream: baseRTSP})
if err != nil {
t.Fatal(err)
}
c2, err := Generate(&Request{
MainStream: "rtsp://admin:pw@10.0.20.20:554/stream",
ExistingConfig: c1.Config,
})
if err != nil {
t.Fatal(err)
}
if len(c2.Added) == 0 {
t.Fatal("expected some Added lines")
}
resultLines := strings.Split(c2.Config, "\n")
for _, n := range c2.Added {
if n < 1 || n > len(resultLines) {
t.Errorf("Added line %d out of bounds (1..%d)", n, len(resultLines))
}
}
// must be less than total (otherwise nothing was preserved)
if len(c2.Added) >= len(resultLines) {
t.Errorf("Added covers %d of %d lines -- expected partial", len(c2.Added), len(resultLines))
}
}
// --- top-level structure stability --------------------------------------------
// Required top-level headers + order (mqtt -> record -> go2rtc -> cameras -> version).
func TestWriter_TopLevel_Order(t *testing.T) {
cfg := mustGen(t, &Request{MainStream: baseRTSP})
iMQTT := strings.Index(cfg, "mqtt:")
iGlobalRec := strings.Index(cfg, "\nrecord:\n enabled: true")
iGo2rtc := strings.Index(cfg, "\ngo2rtc:")
iCameras := strings.Index(cfg, "\ncameras:")
iVersion := strings.Index(cfg, "\nversion:")
if iMQTT < 0 || iGlobalRec < 0 || iGo2rtc < 0 || iCameras < 0 || iVersion < 0 {
t.Fatalf("missing top-level section:\n%s", cfg)
}
if !(iMQTT < iGlobalRec && iGlobalRec < iGo2rtc && iGo2rtc < iCameras && iCameras < iVersion) {
t.Errorf("wrong top-level order: mqtt=%d record=%d go2rtc=%d cameras=%d version=%d",
iMQTT, iGlobalRec, iGo2rtc, iCameras, iVersion)
}
}
// Section order inside a camera block (writer.go sequence).
func TestWriter_CameraBlock_SectionOrder(t *testing.T) {
cfg := mustGen(t, &Request{
MainStream: baseRTSP,
SubStream: baseSubRTSP,
Live: &LiveConfig{Height: 720},
Detect: &DetectConfig{Enabled: true},
Objects: []string{"person"},
Motion: &MotionConfig{Enabled: true},
Record: &RecordConfig{Enabled: true},
Snapshots: &BoolConfig{Enabled: true},
Audio: &AudioConfig{Enabled: true},
Birdseye: &BirdseyeConfig{Enabled: true},
ONVIF: &ONVIFConfig{Host: "10.0.20.10"},
Notifications: &BoolConfig{Enabled: true},
UI: &UIConfig{Dashboard: false},
})
order := []string{
" ffmpeg:\n",
" live:\n",
" detect:\n",
" objects:\n",
" motion:\n",
" record:\n enabled:",
" snapshots:\n",
" audio:\n",
" birdseye:\n",
" onvif:\n",
" notifications:\n",
" ui:\n",
}
prev := -1
for _, s := range order {
idx := strings.Index(cfg, s)
if idx < 0 {
t.Errorf("missing section %q\n%s", s, cfg)
continue
}
if idx < prev {
t.Errorf("section %q out of order\n%s", s, cfg)
}
prev = idx
}
}
+396
View File
@@ -0,0 +1,396 @@
package generate
import (
"net/url"
"strings"
"sync"
"testing"
)
// registerXiaomi installs a xiaomi extractor identical to the one in
// internal/xiaomi. Tests live here (not in internal/xiaomi) because they
// validate generator behavior with xiaomi-style URLs.
var registerOnce sync.Once
func registerXiaomi() {
registerOnce.Do(func() {
RegisterExtract("xiaomi", func(rawURL string) (cleaned, section, key, value string) {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL, "", "", ""
}
q := u.Query()
token := q.Get("token")
if token == "" {
return rawURL, "", "", ""
}
q.Del("token")
u.RawQuery = q.Encode()
return u.String(), "xiaomi", u.User.Username(), token
})
})
}
// --- Helpers ---
func mustGen(t *testing.T, req *Request) string {
t.Helper()
r, err := Generate(req)
if err != nil {
t.Fatalf("Generate: %v", err)
}
return r.Config
}
func assertContains(t *testing.T, cfg, substr string) {
t.Helper()
if !strings.Contains(cfg, substr) {
t.Errorf("expected config to contain:\n %q\n--- got ---\n%s", substr, cfg)
}
}
func assertNotContains(t *testing.T, cfg, substr string) {
t.Helper()
if strings.Contains(cfg, substr) {
t.Errorf("expected config NOT to contain:\n %q\n--- got ---\n%s", substr, cfg)
}
}
func countOccurrences(s, substr string) int {
if substr == "" {
return 0
}
n := 0
for i := 0; ; {
j := strings.Index(s[i:], substr)
if j < 0 {
return n
}
n++
i += j + len(substr)
}
}
// ex. "xiaomi://user:cn@ip?did=D&model=M&token=T"
func xurl(user, region, ip, did, model, token string) string {
return "xiaomi://" + user + ":" + region + "@" + ip +
"?did=" + did + "&model=" + model + "&token=" + url.QueryEscape(token)
}
// --- Tests ---
// Single xiaomi camera in a fresh config.
func TestXiaomi_NewConfig_SingleCamera(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "chuangmi.camera.v1", "V1:TOK_A"),
})
assertContains(t, cfg, "go2rtc:\n streams:\n")
assertContains(t, cfg, " '10_0_20_229_main':\n")
assertContains(t, cfg, "- xiaomi://acc1:cn@10.0.20.229?did=1&model=chuangmi.camera.v1\n")
assertContains(t, cfg, " xiaomi:\n \"acc1\": V1:TOK_A\n")
assertNotContains(t, cfg, "token=")
assertNotContains(t, cfg, "\nxiaomi:") // must be nested, not top-level
}
// Two cameras on the same account -- token appears only once.
func TestXiaomi_SameAccount_TokenNotDuplicated(t *testing.T) {
registerXiaomi()
c1 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:TOK_A"),
})
c2 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.230", "2", "v2", "V1:TOK_A"),
ExistingConfig: c1,
})
if n := countOccurrences(c2, `"acc1":`); n != 1 {
t.Errorf("expected exactly 1 \"acc1\" key, got %d\n---\n%s", n, c2)
}
assertContains(t, c2, `"acc1": V1:TOK_A`)
assertContains(t, c2, " '10_0_20_229_main':")
assertContains(t, c2, " '10_0_20_230_main':")
}
// Two accounts -- both tokens present, sorted by key.
func TestXiaomi_TwoAccounts_SortedKeys(t *testing.T) {
registerXiaomi()
c1 := mustGen(t, &Request{
MainStream: xurl("zeta", "cn", "10.0.20.229", "1", "v1", "TOK_Z"),
})
c2 := mustGen(t, &Request{
MainStream: xurl("alpha", "de", "10.0.20.230", "2", "v2", "TOK_A"),
ExistingConfig: c1,
})
iAlpha := strings.Index(c2, `"alpha":`)
iZeta := strings.Index(c2, `"zeta":`)
if iAlpha < 0 || iZeta < 0 {
t.Fatalf("expected both keys:\n%s", c2)
}
if iAlpha >= iZeta {
t.Errorf("expected alpha before zeta (sorted)\n%s", c2)
}
}
// Re-login with a new token overwrites the existing value.
func TestXiaomi_TokenRefresh_OverwritesValue(t *testing.T) {
registerXiaomi()
c1 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:OLD"),
})
c2 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.230", "2", "v2", "V1:NEW"),
ExistingConfig: c1,
})
assertContains(t, c2, `"acc1": V1:NEW`)
assertNotContains(t, c2, `"acc1": V1:OLD`)
if n := countOccurrences(c2, `"acc1":`); n != 1 {
t.Errorf("expected 1 key after refresh, got %d", n)
}
}
// Main + Sub stream with same credentials -- token deduped to one entry.
func TestXiaomi_MainAndSub_SameAccount_OneToken(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "main", "V1:TOK"),
SubStream: xurl("acc1", "cn", "10.0.20.229", "1", "sub", "V1:TOK"),
})
if n := countOccurrences(cfg, `"acc1":`); n != 1 {
t.Errorf("expected 1 acc1 key for main+sub, got %d\n%s", n, cfg)
}
assertContains(t, cfg, " '10_0_20_229_main':")
assertContains(t, cfg, " '10_0_20_229_sub':")
assertNotContains(t, cfg, "token=")
}
// Main and Sub from different accounts -- both tokens in the section.
func TestXiaomi_MainAndSub_DifferentAccounts(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: xurl("accA", "cn", "10.0.20.229", "1", "v1", "TOK_A"),
SubStream: xurl("accB", "de", "10.0.20.229", "1", "v1", "TOK_B"),
})
assertContains(t, cfg, `"accA": TOK_A`)
assertContains(t, cfg, `"accB": TOK_B`)
}
// 10 cameras across 3 accounts added sequentially -- exactly 3 tokens at the end,
// correct token values, all streams present.
func TestXiaomi_Scale_10Cameras_3Accounts(t *testing.T) {
registerXiaomi()
cases := []struct{ user, ip, token string }{
{"accA", "10.0.20.10", "TOK_A_v1"},
{"accA", "10.0.20.11", "TOK_A_v1"},
{"accB", "10.0.20.12", "TOK_B_v1"},
{"accA", "10.0.20.13", "TOK_A_v1"},
{"accC", "10.0.20.14", "TOK_C_v1"},
{"accB", "10.0.20.15", "TOK_B_v2"}, // B gets refreshed
{"accC", "10.0.20.16", "TOK_C_v1"},
{"accA", "10.0.20.17", "TOK_A_v2"}, // A gets refreshed
{"accB", "10.0.20.18", "TOK_B_v2"},
{"accC", "10.0.20.19", "TOK_C_v2"}, // C gets refreshed
}
cfg := ""
for i, c := range cases {
req := &Request{
MainStream: xurl(c.user, "cn", c.ip, "1", "v", c.token),
}
if i > 0 {
req.ExistingConfig = cfg
}
cfg = mustGen(t, req)
}
if n := countOccurrences(cfg, `"accA":`); n != 1 {
t.Errorf("accA: expected 1 key, got %d", n)
}
if n := countOccurrences(cfg, `"accB":`); n != 1 {
t.Errorf("accB: expected 1 key, got %d", n)
}
if n := countOccurrences(cfg, `"accC":`); n != 1 {
t.Errorf("accC: expected 1 key, got %d", n)
}
// final (latest) tokens
assertContains(t, cfg, `"accA": TOK_A_v2`)
assertContains(t, cfg, `"accB": TOK_B_v2`)
assertContains(t, cfg, `"accC": TOK_C_v2`)
for _, c := range cases {
want := "xiaomi://" + c.user + ":cn@" + c.ip
if !strings.Contains(cfg, want) {
t.Errorf("missing stream URL %q", want)
}
}
// only one xiaomi: section header, only one go2rtc:
if n := countOccurrences(cfg, "\n xiaomi:\n"); n != 1 {
t.Errorf("expected 1 xiaomi: header, got %d", n)
}
if n := countOccurrences(cfg, "\ngo2rtc:\n"); n != 1 {
t.Errorf("expected 1 go2rtc: header, got %d", n)
}
}
// URL without ?token=... -- extractor returns empty section, no xiaomi: block written.
func TestXiaomi_URLWithoutToken_NoSection(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: "xiaomi://acc1:cn@10.0.20.229?did=1&model=v1",
})
// the nested section header would look like "\n xiaomi:\n" -- URL scheme
// "xiaomi://" must not trigger a false positive
assertNotContains(t, cfg, "\n xiaomi:\n")
assertContains(t, cfg, "- xiaomi://acc1:cn@10.0.20.229?did=1&model=v1\n")
}
// Malformed URL must not crash the generator; URL is passed through as-is.
func TestXiaomi_MalformedURL_DoesNotPanic(t *testing.T) {
registerXiaomi()
_, err := Generate(&Request{
MainStream: "xiaomi://%%%bad",
})
if err != nil {
t.Logf("Generate returned error (ok): %v", err)
}
}
// Token with base64 special chars (+ / =) must survive YAML write without escaping.
func TestXiaomi_TokenSpecialChars_PreservedRaw(t *testing.T) {
registerXiaomi()
raw := "V1:9d2w+abc/def=end="
cfg := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", raw),
})
assertContains(t, cfg, `"acc1": `+raw)
}
// Go2RTC override MainStreamSource must also pass through the extractor.
func TestXiaomi_Go2RTCOverride_PassesThroughExtractor(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: "rtsp://placeholder:554/stream",
Go2RTC: &Go2RTCOverride{
MainStreamSource: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:OVR"),
},
})
assertContains(t, cfg, `"acc1": V1:OVR`)
assertNotContains(t, cfg, "token=")
assertContains(t, cfg, "- xiaomi://acc1:cn@10.0.20.229?did=1&model=v1\n")
}
// addToConfig: existing config has no xiaomi: section -- must create one.
func TestXiaomi_AddToConfig_NoExistingSection(t *testing.T) {
registerXiaomi()
// start from a rtsp-only config
c1 := mustGen(t, &Request{
MainStream: "rtsp://user:pass@10.0.20.100/stream1",
})
assertNotContains(t, c1, "xiaomi:")
c2 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:TOK"),
ExistingConfig: c1,
})
assertContains(t, c2, " xiaomi:\n \"acc1\": V1:TOK\n")
assertContains(t, c2, "- rtsp://user:pass@10.0.20.100/stream1")
assertContains(t, c2, "- xiaomi://acc1:cn@10.0.20.229?did=1&model=v1")
}
// addToConfig: existing config already has xiaomi: section with other accounts.
func TestXiaomi_AddToConfig_ExistingSection(t *testing.T) {
registerXiaomi()
c1 := mustGen(t, &Request{
MainStream: xurl("accA", "cn", "10.0.20.10", "1", "v1", "TOK_A"),
})
c2 := mustGen(t, &Request{
MainStream: xurl("accB", "de", "10.0.20.20", "2", "v2", "TOK_B"),
ExistingConfig: c1,
})
assertContains(t, c2, `"accA": TOK_A`)
assertContains(t, c2, `"accB": TOK_B`)
// xiaomi: section stays nested, exactly one header
if n := countOccurrences(c2, "\n xiaomi:\n"); n != 1 {
t.Errorf("expected 1 xiaomi header, got %d\n%s", n, c2)
}
}
// Stream and camera names stay clean (no leftover tokens in URLs) with custom Name.
func TestXiaomi_CustomName_URLStillClean(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
Name: "my_cam",
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:TOK"),
})
assertContains(t, cfg, " 'my_cam_main':")
assertContains(t, cfg, " my_cam:")
assertNotContains(t, cfg, "token=")
assertContains(t, cfg, `"acc1": V1:TOK`)
}
// Mixed: rtsp + xiaomi -- rtsp URL untouched, xiaomi token extracted.
func TestXiaomi_MixedProtocols(t *testing.T) {
registerXiaomi()
c1 := mustGen(t, &Request{
MainStream: "rtsp://admin:pw@10.0.20.100/Streaming/Channels/101",
})
c2 := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:TOK"),
ExistingConfig: c1,
})
assertContains(t, c2, "- rtsp://admin:pw@10.0.20.100/Streaming/Channels/101")
assertContains(t, c2, "- xiaomi://acc1:cn@10.0.20.229?did=1&model=v1")
assertContains(t, c2, `"acc1": V1:TOK`)
}
// Order in generated config: go2rtc -> (streams, xiaomi) -> cameras -> version.
func TestXiaomi_SectionOrder(t *testing.T) {
registerXiaomi()
cfg := mustGen(t, &Request{
MainStream: xurl("acc1", "cn", "10.0.20.229", "1", "v1", "V1:TOK"),
})
iGo2rtc := strings.Index(cfg, "\ngo2rtc:\n")
iStreams := strings.Index(cfg, " streams:")
iXiaomi := strings.Index(cfg, " xiaomi:")
iCameras := strings.Index(cfg, "\ncameras:\n")
iVersion := strings.Index(cfg, "\nversion:")
if iGo2rtc < 0 || iStreams < 0 || iXiaomi < 0 || iCameras < 0 || iVersion < 0 {
t.Fatalf("missing section in config:\n%s", cfg)
}
if !(iGo2rtc < iStreams && iStreams < iXiaomi && iXiaomi < iCameras && iCameras < iVersion) {
t.Errorf("wrong section order: go2rtc=%d streams=%d xiaomi=%d cameras=%d version=%d\n%s",
iGo2rtc, iStreams, iXiaomi, iCameras, iVersion, cfg)
}
}
+12 -6
View File
@@ -9,12 +9,13 @@ type Response struct {
}
type Probes struct {
Ports *PortsResult `json:"ports"`
DNS *DNSResult `json:"dns"`
ARP *ARPResult `json:"arp"`
MDNS *MDNSResult `json:"mdns"`
HTTP *HTTPResult `json:"http"`
ONVIF *ONVIFResult `json:"onvif"`
Ports *PortsResult `json:"ports"`
DNS *DNSResult `json:"dns"`
ARP *ARPResult `json:"arp"`
MDNS *MDNSResult `json:"mdns"`
HTTP *HTTPResult `json:"http"`
ONVIF *ONVIFResult `json:"onvif"`
Xiaomi *XiaomiResult `json:"xiaomi"`
}
type PortsResult struct {
@@ -51,3 +52,8 @@ type ONVIFResult struct {
Name string `json:"name,omitempty"`
Hardware string `json:"hardware,omitempty"`
}
type XiaomiResult struct {
DeviceID uint32 `json:"device_id"`
Stamp uint32 `json:"stamp"`
}
+60
View File
@@ -0,0 +1,60 @@
package probe
import (
"context"
"encoding/binary"
"net"
"time"
)
// miIO hello packet -- 32 bytes. Stock Xiaomi/Mijia devices listen on
// UDP:54321 and reply with the same magic 0x2131 + their device_id + stamp.
// Newer firmwares always return 0xFF in the token field, regardless of
// pairing status -- real token is only available via Mi Cloud API.
var xiaomiHello = []byte{
0x21, 0x31, 0x00, 0x20,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff,
}
// ProbeXiaomi sends miIO hello to ip:54321 and checks the reply magic.
// Returns nil, nil if the device is not a Xiaomi miIO device.
func ProbeXiaomi(ctx context.Context, ip string) (*XiaomiResult, error) {
conn, err := net.ListenPacket("udp4", ":0")
if err != nil {
return nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
}
_ = conn.SetDeadline(deadline)
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 54321}
if _, err = conn.WriteTo(xiaomiHello, addr); err != nil {
return nil, err
}
buf := make([]byte, 64)
n, _, err := conn.ReadFrom(buf)
if err != nil || n < 32 {
return nil, nil
}
// magic must be 0x2131 -- unique miIO header
if buf[0] != 0x21 || buf[1] != 0x31 {
return nil, nil
}
return &XiaomiResult{
DeviceID: binary.BigEndian.Uint32(buf[8:12]),
Stamp: binary.BigEndian.Uint32(buf[12:16]),
}, nil
}
+1 -2
View File
@@ -479,8 +479,7 @@
var defaultName = ip ? 'camera_' + ip.replace(/\./g, '_') : 'camera';
document.getElementById('f-name').value = defaultName;
// prefill ONVIF from probe
if (ip) document.getElementById('f-onvif-host').value = ip;
// prefill ONVIF user only (host stays empty so the onvif block is opt-in)
if (userParam) document.getElementById('f-onvif-user').value = userParam;
// -- tabs (mobile) --
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

+35
View File
@@ -327,6 +327,11 @@
return;
}
if (data.type === 'xiaomi') {
navigateXiaomi(ip, data);
return;
}
if (data.type === 'standard' || data.reachable) {
navigateStandard(ip, data);
return;
@@ -411,6 +416,36 @@
window.location.href = 'standard.html?' + p.toString();
}
function navigateXiaomi(ip, data) {
var p = new URLSearchParams();
p.set('ip', ip);
var probes = data.probes || {};
if (probes.ports && probes.ports.open && probes.ports.open.length) {
p.set('ports', probes.ports.open.join(','));
}
if (probes.arp) {
if (probes.arp.mac) p.set('mac', probes.arp.mac);
if (probes.arp.vendor) p.set('vendor', probes.arp.vendor);
}
if (probes.http && probes.http.server) {
p.set('server', probes.http.server);
}
if (probes.dns && probes.dns.hostname) {
p.set('hostname', probes.dns.hostname);
}
if (probes.ping && probes.ping.latency_ms) {
p.set('latency', Math.round(probes.ping.latency_ms));
}
if (probes.xiaomi) {
if (probes.xiaomi.device_id) p.set('xiaomi_device_id', probes.xiaomi.device_id);
if (probes.xiaomi.stamp) p.set('xiaomi_stamp', probes.xiaomi.stamp);
}
window.location.href = 'xiaomi.html?' + p.toString();
}
function navigateHomeKit(ip, data) {
var p = new URLSearchParams();
p.set('ip', ip);
+1 -1
View File
@@ -2,5 +2,5 @@ package www
import "embed"
//go:embed *.html
//go:embed *.html icons
var Static embed.FS
+874
View File
@@ -0,0 +1,874 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - Xiaomi Camera</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--purple-glow-strong: rgba(139, 92, 246, 0.5);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--error: #ef4444;
--xiaomi-teal: #2BB6AC;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font-primary);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.screen {
min-height: 100vh;
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-base);
}
.container { max-width: 480px; width: 100%; }
@media (min-width: 768px) {
.screen { padding: 3rem 1.5rem; }
.container { max-width: 540px; }
}
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px;
padding: 0 1.5rem;
z-index: 10;
}
@media (min-width: 768px) {
.back-wrapper { max-width: 660px; }
}
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none;
color: var(--text-secondary); font-size: 0.875rem;
font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.hero { text-align: center; margin-bottom: 2.5rem; }
.xiaomi-logo {
width: 72px; height: 72px;
margin: 0 auto 0.875rem;
border-radius: 16px;
filter: drop-shadow(0 4px 16px rgba(43, 182, 172, 0.3));
display: block;
}
.title {
font-size: 1.25rem; font-weight: 600;
letter-spacing: 0.03em;
color: var(--text-primary);
}
/* Form */
.form-group { margin-bottom: 1.25rem; }
.label {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.875rem; font-weight: 500;
color: var(--text-secondary); margin-bottom: 0.5rem;
}
.input, .select {
width: 100%; padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 1rem; font-family: var(--font-primary);
transition: all var(--transition-fast);
outline: none;
}
.input:focus, .select:focus {
border-color: var(--purple-primary);
box-shadow: 0 0 0 3px var(--purple-glow);
}
.input::placeholder { color: var(--text-tertiary); }
.select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b0' stroke-width='2'%3e%3cpath d='M6 9l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1.25rem;
padding-right: 3rem;
cursor: pointer;
}
/* Info icon + tooltip */
.info-icon {
position: relative; display: inline-flex;
align-items: center; justify-content: center;
width: 16px; height: 16px; cursor: help;
color: var(--text-tertiary); transition: color var(--transition-fast);
}
.info-icon:hover { color: var(--purple-primary); }
.info-icon svg { width: 16px; height: 16px; }
.tooltip {
position: absolute; top: calc(100% + 8px); left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated);
border: 1px solid var(--purple-primary);
border-radius: 8px; padding: 1rem;
width: 300px; max-width: 90vw;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
z-index: 1000; opacity: 0; visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
pointer-events: none;
text-align: left;
}
.tooltip::after {
content: ''; position: absolute; bottom: 100%; left: 50%;
transform: translateX(-50%);
border: 6px solid transparent; border-bottom-color: var(--purple-primary);
}
.info-icon:hover .tooltip { opacity: 1; visibility: visible; }
.tooltip-title { font-weight: 600; color: var(--purple-primary); margin-bottom: 0.5rem; font-size: 0.875rem; }
.tooltip-text { font-size: 0.75rem; line-height: 1.5; color: var(--text-secondary); }
/* Captcha image */
.captcha-img {
display: block; margin: 0 auto 1rem;
height: 64px; width: auto;
background: #fff;
border-radius: 8px;
padding: 0.5rem;
}
/* Verify label */
.verify-target {
padding: 0.875rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.875rem;
text-align: center;
margin-bottom: 1rem;
}
/* Error / not found box */
.error-box {
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: var(--error);
font-size: 0.875rem;
margin-bottom: 1.25rem;
display: none;
animation: fadeIn var(--transition-fast);
}
.error-box.visible { display: block; }
/* Not found state */
.notfound {
text-align: center; padding: 1.5rem 0;
}
.notfound-title {
font-size: 1.125rem; font-weight: 600;
color: var(--error); margin-bottom: 0.75rem;
}
.notfound-text {
font-size: 0.875rem; color: var(--text-secondary);
line-height: 1.6; margin-bottom: 0.375rem;
}
.notfound-text code {
font-family: var(--font-mono);
color: var(--purple-light);
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
border: none; outline: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--purple-primary), var(--purple-dark));
color: white; box-shadow: 0 4px 12px var(--purple-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--purple-glow-strong);
}
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-large { width: 100%; padding: 1.25rem; font-size: 1rem; }
.btn-outline {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 0.9375rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border-color); width: 100%;
margin-top: 0.75rem;
}
.btn-outline:hover { border-color: var(--purple-primary); color: var(--purple-light); }
/* Spinner */
.spinner {
width: 20px; height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
vertical-align: middle;
}
.loading-wrap {
text-align: center; padding: 2rem 0;
}
.loading-spinner {
width: 28px; height: 28px;
border: 2px solid var(--border-color);
border-top-color: var(--purple-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin: 0 auto 1rem;
}
.loading-text {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* State switch */
.state { display: none; animation: fadeIn var(--transition-base); }
.state.visible { display: block; }
/* Toast */
.toast {
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 1rem 1.5rem;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 8px; box-shadow: var(--shadow-lg);
font-size: 0.875rem; color: var(--text-primary);
z-index: 1000; transition: transform var(--transition-base);
max-width: 90vw;
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.hidden { display: none; }
/* Modal: version-warning popup shown on page open.
Blocking (full-screen backdrop + centered panel) so the user
cannot miss the go2rtc/Frigate version caveat before attempting
to pair a Xiaomi camera. */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 1.5rem;
z-index: 2000;
animation: fadeIn var(--transition-base);
}
.modal-backdrop.hidden { display: none; }
.modal {
width: 100%; max-width: 420px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem;
box-shadow: var(--shadow-lg), 0 0 0 1px var(--purple-glow);
text-align: center;
animation: modalIn var(--transition-base);
}
.modal-icon {
width: 48px; height: 48px;
margin: 0 auto 1rem;
border-radius: 50%;
background: rgba(245, 158, 11, 0.12);
display: flex; align-items: center; justify-content: center;
color: #f59e0b;
}
.modal-icon svg { width: 28px; height: 28px; }
.modal-title {
font-size: 1.0625rem; font-weight: 600;
color: var(--text-primary); margin-bottom: 0.625rem;
letter-spacing: 0.01em;
}
.modal-text {
font-size: 0.875rem; line-height: 1.55;
color: var(--text-secondary);
margin-bottom: 1.25rem;
}
.modal-text p + p { margin-top: 0.625rem; }
.modal-text code {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--purple-light);
background: var(--bg-tertiary);
padding: 0.0625rem 0.375rem;
border-radius: 4px;
}
.modal .btn-primary { width: 100%; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalIn {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
</style>
</head>
<body>
<div class="back-wrapper">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Back
</button>
</div>
<div class="screen">
<div class="container">
<div class="hero">
<img class="xiaomi-logo" src="icons/mihome.webp" alt="Xiaomi" width="72" height="72">
<h1 class="title">Xiaomi Camera</h1>
</div>
<div class="error-box" id="error-box"></div>
<!-- Loading -->
<div class="state" id="state-loading">
<div class="loading-wrap">
<div class="loading-spinner"></div>
<div class="loading-text" id="loading-text">Loading...</div>
</div>
</div>
<!-- Login -->
<div class="state" id="state-login">
<form id="form-login">
<div class="form-group">
<label class="label" for="input-username">
Mi Account
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Mi Account</div>
<p class="tooltip-text">Use your Mi Home app credentials. Strix requests a login token from Mi Cloud to fetch the P2P keys needed to stream your camera.</p>
</div>
</span>
</label>
<input type="text" id="input-username" class="input" name="username" placeholder="Email, phone or ID" autocomplete="username" spellcheck="false" required>
</div>
<div class="form-group">
<label class="label" for="input-password">Password</label>
<input type="password" id="input-password" class="input" name="password" placeholder="Password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-large" id="btn-login">Log in</button>
</form>
<button class="btn-outline" id="btn-skip-login">Skip, use Standard Discovery</button>
</div>
<!-- Captcha -->
<div class="state" id="state-captcha">
<form id="form-captcha">
<div class="form-group">
<label class="label">Captcha</label>
<img id="captcha-img" class="captcha-img" alt="captcha">
<input type="text" id="input-captcha" class="input" name="captcha" placeholder="Enter captcha" autocomplete="off" spellcheck="false" required>
</div>
<button type="submit" class="btn btn-primary btn-large">Verify</button>
</form>
<button class="btn-outline" id="btn-back-login-1">Back</button>
</div>
<!-- Verify (2FA) -->
<div class="state" id="state-verify">
<form id="form-verify">
<div class="form-group">
<label class="label">
Verification Code
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Two-step verification</div>
<p class="tooltip-text">Mi Cloud sent a verification code to the address shown. Enter it to complete the login.</p>
</div>
</span>
</label>
<div class="verify-target" id="verify-target"></div>
<input type="text" id="input-verify" class="input" name="verify" placeholder="Enter code" autocomplete="one-time-code" inputmode="numeric" spellcheck="false" required>
</div>
<button type="submit" class="btn btn-primary btn-large">Verify</button>
</form>
<button class="btn-outline" id="btn-back-login-2">Back</button>
</div>
<!-- Region / account picker -->
<div class="state" id="state-region">
<form id="form-region">
<div class="form-group" id="group-account" style="display:none;">
<label class="label" for="select-account">Account</label>
<select id="select-account" class="select" name="id" required></select>
</div>
<div class="form-group">
<label class="label" for="select-region">
Server Region
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Mi Home server region</div>
<p class="tooltip-text">Pick the region your Mi Home account is registered in. This is the server setting you chose when you first signed up in the Mi Home app.</p>
</div>
</span>
</label>
<select id="select-region" class="select" name="region" required>
<option value="cn">China</option>
<option value="de">Europe</option>
<option value="i2">India</option>
<option value="ru">Russia</option>
<option value="sg">Singapore</option>
<option value="us">United States</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-large" id="btn-find">Find my camera</button>
</form>
<button class="btn-outline" id="btn-skip-region">Skip, use Standard Discovery</button>
<button class="btn-outline" id="btn-relogin">Log in with a different account</button>
</div>
<!-- Not found -->
<div class="state" id="state-notfound">
<div class="notfound">
<div class="notfound-title">Camera not found</div>
<p class="notfound-text">No camera with IP <code id="nf-ip"></code></p>
<p class="notfound-text">in this Mi Home account / region.</p>
</div>
<button class="btn btn-primary btn-large" id="btn-try-region">Try another region</button>
<button class="btn-outline" id="btn-skip-notfound">Skip, use Standard Discovery</button>
</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<!-- Version-warning modal: displayed on page load until the user dismisses it.
Remove this block once Frigate ships with go2rtc v1.9.13 or newer. -->
<div id="version-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="version-modal-title">
<div class="modal">
<div class="modal-icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3L2 20h20L12 3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M12 10v5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="17.5" r="1" fill="currentColor"/>
</svg>
</div>
<h2 class="modal-title" id="version-modal-title">Xiaomi support pending</h2>
<div class="modal-text">
<p>Frigate <code>0.17.1-stable</code> ships with go2rtc below <code>v1.9.13</code>, which is required for Xiaomi cameras.</p>
<p>Use an external go2rtc <code>v1.9.13+</code>, or wait for the next Frigate release.</p>
</div>
<button type="button" class="btn btn-primary" id="btn-modal-ok">Got it</button>
</div>
</div>
<script>
// ---- URL params ----
var params = new URLSearchParams(location.search);
var ip = params.get('ip') || '';
var ports = params.get('ports') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
var server = params.get('server') || '';
var hostname = params.get('hostname') || '';
var latency = params.get('latency') || '';
var xiaomiDeviceId = params.get('xiaomi_device_id') || '';
var xiaomiStamp = params.get('xiaomi_stamp') || '';
// ---- State control ----
var STATES = ['loading', 'login', 'captcha', 'verify', 'region', 'notfound'];
function showState(name) {
STATES.forEach(function(s) {
var el = document.getElementById('state-' + s);
if (el) el.classList.toggle('visible', s === name);
});
hideError();
}
function showLoading(text) {
document.getElementById('loading-text').textContent = text || 'Loading...';
showState('loading');
}
function showError(msg) {
var el = document.getElementById('error-box');
el.textContent = msg;
el.classList.add('visible');
}
function hideError() {
document.getElementById('error-box').classList.remove('visible');
}
function showToast(msg, duration) {
var t = document.getElementById('toast');
t.textContent = msg;
t.classList.remove('hidden');
t.classList.add('show');
setTimeout(function() {
t.classList.remove('show');
setTimeout(function() { t.classList.add('hidden'); }, 250);
}, duration || 3000);
}
// ---- Version-warning modal ----
// Show on every page load until the user dismisses it; no persistence.
// When Frigate bundles go2rtc v1.9.13+ this block (plus the markup and
// styles) can be removed entirely.
(function showVersionModal() {
var modal = document.getElementById('version-modal');
var okBtn = document.getElementById('btn-modal-ok');
if (!modal || !okBtn) return;
function close() {
modal.classList.add('hidden');
document.removeEventListener('keydown', onKey);
}
function onKey(ev) {
if (ev.key === 'Escape' || ev.key === 'Enter') {
ev.preventDefault();
close();
}
}
modal.classList.remove('hidden');
document.addEventListener('keydown', onKey);
okBtn.addEventListener('click', close);
// Click on backdrop (outside the panel) also dismisses.
modal.addEventListener('click', function(ev) {
if (ev.target === modal) close();
});
setTimeout(function() { okBtn.focus(); }, 50);
})();
// ---- Init ----
init();
async function init() {
showLoading('Checking Mi Home accounts...');
try {
var r = await fetch('api/xiaomi', { cache: 'no-cache' });
if (!r.ok) {
showState('login');
return;
}
var users = await r.json();
if (Array.isArray(users) && users.length > 0) {
populateAccounts(users);
showState('region');
} else {
showState('login');
}
} catch (e) {
showState('login');
}
}
function populateAccounts(users) {
var sel = document.getElementById('select-account');
var group = document.getElementById('group-account');
sel.innerHTML = '';
users.forEach(function(u) {
var opt = document.createElement('option');
opt.value = u;
opt.textContent = u;
sel.appendChild(opt);
});
group.style.display = users.length > 1 ? 'block' : 'none';
}
// ---- Login form ----
document.getElementById('form-login').addEventListener('submit', function(ev) {
ev.preventDefault();
var u = document.getElementById('input-username').value.trim();
var p = document.getElementById('input-password').value;
if (!u || !p) return;
doAuth({ username: u, password: p }, 'Logging in...');
});
document.getElementById('form-captcha').addEventListener('submit', function(ev) {
ev.preventDefault();
var c = document.getElementById('input-captcha').value.trim();
if (!c) return;
doAuth({ captcha: c }, 'Verifying captcha...');
});
document.getElementById('form-verify').addEventListener('submit', function(ev) {
ev.preventDefault();
var v = document.getElementById('input-verify').value.trim();
if (!v) return;
doAuth({ verify: v }, 'Verifying code...');
});
async function doAuth(formData, loadingText) {
showLoading(loadingText);
var body = new URLSearchParams();
for (var k in formData) body.set(k, formData[k]);
try {
var r = await fetch('api/xiaomi', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
if (r.status === 401) {
var data = {};
try { data = await r.json(); } catch (e) {}
if (data.captcha) {
document.getElementById('captcha-img').src = 'data:image/jpeg;base64,' + data.captcha;
document.getElementById('input-captcha').value = '';
showState('captcha');
setTimeout(function() { document.getElementById('input-captcha').focus(); }, 50);
return;
}
if (data.verify_phone || data.verify_email) {
document.getElementById('verify-target').textContent = data.verify_email || data.verify_phone;
document.getElementById('input-verify').value = '';
showState('verify');
setTimeout(function() { document.getElementById('input-verify').focus(); }, 50);
return;
}
showState('login');
showError('Authentication failed. Check your credentials.');
return;
}
if (!r.ok) {
var text = await r.text();
showState('login');
showError(text || 'Login failed (HTTP ' + r.status + ')');
return;
}
// login success — reload account list and go to region picker
var users = [];
try {
var ur = await fetch('api/xiaomi', { cache: 'no-cache' });
if (ur.ok) users = await ur.json();
} catch (e) {}
if (Array.isArray(users) && users.length > 0) {
populateAccounts(users);
showState('region');
} else {
showState('login');
showError('Login succeeded but no account was saved.');
}
} catch (e) {
showState('login');
showError('Connection error: ' + e.message);
}
}
// ---- Back buttons inside captcha/verify ----
document.getElementById('btn-back-login-1').addEventListener('click', function() {
showState('login');
});
document.getElementById('btn-back-login-2').addEventListener('click', function() {
showState('login');
});
document.getElementById('btn-relogin').addEventListener('click', function() {
showState('login');
});
// ---- Find my camera ----
document.getElementById('form-region').addEventListener('submit', async function(ev) {
ev.preventDefault();
var sel = document.getElementById('select-account');
var region = document.getElementById('select-region').value;
var userID = sel.value || (sel.options[0] ? sel.options[0].value : '');
if (!userID) {
showError('No Mi Home account available. Log in first.');
showState('login');
return;
}
showLoading('Fetching your cameras...');
try {
var q = new URLSearchParams({ id: userID, region: region });
var r = await fetch('api/xiaomi?' + q.toString(), { cache: 'no-cache' });
if (!r.ok) {
var text = await r.text();
showState('region');
showError(text || 'Failed to load devices (HTTP ' + r.status + ')');
return;
}
var items = await r.json();
if (!Array.isArray(items) || items.length === 0) {
document.getElementById('nf-ip').textContent = ip || '(unknown)';
showState('notfound');
return;
}
var match = findCameraByIP(items, ip);
if (!match) {
document.getElementById('nf-ip').textContent = ip || '(unknown)';
showState('notfound');
return;
}
goToCreate(match);
} catch (e) {
showState('region');
showError('Connection error: ' + e.message);
}
});
function findCameraByIP(items, targetIP) {
if (!targetIP) return items[0] || null;
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (!it || !it.url) continue;
// ex. xiaomi://user:region@10.0.20.229?did=...
var m = it.url.match(/@([^:/?]+)/);
if (m && m[1] === targetIP) return it;
}
return null;
}
function goToCreate(item) {
var model = '';
try {
var u = new URL(item.url);
model = u.searchParams.get('model') || '';
} catch (e) {}
var p = new URLSearchParams();
p.set('url', item.url);
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (model) p.set('model', model);
p.set('vendor', vendor || 'Xiaomi');
if (hostname) p.set('hostname', hostname);
if (server) p.set('server', server);
if (latency) p.set('latency', latency);
if (ports) p.set('ports', ports);
window.location.href = 'create.html?' + p.toString();
}
// ---- Not found actions ----
document.getElementById('btn-try-region').addEventListener('click', function() {
showState('region');
});
// ---- Skip to standard ----
function goToStandard() {
var p = new URLSearchParams();
if (ip) p.set('ip', ip);
if (ports) p.set('ports', ports);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (latency) p.set('latency', latency);
window.location.href = 'standard.html?' + p.toString();
}
document.getElementById('btn-skip-login').addEventListener('click', goToStandard);
document.getElementById('btn-skip-region').addEventListener('click', goToStandard);
document.getElementById('btn-skip-notfound').addEventListener('click', goToStandard);
// ---- Back to index ----
document.getElementById('btn-back').addEventListener('click', function() {
window.location.href = 'index.html';
});
</script>
</body>
</html>