82 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
eduard256 ffe77cb9c4 Merge develop into main for v2.1.0 release 2026-04-08 11:31:03 +00:00
eduard256 29e03ce85a Release v2.1.0 2026-04-08 11:30:58 +00:00
eduard256 e47c0f7ce6 Add top-1000 checkbox to ONVIF page, classify JPEG streams as alternative
- Add checked-by-default checkbox to also test popular stream patterns
- Move JPEG-only streams (no H264/H265) to Alternative group in test results
2026-04-08 11:27:08 +00:00
eduard256 0fb7356a5e Add ONVIF stream handler for tester
- Add testOnvif(): resolves all profiles via ONVIF client, tests
  each RTSP stream, returns two Results per profile (onvif + rtsp)
  with shared screenshot
- Route onvif:// URLs in worker.go alongside homekit://
- Classify onvif:// streams as recommended in test.html
- Harden create.html against undefined/null URL values
2026-04-08 11:00:32 +00:00
eduard256 ce4b777e98 Add ONVIF camera page and probe routing
- Add onvif.html: credentials form, Discover Streams button,
  fallback to Standard Discovery and HomeKit Pairing
- Update index.html routing: onvif type -> onvif.html with all
  probe params (onvif_url, onvif_port, onvif_name, onvif_hardware,
  mdns_* for HomeKit fallback)
2026-04-08 10:50:05 +00:00
eduard256 5be8d4aa00 Add ONVIF probe detector via unicast WS-Discovery
- Add ProbeONVIF() prober: sends unicast WS-Discovery to ip:3702,
  parses XAddrs, Name, Hardware from response (no auth needed)
- Add ONVIFResult struct to probe models
- Register ONVIF detector with highest priority (before HomeKit)
- Fix homekit.html back-wrapper max-width to match design system
2026-04-08 10:31:46 +00:00
eduard256 1291e6a5b6 Add frontend_design_strix skill for UI page creation
Design guide with principles, layout patterns, component usage,
navigation rules, and checklist. References homekit.html as the
design gold standard and design-system.html for components.
2026-04-08 09:49:25 +00:00
eduard256 699ddda39b Update design system with centered layout, PIN input, floating back button
Add true-center layout pattern, back-wrapper for floating navigation,
PIN digit input component with all states, and centered page demo
with HomeKit logo example. Document PIN input JS pattern.
2026-04-08 09:39:36 +00:00
eduard256 89c5d83a6f Refine HomeKit page: add Apple HomeKit logo, centered layout, back button
Replace text-only header with official HomeKit house icon and
"Apple HomeKit" label. Pin input centered on screen, back button
aligned to container edge. Remove device info table and decorative
elements for a cleaner look matching the rest of the frontend.
2026-04-08 09:30:31 +00:00
eduard256 8398832960 Redesign HomeKit page, add design system reference
Rebuild homekit.html with centered layout, cleaner PIN input,
and consistent styling matching the rest of the frontend.
Add www/design-system.html as a living component reference
for all UI elements used across the Strix frontend.
2026-04-08 08:54:40 +00:00
eduard256 a16799fa8d Add Docker Compose files for Strix, Frigate, and go2rtc setups
Add three docker-compose variants: standalone Strix,
Strix + Frigate, and Strix + go2rtc. Update README with
Docker Compose install instructions linking to the files.
2026-04-05 14:47:54 +00:00
eduard256 0652e53bc7 Update README: add HomeKit protocol and StrixAHKCamFake reference 2026-04-05 14:41:19 +00:00
eduard256 528ec8e00b Add HomeKit stream testing via HAP snapshot
- Add worker_homekit.go with direct hap.Dial + GetImage flow
- Bypass SRTP/Producer pipeline for homekit:// URLs
- Route homekit:// streams to dedicated handler in worker.go
2026-04-05 12:58:26 +00:00
eduard256 a9820abc37 Add HomeKit camera pairing support
- Add POST /api/homekit/pair endpoint that calls hap.Pair() from go2rtc
- Rewrite homekit.html with PIN input UI (XXX-XX-XXX format)
- Auto-advance between digit fields, paste support, error/success states
- On successful pairing, redirect to create.html with homekit:// URL
- Pass mdns_port and mdns_paired from probe to homekit.html
- Detect HomeKit cameras regardless of pairing status
2026-04-05 12:43:06 +00:00
eduard256 e2e24c7578 Switch mDNS probe to multicast, use mDNS for reachability
Unicast mDNS queries (direct to IP:5353) are ignored by some HomeKit
devices. Switch to multicast (224.0.0.251:5353) and filter responses
by sender IP. Also consider mDNS response as reachability signal.

Split probe timeouts: 100ms for ports/DNS/HTTP, 120ms total to give
mDNS extra time. HomeKit responds in ~0.2ms via multicast.
2026-04-05 12:06:11 +00:00
eduard256 f084135701 Remove ICMP ping from probe, add HomeKit port 51826
ICMP requires root or CAP_NET_RAW which is not available in
unprivileged containers. Probe now relies solely on port scanning
for reachability detection, which works without any special
permissions. Add port 51826 (HomeKit) to both default and
database-loaded port lists.
2026-04-05 11:54:24 +00:00
eduard256 4e9ffd1440 Show LAN IP instead of localhost in install summary
Detect local network IP address using ip route / hostname -I / ifconfig
fallback chain and display it in the post-install summary box so users
can immediately open Strix from other devices on the network.
2026-04-05 10:45:46 +00:00
eduard256 83659c9a82 Replace double dashes with single dashes 2026-04-05 10:36:48 +00:00
eduard256 e0ccef8683 Replace double dashes with single dashes 2026-04-05 10:36:47 +00:00
eduard256 166feceab9 Add StrixCamFake link to DEVELOPERS.md 2026-04-05 10:36:11 +00:00
eduard256 66f9131cff Add StrixCamFake link to DEVELOPERS.md 2026-04-05 10:36:10 +00:00
eduard256 8cf3195b51 Add StrixCamFake link to README 2026-04-05 10:35:51 +00:00
eduard256 600141d11b Add StrixCamFake link to README 2026-04-05 10:35:50 +00:00
eduard256 608d4989ff Link feature bullets to corresponding screenshots 2026-04-05 10:28:55 +00:00
eduard256 5fb1efe599 Link feature bullets to corresponding screenshots 2026-04-05 10:28:54 +00:00
eduard256 2ab8106b01 Add supported cameras link to quick links 2026-04-05 10:25:42 +00:00
eduard256 acc456f3f5 Add supported cameras link to quick links 2026-04-05 10:25:41 +00:00
eduard256 22baefd57f Add camera database browse and contribute links 2026-04-05 10:23:33 +00:00
eduard256 f06d60f6ff Add camera database browse and contribute links 2026-04-05 10:23:32 +00:00
eduard256 6044df6ee4 Add horizontal rule before demo GIF 2026-04-05 10:20:39 +00:00
eduard256 56c02f6b72 Add horizontal rule before demo GIF 2026-04-05 10:20:38 +00:00
eduard256 43632fb8c2 Add live demo, video, and API docs links after banner 2026-04-05 10:19:33 +00:00
eduard256 55a4a62752 Add live demo, video, and API docs links after banner 2026-04-05 10:19:32 +00:00
eduard256 4c1fab86b1 Add separator between logo and title 2026-04-05 10:18:26 +00:00
eduard256 1c50564548 Add separator between logo and title 2026-04-05 10:18:25 +00:00
eduard256 1efe3cc9ba Add STRIX text next to logo in header 2026-04-05 10:17:40 +00:00
eduard256 21c96d6548 Add STRIX text next to logo in header 2026-04-05 10:17:39 +00:00
eduard256 4ea3485c9b Move API reference to DEVELOPERS.md 2026-04-05 10:15:47 +00:00
eduard256 dd8966a8d7 Move API reference to DEVELOPERS.md 2026-04-05 10:15:46 +00:00
eduard256 74eed5ede9 Redesign README header with centered layout and feature list 2026-04-05 10:09:26 +00:00
eduard256 96354f018f Redesign README header with centered layout and feature list 2026-04-05 10:09:25 +00:00
eduard256 75947be26b Remove title text from README header 2026-04-05 10:03:45 +00:00
eduard256 e4a28fe61a Remove title text from README header 2026-04-05 10:03:44 +00:00
eduard256 4cb00ec85f Left-align logo and badges in README 2026-04-05 10:03:15 +00:00
eduard256 2fc9be2d9f Left-align logo and badges in README 2026-04-05 10:03:14 +00:00
eduard256 0cf9f7d44e Remove logo caption from README 2026-04-05 10:02:39 +00:00
eduard256 39da8d2d50 Remove logo caption from README 2026-04-05 10:02:38 +00:00
eduard256 8c1a6b1b0e Add MIT license and license badge 2026-04-05 10:02:03 +00:00
eduard256 258f3712c2 Add MIT license and license badge 2026-04-05 10:02:02 +00:00
eduard256 2db7ae6f25 Move badges above icon in README 2026-04-05 10:00:31 +00:00
eduard256 bb740a04bc Move badges above icon in README 2026-04-05 10:00:30 +00:00
eduard256 09bd2ce220 Use PNG icon in README 2026-04-05 09:00:15 +00:00
eduard256 8a4201936a Use PNG icon in README 2026-04-05 09:00:10 +00:00
eduard256 0a30496991 Add README for v2.0.0 2026-04-05 08:58:55 +00:00
eduard256 eb6719237d Add README for v2.0.0 2026-04-05 08:58:23 +00:00
48 changed files with 10886 additions and 1144 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)
@@ -0,0 +1,211 @@
---
name: frontend_design_strix
description: Create or redesign frontend pages for Strix. Use when building new HTML pages, redesigning existing ones, or working on any UI task in the www/ directory. Covers design principles, layout patterns, and component usage.
disable-model-invocation: true
---
# Strix Frontend Design
You are creating or modifying a frontend page for Strix. Your goal is to produce a page that looks **identical in quality** to the existing pages, especially `www/homekit.html` which is the design reference.
## Before you start
Read these files completely:
1. **`www/design-system.html`** -- All CSS variables, every component, JS patterns. This is your component library.
2. **`www/homekit.html`** -- The design reference. This page is the gold standard. Study its structure, spacing, how little text it uses, how the back button is positioned.
3. **`www/index.html`** -- The entry point. Understand the probe flow and how data is passed between pages via URL params.
If you need to understand backend APIs or the probe system, read:
- `www/standard.html` -- how probe data flows into a configuration page
- `www/test.html` -- how polling and real-time updates work
- `www/config.html` -- complex two-column layout with live preview
## Design Philosophy
### Radical minimalism
Every element on screen must earn its place. If something doesn't help the user complete their task, remove it.
- **10% text, 90% meaning.** A label that says "Pairing Code" with an info-icon is better than a paragraph explaining what a pairing code is.
- **Hide details behind info-icons.** Long explanations go into tooltips (the `(i)` icon pattern). The user who needs the explanation can hover. The user who doesn't is not bothered.
- **No decorative elements without function.** No ornamental icons, no badges that don't convey information, no cards-as-decoration.
- **One action per screen.** Each page should have one primary thing the user does. Everything else is secondary.
### How we think about design decisions
When building homekit.html, we went through this process:
1. **Started with all the data** -- device info table, long descriptions, badges, decorative icons
2. **Asked "does the user need this?"** for every element
3. **Removed everything that wasn't essential** -- the device info table (IP, MAC, vendor) was removed because the user doesn't need it to enter a PIN code
4. **Moved explanations into tooltips** -- "This camera supports Apple HomeKit. Enter the 8-digit pairing code printed on your camera or included in the manual" became just a label "Pairing Code" with a tooltip
5. **Removed format hints** -- "Format: XXX-XX-XXX" was removed because the input fields themselves make the format obvious
6. **Made the primary action obvious** -- big button, full width, impossible to miss
Apply this same thinking to every page you create.
### Visual rules
- Dark theme with purple accent -- never deviate from the color palette in `:root`
- All icons are inline SVG -- never use emoji, never use icon fonts, never use external icon libraries
- Fonts: system font stack for UI, monospace for technical values (URLs, IPs, codes)
- Borders are subtle: `rgba(139, 92, 246, 0.15)` -- barely visible purple tint
- Glow effects on focus and hover, never on static elements (except logos)
- Animations are fast (150ms) and subtle -- translateY(-2px) on hover, fadeIn on page load
- No rounded corners larger than 8px (except special cases like toggle switches)
## Layout Patterns
### Pages after probe (like homekit.html) -- TRUE CENTER
This is the most common case for new pages. Content is vertically centered on screen.
```
.screen {
min-height: 100vh;
display: flex;
align-items: center; /* TRUE CENTER -- not flex-start */
justify-content: center;
}
.container { max-width: 480px; width: 100%; }
```
**Back button** is positioned OUTSIDE the container, wider than content, using `.back-wrapper`:
```
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px; /* wider than container */
padding: 0 1.5rem;
z-index: 10;
}
```
This is MANDATORY for all centered layout pages. The back button must NOT be inside the centered container.
### Entry page (like index.html) -- TOP CENTER
Content is near the top with `margin-top: 8vh`. Used for the main entry point only.
### Content pages (like standard.html, create.html) -- STANDARD
Back button at top, then title, then content flowing down. `max-width: 600px`, no vertical centering.
### Data-heavy pages (like test.html) -- WIDE
`max-width: 1200px` with card grids.
### Two-column (like config.html) -- SPLIT
Settings left, live preview right. Collapses to tabs on mobile.
## Hero Section Pattern
For centered pages, the hero contains a logo/icon + short title:
```html
<div class="hero">
<svg class="logo-icon">...</svg> <!-- 48-72px, with glow filter -->
<h1 class="title">Short Name</h1> <!-- 1.25rem, white, font-weight 600 -->
</div>
```
- The icon should be recognizable and relevant (Strix owl for main, HomeKit house for HomeKit)
- The title is SHORT -- one or two words max
- No subtitles unless absolutely necessary
- Glow effect on the icon via `filter: drop-shadow()`
## Component Usage
All components are documented with live examples in `www/design-system.html`. Key ones:
- **Buttons**: `.btn .btn-primary .btn-large` for primary action (full width), `.btn-outline` for secondary
- **Inputs**: `.input` with `.label` and optional `.info-icon` with `.tooltip`
- **Toast**: Every page needs `<div id="toast" class="toast hidden"></div>` and the `showToast()` function
- **Error box**: `.error-box` with `.visible` class toggled
- **Info icon + tooltip**: For hiding explanations -- always prefer this over visible text
## Navigation -- CRITICAL
### Always pass ALL known data forward
When navigating to another page, pass every piece of data you have. This is non-negotiable. Future pages may need any of these values.
```javascript
function navigateNext() {
var p = new URLSearchParams();
p.set('primary_data', value);
// Pass through EVERYTHING known:
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (model) p.set('model', model);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (ports) p.set('ports', ports);
if (user) p.set('user', user);
if (channel) p.set('channel', channel);
// ... any other params from probe
window.location.href = 'next.html?' + p.toString();
}
```
### Page init always reads all params
```javascript
var params = new URLSearchParams(location.search);
var ip = params.get('ip') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
// ... read ALL possible params even if this page doesn't use them
// They need to be available for passing to the next page
```
## JavaScript Rules
- Use `var`, not `let`/`const` -- ES5 compatible
- Build DOM with `document.createElement`, not innerHTML
- Use `async function` + `fetch()` for API calls
- Always handle errors: check `!r.ok`, catch exceptions, show toast
- Debounce input handlers if they trigger API calls (300ms)
- Use `addEventListener`, never inline event handlers in HTML
## API Pattern
```javascript
async function doSomething() {
try {
var r = await fetch('api/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!r.ok) {
var text = await r.text();
showToast(text || 'Error ' + r.status);
return;
}
var data = await r.json();
// success...
} catch (e) {
showToast('Connection error: ' + e.message);
}
}
```
## Checklist before finishing
- [ ] Page uses correct layout pattern for its type
- [ ] Back button positioned correctly (`.back-wrapper` for centered, inline for standard)
- [ ] All CSS variables from `:root` -- no hardcoded colors
- [ ] No unnecessary text -- everything possible hidden behind info-icons
- [ ] All known URL params are read at init and passed forward on navigation
- [ ] Toast element present, showToast function included
- [ ] Error states handled (API errors, validation)
- [ ] Mobile responsive (test at 375px width)
- [ ] No emoji anywhere
- [ ] All icons are inline SVG
- [ ] Primary action is obvious and full-width
- [ ] Page looks like it belongs with homekit.html and index.html
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [2.1.0] - 2026-04-08
### Added
- ONVIF protocol support: auto-discovery via unicast WS-Discovery, stream resolution through ONVIF profiles
- ONVIF probe detector: detects ONVIF cameras during network probe (4-7ms response time, no auth required)
- ONVIF camera page (onvif.html): credentials form with option to also test popular stream patterns
- ONVIF stream handler: resolves all camera profiles, tests each via RTSP, returns paired results (onvif:// + rtsp://) with shared screenshots
- Design system reference (design-system.html) with all UI components documented
### Changed
- ONVIF has highest probe priority (above HomeKit and Standard)
- JPEG-only streams (no H264/H265) are classified as Alternative in test results
- HomeKit page redesigned: Apple HomeKit logo, centered layout, floating back button
- Hardened create.html against undefined/null URL values in query parameters
## [2.0.0] - 2025-04-05
### Added
+370
View File
@@ -0,0 +1,370 @@
# Strix for Developers
Strix is a single static binary with embedded web UI and SQLite camera database. No config files, no external dependencies (except optional `ffmpeg` for H264/H265 screenshot conversion). Designed to run alongside your project the same way [go2rtc](https://github.com/AlexxIT/go2rtc) does.
For development and testing without real cameras, use [StrixCamFake](https://github.com/eduard256/StrixCamFake) - IP camera emulator with RTSP, HTTP, RTMP, Bubble and more.
## Binary
Download from [GitHub Releases](https://github.com/eduard256/Strix/releases). Two platforms: `linux/amd64` and `linux/arm64`.
```bash
chmod +x strix-linux-amd64
./strix-linux-amd64
```
The binary needs `cameras.db` in the working directory. Download it from [StrixCamDB](https://github.com/eduard256/StrixCamDB/releases):
```bash
curl -fsSL https://github.com/eduard256/StrixCamDB/releases/latest/download/cameras.db -o cameras.db
./strix-linux-amd64
```
## Docker
```bash
docker run -d --name strix --network host eduard256/strix:latest
```
Database is already embedded in the image.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `STRIX_LISTEN` | `:4567` | HTTP listen address |
| `STRIX_DB_PATH` | `cameras.db` | Path to SQLite database |
| `STRIX_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
| `STRIX_FRIGATE_URL` | auto-discovery | Frigate URL, e.g. `http://localhost:5000` |
| `STRIX_GO2RTC_URL` | auto-discovery | go2rtc URL, e.g. `http://localhost:1984` |
## Integration Flow
Typical automation flow using the API:
```
1. Probe device GET /api/probe?ip=192.168.1.100
2. Search database GET /api/search?q=hikvision
3. Build stream URLs GET /api/streams?ids=b:hikvision&ip=192.168.1.100&user=admin&pass=12345
4. Test streams POST /api/test {sources: {streams: [...]}}
5. Poll results GET /api/test?id=xxx
6. Generate config POST /api/generate {mainStream: "rtsp://...", subStream: "rtsp://..."}
```
All endpoints return JSON. CORS is enabled. No authentication.
---
## API Reference
### System
#### `GET /api`
```json
{"version": "2.0.0", "platform": "amd64"}
```
#### `GET /api/health`
```json
{"version": "2.0.0", "uptime": "1h30m0s"}
```
#### `GET /api/log`
Returns in-memory log in `application/jsonlines` format. Passwords are masked automatically.
#### `DELETE /api/log`
Clears in-memory log. Returns `204`.
---
### Search
#### `GET /api/search?q={query}`
Search camera database by brand, model, or preset name. Empty `q` returns all presets + first brands (limit 50).
```bash
curl "localhost:4567/api/search?q=hikvision"
```
```json
{
"results": [
{"type": "brand", "id": "b:hikvision", "name": "Hikvision"},
{"type": "model", "id": "m:hikvision:DS-2CD2032", "name": "Hikvision: DS-2CD2032"}
]
}
```
Result types:
| Type | ID format | Description |
|------|-----------|-------------|
| `preset` | `p:{preset_id}` | Curated URL pattern sets (e.g. "ONVIF", "Popular RTSP") |
| `brand` | `b:{brand_id}` | All URL patterns for a brand |
| `model` | `m:{brand_id}:{model}` | URL patterns for a specific model |
Multi-word queries match independently: `hikvision DS-2CD` matches brand "Hikvision" AND model containing "DS-2CD".
#### `GET /api/streams`
Build full stream URLs from database patterns with credentials and placeholders substituted.
| Param | Required | Description |
|-------|----------|-------------|
| `ids` | yes | Comma-separated IDs from search results |
| `ip` | yes | Camera IP address |
| `user` | no | Username (URL-encoded automatically) |
| `pass` | no | Password (URL-encoded automatically) |
| `channel` | no | Channel number, default `0` |
| `ports` | no | Comma-separated port filter (only return URLs matching these ports) |
```bash
curl "localhost:4567/api/streams?ids=b:hikvision&ip=192.168.1.100&user=admin&pass=12345"
```
```json
{
"streams": [
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/102",
"http://admin:12345@192.168.1.100/ISAPI/Streaming/channels/101/picture"
]
}
```
Maximum 20,000 URLs per request. URLs are deduplicated.
---
### Testing
#### `POST /api/test`
Create a test session. 20 parallel workers connect to each URL, extract codecs, capture screenshots.
```bash
curl -X POST localhost:4567/api/test -d '{
"sources": {
"streams": [
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/102"
]
}
}'
```
```json
{"session_id": "a1b2c3d4e5f6g7h8"}
```
#### `GET /api/test`
List all active and completed sessions.
```json
{
"sessions": [
{
"session_id": "a1b2c3d4",
"status": "running",
"total": 604,
"tested": 341,
"alive": 191,
"with_screenshot": 191
}
]
}
```
#### `GET /api/test?id={session_id}`
Get session details with full results. Poll this endpoint to track progress.
```json
{
"session_id": "a1b2c3d4",
"status": "done",
"total": 604,
"tested": 604,
"alive": 375,
"with_screenshot": 375,
"results": [
{
"source": "rtsp://admin:***@192.168.1.100/Streaming/Channels/101",
"codecs": ["H264", "PCMA"],
"width": 1920,
"height": 1080,
"latency_ms": 45,
"screenshot": "api/test/screenshot?id=a1b2c3d4&i=0"
}
]
}
```
- `status`: `running` or `done`
- `codecs`: detected media codecs (H264, H265, PCMA, PCMU, OPUS, etc.)
- `width`, `height`: resolution extracted from JPEG screenshot
- `screenshot`: relative URL to fetch the JPEG image
- Sessions expire 30 minutes after completion
#### `DELETE /api/test?id={session_id}`
Cancel a running session and delete it.
```json
{"status": "deleted"}
```
#### `GET /api/test/screenshot?id={session_id}&i={index}`
Returns raw JPEG image. `Content-Type: image/jpeg`.
---
### Config Generation
#### `POST /api/generate`
Generate Frigate config from stream URLs.
```bash
curl -X POST localhost:4567/api/generate -d '{
"mainStream": "rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"subStream": "rtsp://admin:12345@192.168.1.100/Streaming/Channels/102",
"name": "front_door",
"objects": ["person", "car"]
}'
```
```json
{
"config": "mqtt:\n enabled: false\n\nrecord:\n enabled: true\n\ngo2rtc:\n streams:\n ...",
"added": [1, 2, 3, 4, 5]
}
```
- `config`: complete Frigate YAML
- `added`: 1-based line numbers of new lines (for highlighting in UI)
**Merge into existing config** - pass `existingConfig` field:
```json
{
"mainStream": "rtsp://...",
"existingConfig": "go2rtc:\n streams:\n existing_cam:\n - rtsp://...\n\ncameras:\n existing_cam:\n ..."
}
```
Strix finds the right insertion points in go2rtc streams and cameras sections. Camera and stream names are deduplicated automatically.
<details>
<summary>Full request schema</summary>
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mainStream` | string | **yes** | Main stream URL |
| `subStream` | string | no | Sub stream URL for detect role |
| `name` | string | no | Camera name (auto-generated from IP if empty) |
| `existingConfig` | string | no | Existing Frigate YAML to merge into |
| `objects` | string[] | no | Objects to track (default: `["person"]`) |
| `go2rtc` | object | no | `{mainStreamName, subStreamName, mainStreamSource, subStreamSource}` |
| `frigate` | object | no | `{mainStreamPath, subStreamPath, mainStreamInputArgs, subStreamInputArgs}` |
| `detect` | object | no | `{enabled, fps, width, height}` |
| `record` | object | no | `{enabled, retain_days, mode, alerts_days, detections_days, pre_capture, post_capture}` |
| `motion` | object | no | `{enabled, threshold, contour_area}` |
| `snapshots` | object | no | `{enabled}` |
| `audio` | object | no | `{enabled, filters[]}` |
| `ffmpeg` | object | no | `{hwaccel, gpu}` |
| `live` | object | no | `{height, quality}` |
| `birdseye` | object | no | `{enabled, mode}` |
| `onvif` | object | no | `{host, port, user, password, autotracking, required_zones[]}` |
| `ptz` | object | no | `{enabled, presets{}}` |
| `notifications` | object | no | `{enabled}` |
| `ui` | object | no | `{order, dashboard}` |
</details>
---
### Probe
#### `GET /api/probe?ip={ip}`
Probe a network device. Runs 6 checks in parallel within 100ms: port scan, ICMP ping, ARP + OUI vendor lookup, reverse DNS, mDNS/HomeKit query, HTTP probe.
```bash
curl "localhost:4567/api/probe?ip=192.168.1.100"
```
```json
{
"ip": "192.168.1.100",
"reachable": true,
"latency_ms": 2.5,
"type": "standard",
"probes": {
"ping": {"latency_ms": 2.5},
"ports": {"open": [80, 554, 8080]},
"dns": {"hostname": "ipcam.local"},
"arp": {"mac": "C0:56:E3:AA:BB:CC", "vendor": "Hikvision"},
"mdns": null,
"http": {"port": 80, "status_code": 401, "server": "Hikvision-Webs"}
}
}
```
- `type`: `standard`, `homekit`, or `unreachable`
- `ports.open`: scanned from 189 ports known in the camera database
- `arp.vendor`: looked up from OUI table in SQLite database
- HomeKit cameras return `mdns` with `name`, `model`, `category` (`camera` or `doorbell`), `device_id`, `paired`, `port`
- ICMP ping requires `CAP_NET_RAW` capability. Falls back to port scan only.
---
### Frigate
#### `GET /api/frigate/config`
Get current Frigate config. Frigate is discovered automatically by probing known addresses (`localhost:5000`, `ccab4aaf-frigate:5000`) or via `STRIX_FRIGATE_URL`.
```json
{"connected": true, "url": "http://localhost:5000", "config": "mqtt:\n enabled: false\n ..."}
```
```json
{"connected": false, "config": ""}
```
#### `POST /api/frigate/config/save?save_option={option}`
Save config to Frigate. Request body is plain text (YAML config).
| Option | Description |
|--------|-------------|
| `saveonly` | Save config without restart (default) |
| `restart` | Save config and restart Frigate |
---
### go2rtc
#### `PUT /api/go2rtc/streams?name={name}&src={source}`
Add a stream to go2rtc. Proxied to local go2rtc instance (discovered automatically or via `STRIX_GO2RTC_URL`).
```bash
curl -X PUT "localhost:4567/api/go2rtc/streams?name=front_door&src=rtsp://admin:12345@192.168.1.100/Streaming/Channels/101"
```
```json
{"success": true}
```
```json
{"success": false, "error": "go2rtc not found"}
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 eduard256
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+199
View File
@@ -0,0 +1,199 @@
<h1 align="center">
<a href="https://github.com/eduard256/Strix">
<img src="https://github.com/eduard256/Strix/releases/download/v2.0.0/icon-192.png" width="64" alt="Strix" valign="middle">
</a>
&nbsp;|&nbsp;
STRIX
</h1>
<p align="center">
<a href="https://github.com/eduard256/strix/stargazers"><img src="https://img.shields.io/github/stars/eduard256/strix?style=flat-square&logo=github" alt="GitHub Stars"></a>
<a href="https://hub.docker.com/r/eduard256/strix"><img src="https://img.shields.io/docker/pulls/eduard256/strix?style=flat-square&logo=docker&logoColor=white&label=pulls" alt="Docker Pulls"></a>
<a href="https://github.com/eduard256/Strix/releases"><img src="https://img.shields.io/github/downloads/eduard256/Strix/total?color=blue&style=flat-square&logo=github" alt="GitHub Downloads"></a>
<a href="https://github.com/eduard256/Strix/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License"></a>
</p>
Camera stream discovery and Frigate config generator.
- 3,600+ camera brands with 100,000+ [URL patterns](#streams) in SQLite database
- automatic device [probing](#probe) in 100ms: ports, ARP/OUI, mDNS/HomeKit, HTTP
- 20 parallel workers [test every URL](#testing) with live screenshots
- supports [RTSP, HTTP, RTMP, Bubble, DVRIP](#supported-protocols) and more
- ready [Frigate config](#config-generation) with smart merge into existing setup
- auto-discovery of Frigate and [go2rtc](https://github.com/AlexxIT/go2rtc) on local network
- zero-dependency static [binary](#binary) for Linux amd64/arm64
- can be used as [standalone app](#binary), [Docker](#docker), or [Home Assistant add-on](#home-assistant-add-on)
---
<a href="https://youtu.be/JgVWsl4NApE">
<img src="https://github.com/eduard256/Strix/releases/download/v2.0.0/demo.gif" width="100%">
</a>
<p align="center">
<a href="https://gostrix.github.io/demo.html"><b>Live Demo</b></a>
&nbsp;&bull;&nbsp;
<a href="https://gostrix.github.io/"><b>Supported Cameras</b></a>
&nbsp;&bull;&nbsp;
<a href="https://youtu.be/JgVWsl4NApE"><b>Video</b></a>
&nbsp;&bull;&nbsp;
<a href="DEVELOPERS.md"><b>API Docs</b></a>
</p>
## Install
Any Linux or Proxmox, one command:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/install.sh)
```
Run as root (or with `sudo`). Interactive installer detects your system (Linux / Proxmox) and guides you through setup.
Open `http://YOUR_IP:4567`
## How it works
<a id="probe"></a>
Enter camera IP. Strix probes the device - open ports, MAC vendor, mDNS, HTTP server.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/01-enter-ip.png)
<a id="search"></a>
Search camera model in database. Enter credentials if needed.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/02-camera-config.png)
<a id="streams"></a>
Strix builds all possible stream URLs from database patterns.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/03-stream-urls.png)
<a id="testing"></a>
20 parallel workers test every URL. Live screenshots, codecs, resolution, latency.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/04-testing.png)
Pick main and sub streams from results.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/05-results.png)
<a id="config-generation"></a>
Generate ready Frigate config. Copy, download, or save directly to Frigate.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/06-frigate-config.png)
Camera works in Frigate. Done.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/07-frigate-result.png)
## Other install methods
### Docker
```bash
docker run -d --name strix --network host --restart unless-stopped eduard256/strix:latest
```
### Docker Compose
Strix only:
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml
docker compose up -d
```
Strix + [Frigate](https://github.com/blakeblackshear/frigate):
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.frigate.yml
docker compose -f docker-compose.frigate.yml up -d
```
Strix + [go2rtc](https://github.com/AlexxIT/go2rtc):
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.go2rtc.yml
docker compose -f docker-compose.go2rtc.yml up -d
```
### Podman
Podman drops `NET_RAW` and `NET_ADMIN` by default, which Strix needs for network scanning. Add them explicitly:
```bash
podman run -d \
--name strix \
--network host \
--cap-add=NET_RAW \
--cap-add=NET_ADMIN \
--restart unless-stopped \
eduard256/strix:latest
```
Or run with `--privileged` if you prefer.
### Home Assistant Add-on
1. **Settings** > **Add-ons** > **Add-on Store**
2. Menu (top right) > **Repositories** > add `https://github.com/eduard256/hassio-strix`
3. Install **Strix**, enable **Start on boot** and **Show in sidebar**
### Umbrel
<a href="https://apps.umbrel.com/app/strix">
<img src="https://apps.umbrel.com/badge-light.svg" alt="Install on Umbrel" height="60">
</a>
### Binary
Download from [GitHub Releases](https://github.com/eduard256/Strix/releases). No dependencies except `ffmpeg` for screenshot conversion.
```bash
chmod +x strix-linux-amd64
STRIX_LISTEN=:4567 ./strix-linux-amd64
```
## Supported protocols
| Protocol | Port | Description |
|----------|------|-------------|
| RTSP | 554 | Most IP cameras |
| RTSPS | 322 | RTSP over TLS |
| HTTP/HTTPS | 80/443 | MJPEG, JPEG snapshots, HLS, MPEG-TS |
| RTMP | 1935 | Some Chinese NVRs |
| Bubble | 80 | XMeye/NetSurveillance cameras |
| DVRIP | 34567 | Sofia protocol DVR/NVR |
| HomeKit | 51826 | Apple HomeKit cameras via HAP |
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `STRIX_LISTEN` | `:4567` | HTTP listen address |
| `STRIX_DB_PATH` | `cameras.db` | Path to SQLite camera database |
| `STRIX_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, `trace` |
| `STRIX_FRIGATE_URL` | auto-discovery | Frigate URL, e.g. `http://localhost:5000` |
| `STRIX_GO2RTC_URL` | auto-discovery | go2rtc URL, e.g. `http://localhost:1984` |
## Camera database
SQLite database with 3,600+ brands and 100,000+ URL patterns. Maintained separately in [StrixCamDB](https://github.com/eduard256/StrixCamDB). Database is embedded in Docker image and bundled with binary releases.
[Browse supported cameras](https://gostrix.github.io/) - search by brand or model to check if your camera is in the database.
Three entity types:
- **Presets** - curated sets of popular URL patterns (e.g. "ONVIF", "Popular RTSP")
- **Brands** - all URL patterns for a brand (e.g. "Hikvision", "Dahua")
- **Models** - URL patterns for a specific model within a brand
Camera not in the database? [Add it here](https://gostrix.github.io/#/contribute).
**Developers:** integrate [Strix HTTP API](DEVELOPERS.md) into your smart home platform.
**Testing:** [StrixCamFake](https://github.com/eduard256/StrixCamFake) - IP camera emulator for development and testing. [StrixAHKCamFake](https://github.com/eduard256/StrixAHKCamFake) - Apple HomeKit camera emulator.
+55
View File
@@ -0,0 +1,55 @@
# Strix + Frigate
# 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:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
STRIX_FRIGATE_URL: "http://localhost:5000"
# STRIX_GO2RTC_URL: "http://localhost:1984"
# STRIX_LOG_LEVEL: debug
depends_on:
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:/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
- ./frigate/storage:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
FRIGATE_RTSP_PASSWORD: "password"
+27
View File
@@ -0,0 +1,27 @@
# Strix + go2rtc
# Usage: docker compose -f docker-compose.go2rtc.yml up -d
services:
strix:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
STRIX_GO2RTC_URL: "http://localhost:1984"
# STRIX_LOG_LEVEL: debug
depends_on:
- go2rtc
go2rtc:
container_name: go2rtc
image: alexxit/go2rtc
restart: unless-stopped
volumes:
- ./go2rtc:/config
ports:
- "1984:1984"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
+12
View File
@@ -0,0 +1,12 @@
# Strix standalone
# Usage: docker compose up -d
services:
strix:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
# STRIX_LOG_LEVEL: debug
+5
View File
@@ -15,13 +15,18 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 // indirect
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f // indirect
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
+10
View File
@@ -25,6 +25,8 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
@@ -33,6 +35,10 @@ github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=
github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -47,6 +53,10 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxI
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+407 -770
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
package homekit
import (
"encoding/json"
"fmt"
"net/http"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app"
"github.com/rs/zerolog"
)
var log zerolog.Logger
func Init() {
log = app.GetLogger("homekit")
api.HandleFunc("api/homekit/pair", apiPair)
}
func apiPair(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IP string `json:"ip"`
Port int `json:"port"`
DeviceID string `json:"device_id"`
PIN string `json:"pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.IP == "" || req.Port == 0 || req.DeviceID == "" || req.PIN == "" {
http.Error(w, "ip, port, device_id and pin required", http.StatusBadRequest)
return
}
// ex. "homekit://10.0.10.52:45959?device_id=90:8C:0F:F2:EC:F3&pin=12345678"
rawURL := fmt.Sprintf("homekit://%s:%d?device_id=%s&pin=%s", req.IP, req.Port, req.DeviceID, req.PIN)
log.Debug().Str("ip", req.IP).Int("port", req.Port).Str("device_id", req.DeviceID).Msg("[homekit] pair")
conn, err := hap.Pair(rawURL)
if err != nil {
log.Warn().Err(err).Str("ip", req.IP).Msg("[homekit] pair failed")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close()
url := conn.URL()
log.Info().Str("ip", req.IP).Str("device_id", req.DeviceID).Msg("[homekit] paired")
api.ResponseJSON(w, map[string]string{"url": url})
}
+35 -27
View File
@@ -16,13 +16,11 @@ import (
_ "modernc.org/sqlite"
)
const probeTimeout = 100 * time.Millisecond
const probeTimeout = 120 * time.Millisecond
var log zerolog.Logger
var db *sql.DB
var ports []int
var hasICMP bool
var detectors []func(*probe.Response) string
func Init() {
@@ -35,17 +33,17 @@ func Init() {
}
ports = loadPorts()
hasICMP = probe.CanICMP()
if hasICMP {
log.Info().Msg("[probe] ICMP available")
} else {
log.Info().Msg("[probe] ICMP not available, using port scan only")
// ONVIF detector (highest priority -- auto-discovers all streams)
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.ONVIF != nil {
return "onvif"
}
return ""
})
// HomeKit detector
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.MDNS != nil && !r.Probes.MDNS.Paired {
if r.Probes.MDNS != nil {
if r.Probes.MDNS.Category == "camera" || r.Probes.MDNS.Category == "doorbell" {
return "homekit"
}
@@ -53,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)
}
@@ -88,14 +94,17 @@ func runProbe(parent context.Context, ip string) *probe.Response {
}()
}
fastCtx, fastCancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer fastCancel()
run(func() {
r, _ := probe.ScanPorts(ctx, ip, ports)
r, _ := probe.ScanPorts(fastCtx, ip, ports)
mu.Lock()
resp.Probes.Ports = r
mu.Unlock()
})
run(func() {
r, _ := probe.ReverseDNS(ctx, ip)
r, _ := probe.ReverseDNS(fastCtx, ip)
mu.Lock()
resp.Probes.DNS = r
mu.Unlock()
@@ -117,32 +126,30 @@ func runProbe(parent context.Context, ip string) *probe.Response {
mu.Unlock()
})
run(func() {
r, _ := probe.ProbeHTTP(ctx, ip, nil)
r, _ := probe.ProbeHTTP(fastCtx, ip, nil)
mu.Lock()
resp.Probes.HTTP = r
mu.Unlock()
})
if hasICMP {
run(func() {
r, _ := probe.Ping(ctx, ip)
r, _ := probe.ProbeONVIF(fastCtx, ip)
mu.Lock()
resp.Probes.Ping = r
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
if !resp.Reachable && resp.Probes.Ping != nil {
resp.Reachable = true
}
if resp.Reachable && resp.Probes.Ping != nil {
resp.LatencyMs = resp.Probes.Ping.LatencyMs
}
resp.Reachable = (resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0) ||
resp.Probes.MDNS != nil ||
resp.Probes.Xiaomi != nil
// determine type
resp.Type = "standard"
@@ -184,10 +191,11 @@ func loadPorts() []int {
return defaultPorts()
}
result = append(result, 51826)
log.Info().Int("count", len(result)).Msg("[probe] loaded ports from db")
return result
}
func defaultPorts() []int {
return []int{554, 80, 8080, 443, 8554, 5544, 10554, 1935, 81, 88, 8090, 8001, 8081, 7070, 7447, 34567}
return []int{554, 80, 8080, 443, 8554, 5544, 10554, 1935, 81, 88, 8090, 8001, 8081, 7070, 7447, 34567, 51826}
}
+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"
}
+4
View File
@@ -6,9 +6,11 @@ import (
"github.com/eduard256/strix/internal/frigate"
"github.com/eduard256/strix/internal/generate"
"github.com/eduard256/strix/internal/go2rtc"
"github.com/eduard256/strix/internal/homekit"
"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:
@@ -33,6 +35,8 @@ func main() {
{"generate", generate.Init},
{"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)
}
}
+19 -9
View File
@@ -22,8 +22,11 @@ const (
categoryDoorbell = "18"
)
// QueryHAP sends unicast mDNS query to ip:5353 for HomeKit service.
// Returns nil if device is not a HomeKit camera/doorbell.
var multicastAddr = &net.UDPAddr{IP: net.IP{224, 0, 0, 251}, Port: 5353}
// QueryHAP sends multicast mDNS query for HomeKit service and waits
// for a response from the specified ip. Returns nil if device is not
// a HomeKit camera/doorbell.
func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
msg := &dns.Msg{
Question: []dns.Question{
@@ -36,7 +39,7 @@ func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
return nil, err
}
conn, err := net.ListenPacket("udp4", ":0")
conn, err := net.ListenMulticastUDP("udp4", nil, multicastAddr)
if err != nil {
return nil, err
}
@@ -44,27 +47,34 @@ func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
deadline = time.Now().Add(time.Second)
}
_ = conn.SetDeadline(deadline)
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 5353}
if _, err = conn.WriteTo(query, addr); err != nil {
if _, err = conn.WriteTo(query, multicastAddr); err != nil {
return nil, err
}
targetIP := net.ParseIP(ip)
buf := make([]byte, 1500)
n, _, err := conn.ReadFrom(buf)
for {
n, from, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout = not a HomeKit device
return nil, nil // timeout
}
if !from.(*net.UDPAddr).IP.Equal(targetIP) {
continue
}
var resp dns.Msg
if err = resp.Unpack(buf[:n]); err != nil {
return nil, nil
continue
}
return parseHAPResponse(&resp)
}
}
// internals
+14 -6
View File
@@ -3,23 +3,19 @@ package probe
type Response struct {
IP string `json:"ip"`
Reachable bool `json:"reachable"`
LatencyMs float64 `json:"latency_ms,omitempty"`
Type string `json:"type"` // "unreachable", "standard", "homekit"
Error string `json:"error,omitempty"`
Probes Probes `json:"probes"`
}
type Probes struct {
Ping *PingResult `json:"ping"`
Ports *PortsResult `json:"ports"`
DNS *DNSResult `json:"dns"`
ARP *ARPResult `json:"arp"`
MDNS *MDNSResult `json:"mdns"`
HTTP *HTTPResult `json:"http"`
}
type PingResult struct {
LatencyMs float64 `json:"latency_ms"`
ONVIF *ONVIFResult `json:"onvif"`
Xiaomi *XiaomiResult `json:"xiaomi"`
}
type PortsResult struct {
@@ -49,3 +45,15 @@ type HTTPResult struct {
StatusCode int `json:"status_code"`
Server string `json:"server"`
}
type ONVIFResult struct {
URL string `json:"url"`
Port int `json:"port"`
Name string `json:"name,omitempty"`
Hardware string `json:"hardware,omitempty"`
}
type XiaomiResult struct {
DeviceID uint32 `json:"device_id"`
Stamp uint32 `json:"stamp"`
}
+126
View File
@@ -0,0 +1,126 @@
package probe
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/url"
"regexp"
"strings"
"time"
)
// ProbeONVIF sends unicast WS-Discovery probe to ip:3702.
// Returns nil, nil if the device does not support ONVIF.
func ProbeONVIF(ctx context.Context, ip string) (*ONVIFResult, 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)
// WS-Discovery Probe message
// https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf
msg := `<?xml version="1.0" ?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Header xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">
<a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
<a:MessageID>urn:uuid:` + randUUID() + `</a:MessageID>
<a:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
</s:Header>
<s:Body>
<d:Probe xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<d:Types />
<d:Scopes />
</d:Probe>
</s:Body>
</s:Envelope>`
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 3702}
if _, err = conn.WriteTo([]byte(msg), addr); err != nil {
return nil, err
}
buf := make([]byte, 8192)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout -- device doesn't support ONVIF
}
body := string(buf[:n])
if !strings.Contains(body, "onvif") {
continue
}
xaddrs := findXMLTag(body, "XAddrs")
if xaddrs == "" {
continue
}
// fix buggy cameras reporting 0.0.0.0
// ex. <wsdd:XAddrs>http://0.0.0.0:8080/onvif/device_service</wsdd:XAddrs>
if s, ok := strings.CutPrefix(xaddrs, "http://0.0.0.0"); ok {
xaddrs = "http://" + ip + s
}
port := 80
if u, err := url.Parse(xaddrs); err == nil && u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
scopes := findXMLTag(body, "Scopes")
return &ONVIFResult{
URL: xaddrs,
Port: port,
Name: findScope(scopes, "onvif://www.onvif.org/name/"),
Hardware: findScope(scopes, "onvif://www.onvif.org/hardware/"),
}, nil
}
}
// internals
var reXMLTag = map[string]*regexp.Regexp{}
func findXMLTag(s, tag string) string {
re, ok := reXMLTag[tag]
if !ok {
re = regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`)
reXMLTag[tag] = re
}
m := re.FindStringSubmatch(s)
if len(m) != 2 {
return ""
}
return m[1]
}
func findScope(s, prefix string) string {
i := strings.Index(s, prefix)
if i < 0 {
return ""
}
s = s[i+len(prefix):]
if j := strings.IndexByte(s, ' '); j >= 0 {
s = s[:j]
}
s, _ = url.QueryUnescape(s)
return s
}
func randUUID() string {
b := make([]byte, 16)
rand.Read(b)
s := hex.EncodeToString(b)
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
}
-39
View File
@@ -1,39 +0,0 @@
package probe
import (
"context"
"net"
"time"
)
func CanICMP() bool {
conn, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 100*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
func Ping(ctx context.Context, ip string) (*PingResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
start := time.Now()
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
if err != nil {
return nil, err
}
conn.Close()
return &PingResult{
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
}, nil
}
+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
}
+104
View File
@@ -0,0 +1,104 @@
package tester
import (
"fmt"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/onvif"
)
// testOnvif resolves all ONVIF profiles, tests each via RTSP,
// and adds two Results per profile (onvif:// + rtsp://).
// ex. "onvif://admin:pass@10.0.20.111" or "onvif://admin:pass@10.0.20.119:2020"
func testOnvif(s *Session, rawURL string) {
client, err := onvif.NewClient(rawURL)
if err != nil {
return
}
tokens, err := client.GetProfilesTokens()
if err != nil {
return
}
for _, token := range tokens {
profileURL := rawURL + "?subtype=" + token
pc, err := onvif.NewClient(profileURL)
if err != nil {
continue
}
rtspURI, err := pc.GetURI()
if err != nil {
continue
}
testOnvifProfile(s, profileURL, rtspURI)
}
}
// testOnvifProfile tests a single RTSP stream and adds two Results (onvif + rtsp)
func testOnvifProfile(s *Session, onvifURL, rtspURL string) {
start := time.Now()
prod, err := rtspHandler(rtspURL)
if err != nil {
return
}
defer func() { _ = prod.Stop() }()
latency := time.Since(start).Milliseconds()
var codecs []string
for _, media := range prod.GetMedias() {
if media.Direction != core.DirectionRecvonly {
continue
}
for _, codec := range media.Codecs {
codecs = append(codecs, codec.Name)
}
}
// capture screenshot
var screenshotPath string
var width, height int
if raw, codecName := getScreenshot(prod); raw != nil {
var jpeg []byte
switch codecName {
case core.CodecH264, core.CodecH265:
jpeg = toJPEG(raw)
default:
jpeg = raw
}
if jpeg != nil {
idx := s.AddScreenshot(jpeg)
screenshotPath = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
width, height = jpegSize(jpeg)
}
}
// add onvif:// result
s.AddResult(&Result{
Source: onvifURL,
Screenshot: screenshotPath,
Codecs: codecs,
Width: width,
Height: height,
LatencyMs: latency,
})
// add rtsp:// result (same screenshot, same codecs)
s.AddResult(&Result{
Source: rtspURL,
Screenshot: screenshotPath,
Codecs: codecs,
Width: width,
Height: height,
LatencyMs: latency,
})
}
+11
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
@@ -50,6 +51,16 @@ func RunWorkers(s *Session, urls []string) {
func testURL(s *Session, rawURL string) {
defer s.AddTested()
if strings.HasPrefix(rawURL, "homekit://") {
testHomeKit(s, rawURL)
return
}
if strings.HasPrefix(rawURL, "onvif://") {
testOnvif(s, rawURL)
return
}
handler := GetHandler(rawURL)
if handler == nil {
return
+40
View File
@@ -0,0 +1,40 @@
package tester
import (
"fmt"
"time"
"github.com/AlexxIT/go2rtc/pkg/hap"
)
// testHomeKit -- snapshot via HAP GetImage, bypasses SRTP/Producer flow
func testHomeKit(s *Session, rawURL string) {
start := time.Now()
conn, err := hap.Dial(rawURL)
if err != nil {
return
}
defer conn.Close()
jpeg, err := conn.GetImage(1920, 1080)
if err != nil {
return
}
latency := time.Since(start).Milliseconds()
r := &Result{
Source: rawURL,
Codecs: []string{"JPEG"},
LatencyMs: latency,
}
if len(jpeg) > 0 {
idx := s.AddScreenshot(jpeg)
r.Screenshot = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
r.Width, r.Height = jpegSize(jpeg)
}
s.AddResult(r)
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- detect.sh (worker)
#
# Detects system environment: OS type, Docker, Compose, Frigate, go2rtc.
# Fast, silent, returns JSON events to stdout.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, error, done
# - Exit code: 0 always (detection never "fails", it just reports what it finds)
#
# Usage:
# bash scripts/detect.sh
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
# ---------------------------------------------------------------------------
# 1. System type
# ---------------------------------------------------------------------------
detect_system() {
emit "check" "Detecting system"
if command -v pveversion &>/dev/null; then
local pve_ver
pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown")
emit "ok" "Proxmox VE ${pve_ver}" "{\"type\":\"proxmox\",\"pve_version\":\"${pve_ver}\"}"
elif [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
local mac_ver
mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
emit "ok" "macOS ${mac_ver} (${arch})" "{\"type\":\"macos\",\"version\":\"${mac_ver}\",\"arch\":\"${arch}\"}"
else
local os_name="Linux"
local os_id="unknown"
local os_ver="unknown"
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
if [[ -f /etc/os-release ]]; then
. /etc/os-release
os_name="${PRETTY_NAME:-Linux}"
os_id="${ID:-unknown}"
os_ver="${VERSION_ID:-unknown}"
fi
emit "ok" "${os_name} (${arch})" "{\"type\":\"linux\",\"id\":\"${os_id}\",\"version\":\"${os_ver}\",\"arch\":\"${arch}\"}"
fi
}
# ---------------------------------------------------------------------------
# 2. Docker
# ---------------------------------------------------------------------------
detect_docker() {
emit "check" "Checking Docker"
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}"
else
emit "miss" "Docker not installed"
fi
}
# ---------------------------------------------------------------------------
# 3. Docker Compose
# ---------------------------------------------------------------------------
detect_compose() {
emit "check" "Checking Docker Compose"
if docker compose version &>/dev/null 2>&1; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"plugin\"}"
elif command -v docker-compose &>/dev/null; then
local ver
ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"standalone\"}"
else
emit "miss" "Docker Compose not installed"
fi
}
# ---------------------------------------------------------------------------
# 4. Frigate
# ---------------------------------------------------------------------------
detect_frigate() {
emit "check" "Checking Frigate"
if command -v curl &>/dev/null; then
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then
emit "ok" "Frigate on port 5000" "{\"url\":\"http://localhost:5000\",\"port\":5000}"
return
fi
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:8971/api/config" &>/dev/null; then
emit "ok" "Frigate on port 8971" "{\"url\":\"http://localhost:8971\",\"port\":8971}"
return
fi
fi
emit "miss" "Frigate not found"
}
# ---------------------------------------------------------------------------
# 5. go2rtc
# ---------------------------------------------------------------------------
detect_go2rtc() {
emit "check" "Checking go2rtc"
if command -v curl &>/dev/null; then
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:1984/api" &>/dev/null; then
emit "ok" "go2rtc on port 1984" "{\"url\":\"http://localhost:1984\",\"port\":1984}"
return
fi
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:11984/api" &>/dev/null; then
emit "ok" "go2rtc on port 11984" "{\"url\":\"http://localhost:11984\",\"port\":11984}"
return
fi
fi
emit "miss" "go2rtc not found"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
detect_system
detect_docker
detect_compose
detect_frigate
detect_go2rtc
printf '{"type":"done","ok":true}\n'
}
main
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- linux.sh (navigator for plain Linux / macOS)
# =============================================================================
set -o pipefail
BACKTITLE="Strix Installer | Linux mode"
WT_H=16
WT_W=60
command -v whiptail &>/dev/null || { echo "whiptail required (install: apt install whiptail | dnf install newt)"; exit 1; }
# Dark theme for whiptail
export NEWT_COLORS='
root=,black
window=,black
border=white,black
textbox=white,black
button=black,white
actbutton=white,magenta
compactbutton=white,black
listbox=white,black
actlistbox=white,magenta
title=magenta,black
roottext=white,black
emptyscale=,black
fullscale=,magenta
helpline=white,black
'
# Parameters
INSTALL_MODE=""
FRIGATE_URL=""
GO2RTC_URL=""
STRIX_PORT="4567"
LOG_LEVEL=""
STRIX_TAG="latest"
# ---------------------------------------------------------------------------
# Simple flow
# ---------------------------------------------------------------------------
simple_flow() {
local step=1
while true; do
case $step in
1) # Mode
INSTALL_MODE=$(whiptail --backtitle "$BACKTITLE" --title " Install Mode " \
--menu "" $WT_H $WT_W 3 \
"1" "Strix only" \
"2" "Strix + Frigate" \
"3" "Advanced setup" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$INSTALL_MODE" in
1) INSTALL_MODE="strix"; step=2 ;;
2) INSTALL_MODE="strix-frigate"; step=3 ;;
3) advanced_flow; return ;;
esac
;;
2) # Frigate URL (strix only)
FRIGATE_URL=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):\n\nExample: http://192.168.1.100:5000" \
$WT_H $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$FRIGATE_URL" == "http://" || "$FRIGATE_URL" == "https://" ]] && FRIGATE_URL=""
step=3
;;
3) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Advanced flow
# ---------------------------------------------------------------------------
advanced_flow() {
local step=1
INSTALL_MODE="${INSTALL_MODE:-strix}"
while true; do
case $step in
1) # Mode
local choice
choice=$(whiptail --backtitle "$BACKTITLE" --title " Mode " \
--menu "" $WT_H $WT_W 2 \
"strix" "Strix only" \
"strix-frigate" "Strix + Frigate" \
--default-item "$INSTALL_MODE" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
INSTALL_MODE="$choice"; step=2 ;;
2) # Port
local val
val=$(whiptail --backtitle "$BACKTITLE" --title " Port " \
--inputbox "Strix port:" 9 $WT_W "$STRIX_PORT" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_PORT="${val:-4567}"; step=3 ;;
3) # Frigate
val=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):" 9 $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
FRIGATE_URL="$val"; step=4 ;;
4) # go2rtc
val=$(whiptail --backtitle "$BACKTITLE" --title " go2rtc " \
--inputbox "go2rtc URL (empty to skip):" 9 $WT_W "${GO2RTC_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
GO2RTC_URL="$val"; step=5 ;;
5) # Log level
val=$(whiptail --backtitle "$BACKTITLE" --title " Log Level " \
--menu "" 14 $WT_W 5 \
"" "default (info)" \
"debug" "debug" \
"info" "info" \
"warn" "warn" \
"error" "error" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LOG_LEVEL="$val"; step=6 ;;
6) # Tag
val=$(whiptail --backtitle "$BACKTITLE" --title " Image Tag " \
--inputbox "Strix image tag:" 9 $WT_W "$STRIX_TAG" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_TAG="${val:-latest}"; step=7 ;;
7) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\nTag: ${STRIX_TAG}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
[[ -n "$GO2RTC_URL" ]] && s+="go2rtc: ${GO2RTC_URL}\n"
[[ -n "$LOG_LEVEL" ]] && s+="Log: ${LOG_LEVEL}\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
C_RESET="\033[0m"
C_BOLD="\033[1m"
C_DIM="\033[2m"
C_GREEN="\033[32m"
C_RED="\033[31m"
C_YELLOW="\033[33m"
C_CYAN="\033[36m"
C_WHITE="\033[97m"
C_MAGENTA="\033[35m"
# ---------------------------------------------------------------------------
# Worker runner: streams JSON events as status lines
# ---------------------------------------------------------------------------
SCRIPTS_BASE="https://raw.githubusercontent.com/eduard256/Strix/main/scripts"
download_worker() {
local name="$1"
local dest="/tmp/strix-${name}"
curl -fsSL "${SCRIPTS_BASE}/${name}" -o "$dest" 2>/dev/null
echo "$dest"
}
print_events() {
while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
}
json_field() {
echo "$1" | grep -oP "\"$2\"\s*:\s*\"\K[^\"]*" | head -1
}
# ---------------------------------------------------------------------------
# LAN IP detection
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip=$(ifconfig 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '127.0.0.1' | head -1)
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# Final URLs
# ---------------------------------------------------------------------------
show_urls() {
local ip="$1"
local port="$2"
local mode="$3"
echo ""
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD} Installation Complete${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo ""
echo -e " ${C_WHITE}${C_BOLD}Strix:${C_RESET} ${C_CYAN}http://${ip}:${port}${C_RESET}"
if [[ "$mode" == "strix-frigate" ]]; then
echo -e " ${C_WHITE}${C_BOLD}Frigate:${C_RESET} ${C_CYAN}http://${ip}:8971${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}Frigate API:${C_RESET} ${C_CYAN}http://${ip}:5000${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}go2rtc:${C_RESET} ${C_CYAN}http://${ip}:1984${C_RESET}"
fi
echo ""
echo -e " ${C_DIM}Press Enter to exit${C_RESET}"
read -r
}
# ---------------------------------------------------------------------------
# Check root / docker -- bail early if not sudo and docker missing
# ---------------------------------------------------------------------------
check_sudo_required() {
if [[ "$(id -u)" -eq 0 ]]; then
return 0 # already root
fi
if command -v docker &>/dev/null; then
return 0 # docker present, maybe root not strictly needed
fi
clear
echo ""
echo -e " ${C_RED}${C_BOLD}Root privileges required${C_RESET}"
echo ""
echo -e " Docker is not installed. Installing it needs root."
echo -e " Please re-run the installer with ${C_BOLD}sudo${C_RESET}:"
echo ""
echo -e " ${C_CYAN}${C_BOLD}curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/scripts/install.sh | sudo bash${C_RESET}"
echo ""
exit 1
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
simple_flow
clear
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}STRIX INSTALLER${C_RESET} ${C_DIM}(Linux)${C_RESET}"
echo -e " ${C_DIM}Mode: ${INSTALL_MODE} | Port: ${STRIX_PORT}${C_RESET}"
echo ""
check_sudo_required
# Step 1: Check Docker / install via prepare.sh
if ! command -v docker &>/dev/null || ! docker compose version &>/dev/null; then
echo -e " ${C_MAGENTA}${C_BOLD}--- Installing Docker ---${C_RESET}"
echo ""
prepare_script=$(download_worker "prepare.sh")
bash "$prepare_script" 2>/dev/null | print_events
rm -f "$prepare_script"
echo ""
fi
# Step 2: Deploy
if [[ "$INSTALL_MODE" == "strix-frigate" ]]; then
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix + Frigate ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix-frigate.sh")
deploy_args="--port $STRIX_PORT --tag $STRIX_TAG"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
[[ -n "$LOG_LEVEL" ]] && deploy_args="$deploy_args --log-level $LOG_LEVEL"
deploy_output=$(bash "$deploy_script" $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | print_events
rm -f "$deploy_script"
else
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix.sh")
deploy_args="--port $STRIX_PORT --tag $STRIX_TAG"
[[ -n "$FRIGATE_URL" ]] && deploy_args="$deploy_args --frigate-url $FRIGATE_URL"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
[[ -n "$LOG_LEVEL" ]] && deploy_args="$deploy_args --log-level $LOG_LEVEL"
deploy_output=$(bash "$deploy_script" $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | print_events
rm -f "$deploy_script"
fi
# Final URLs
deploy_ok=$(echo "$deploy_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$deploy_ok" == "true" ]]; then
lan_ip=$(detect_lan_ip)
show_urls "$lan_ip" "$STRIX_PORT" "$INSTALL_MODE"
else
echo ""
echo -e " ${C_RED}${C_BOLD}Deployment failed.${C_RESET}"
echo ""
fi
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- prepare.sh (worker)
#
# Silent backend worker that prepares the system for Strix deployment.
# Detects OS, installs Docker and Docker Compose if missing.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Field "msg" is always human-readable.
# - Field "data" is optional, carries machine-readable details.
# - Last line is always: {"type":"done","ok":true} or {"type":"done","ok":false,"error":"..."}
# - All internal command output goes to /dev/null or stderr (never stdout).
# - Exit code: 0 = success, 1 = failure.
#
# Usage:
# bash scripts/prepare.sh
# result=$(bash scripts/prepare.sh)
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# JSON helpers (no jq dependency)
# ---------------------------------------------------------------------------
# Emit a JSON event line to stdout.
# Usage: emit "type" "msg" '{"key":"val"}'
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
# Escape double quotes in msg
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
# Emit final done event and exit.
emit_done() {
local ok="$1"
local error="${2:-}"
if [[ "$ok" == "true" ]]; then
printf '{"type":"done","ok":true}\n'
exit 0
else
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
fi
}
# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------
detect_os() {
emit "check" "Detecting operating system"
local kernel
kernel=$(uname -s 2>/dev/null || echo "unknown")
case "$kernel" in
Linux)
local os_id="unknown"
local os_ver="unknown"
local os_name="Unknown Linux"
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
os_id="${ID:-unknown}"
os_ver="${VERSION_ID:-unknown}"
os_name="${PRETTY_NAME:-${ID} ${VERSION_ID}}"
fi
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
local arch_label="$arch"
case "$arch" in
x86_64) arch_label="amd64" ;;
aarch64) arch_label="arm64" ;;
armv7l) arch_label="armv7" ;;
esac
OS_TYPE="linux"
OS_ID="$os_id"
OS_VER="$os_ver"
OS_NAME="$os_name"
OS_ARCH="$arch_label"
emit "ok" "${os_name} (${arch_label})" \
"{\"os\":\"linux\",\"id\":\"${os_id}\",\"ver\":\"${os_ver}\",\"arch\":\"${arch_label}\"}"
;;
Darwin)
local mac_ver
mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
local arch_label="$arch"
case "$arch" in
x86_64) arch_label="amd64" ;;
arm64) arch_label="arm64" ;;
esac
OS_TYPE="mac"
OS_ID="macos"
OS_VER="$mac_ver"
OS_NAME="macOS ${mac_ver}"
OS_ARCH="$arch_label"
emit "ok" "macOS ${mac_ver} (${arch_label})" \
"{\"os\":\"mac\",\"id\":\"macos\",\"ver\":\"${mac_ver}\",\"arch\":\"${arch_label}\"}"
;;
*)
emit "error" "Unsupported OS: ${kernel}" \
"{\"kernel\":\"${kernel}\"}"
emit_done "false" "Unsupported operating system: ${kernel}"
;;
esac
}
# ---------------------------------------------------------------------------
# Root check (Linux only)
# ---------------------------------------------------------------------------
check_root() {
if [[ "$OS_TYPE" == "mac" ]]; then
return
fi
emit "check" "Checking root privileges"
if [[ "$(id -u)" -eq 0 ]]; then
emit "ok" "Running as root"
else
emit "error" "Root privileges required. Run with sudo."
emit_done "false" "Not running as root"
fi
}
# ---------------------------------------------------------------------------
# curl (required for Docker install and compose download)
# ---------------------------------------------------------------------------
ensure_curl() {
emit "check" "Checking curl"
if command -v curl &>/dev/null; then
emit "ok" "curl available"
return 0
fi
emit "miss" "curl not found"
emit "install" "Installing curl"
local pkg_mgr="unknown"
if command -v apt-get &>/dev/null; then
pkg_mgr="apt"
emit "check" "Updating apt package lists"
if ! apt-get update -qq &>/dev/null; then
emit "error" "apt-get update failed"
emit_done "false" "Failed to update package lists"
fi
emit "ok" "Package lists updated"
emit "install" "Installing curl via apt"
apt-get install -y -qq curl &>/dev/null
elif command -v yum &>/dev/null; then
pkg_mgr="yum"
emit "install" "Installing curl via yum"
yum install -y -q curl &>/dev/null
elif command -v dnf &>/dev/null; then
pkg_mgr="dnf"
emit "install" "Installing curl via dnf"
dnf install -y -q curl &>/dev/null
elif command -v apk &>/dev/null; then
pkg_mgr="apk"
emit "install" "Installing curl via apk"
apk add --no-cache curl &>/dev/null
elif command -v pacman &>/dev/null; then
pkg_mgr="pacman"
emit "install" "Installing curl via pacman"
pacman -Sy --noconfirm curl &>/dev/null
elif command -v zypper &>/dev/null; then
pkg_mgr="zypper"
emit "install" "Installing curl via zypper"
zypper install -y curl &>/dev/null
else
emit "error" "No supported package manager found" "{\"tried\":\"apt,yum,dnf,apk,pacman,zypper\"}"
emit_done "false" "Cannot install curl: no supported package manager"
fi
if command -v curl &>/dev/null; then
emit "ok" "curl installed via ${pkg_mgr}"
return 0
fi
emit "error" "curl installation failed via ${pkg_mgr}"
emit_done "false" "curl installation failed"
}
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
check_docker() {
emit "check" "Checking Docker"
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}"
return 0
fi
emit "miss" "Docker not found"
return 1
}
install_docker_linux() {
emit "install" "Downloading Docker install script from get.docker.com"
local tmp_script="/tmp/get-docker.sh"
if ! curl -fsSL https://get.docker.com -o "$tmp_script" 2>/dev/null; then
emit "error" "Failed to download get.docker.com"
emit_done "false" "Docker download failed"
fi
emit "ok" "Docker install script downloaded"
emit "install" "Running Docker install script (this may take a minute)"
if sh "$tmp_script" &>/dev/null; then
rm -f "$tmp_script"
emit "ok" "Docker install script completed"
else
rm -f "$tmp_script"
emit "error" "Docker install script failed"
emit_done "false" "Docker installation failed"
fi
# Enable and start via systemd
if command -v systemctl &>/dev/null; then
emit "check" "Enabling Docker service"
systemctl enable docker &>/dev/null || true
systemctl start docker &>/dev/null || true
if systemctl is-active docker &>/dev/null; then
emit "ok" "Docker service started"
else
emit "error" "Docker service failed to start"
emit_done "false" "Docker service failed to start"
fi
fi
# Verify docker binary works
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver} installed" "{\"version\":\"${ver}\"}"
return 0
fi
emit "error" "Docker binary not found after install"
emit_done "false" "Docker installation failed"
}
install_docker_mac() {
emit "check" "Checking Docker Desktop for Mac"
# Docker Desktop should already be installed on Mac.
# We can't silently install it -- it requires GUI interaction.
emit "error" "Docker not found. Install Docker Desktop from https://docker.com/products/docker-desktop"
emit_done "false" "Docker Desktop not installed on Mac"
}
# ---------------------------------------------------------------------------
# Docker Compose
# ---------------------------------------------------------------------------
check_compose() {
emit "check" "Checking Docker Compose"
# Plugin (v2): docker compose
if docker compose version &>/dev/null; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
COMPOSE_CMD="docker compose"
emit "ok" "Docker Compose ${ver} (plugin)" "{\"version\":\"${ver}\",\"type\":\"plugin\"}"
return 0
fi
# Standalone: docker-compose
if command -v docker-compose &>/dev/null; then
local ver
ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
COMPOSE_CMD="docker-compose"
emit "ok" "Docker Compose ${ver} (standalone)" "{\"version\":\"${ver}\",\"type\":\"standalone\"}"
return 0
fi
emit "miss" "Docker Compose not found"
return 1
}
install_compose_linux() {
emit "install" "Installing Docker Compose plugin"
local installed=false
# Try package manager first
if command -v apt-get &>/dev/null; then
apt-get update -qq &>/dev/null && apt-get install -y -qq docker-compose-plugin &>/dev/null && installed=true
elif command -v yum &>/dev/null; then
yum install -y -q docker-compose-plugin &>/dev/null && installed=true
elif command -v dnf &>/dev/null; then
dnf install -y -q docker-compose-plugin &>/dev/null && installed=true
fi
# Fallback: download binary
if [[ "$installed" == false ]]; then
emit "install" "Downloading Docker Compose binary"
local compose_ver="v2.29.1"
local compose_arch
case "$OS_ARCH" in
amd64) compose_arch="x86_64" ;;
arm64) compose_arch="aarch64" ;;
*) compose_arch="$(uname -m)" ;;
esac
mkdir -p /usr/local/lib/docker/cli-plugins &>/dev/null
if curl -fsSL "https://github.com/docker/compose/releases/download/${compose_ver}/docker-compose-linux-${compose_arch}" \
-o /usr/local/lib/docker/cli-plugins/docker-compose &>/dev/null; then
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
installed=true
fi
fi
# Verify
if [[ "$installed" == true ]] && docker compose version &>/dev/null; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
COMPOSE_CMD="docker compose"
emit "ok" "Docker Compose ${ver} installed" "{\"version\":\"${ver}\"}"
return 0
fi
emit "error" "Docker Compose installation failed"
emit_done "false" "Docker Compose installation failed"
}
install_compose_mac() {
# On Mac, Docker Compose comes with Docker Desktop
emit "error" "Docker Compose not found. It should be included with Docker Desktop."
emit_done "false" "Docker Compose missing on Mac"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Detect OS
detect_os
# 2. Root check
check_root
# 3. curl (needed for Docker install, always present on Mac)
if [[ "$OS_TYPE" == "linux" ]]; then
ensure_curl
fi
# 4. Docker
if ! check_docker; then
case "$OS_TYPE" in
linux) install_docker_linux ;;
mac) install_docker_mac ;;
esac
fi
# 5. Docker Compose
if ! check_compose; then
case "$OS_TYPE" in
linux) install_compose_linux ;;
mac) install_compose_mac ;;
esac
fi
# 6. All good
emit_done "true"
}
main
+606
View File
@@ -0,0 +1,606 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- proxmox-lxc-create.sh (worker)
#
# Creates an unprivileged Ubuntu LXC container on Proxmox with Docker support.
# Runs ON the Proxmox host. Uses only official CLI tools (pct, pveam, pvesm).
# Does NOT install anything inside the container -- just creates and starts it.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Last line: {"type":"done","ok":true,"data":{...}} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --id ID Container ID (default: auto, next free)
# --hostname NAME Hostname (default: strix)
# --memory MB RAM in MB (default: 2048)
# --swap MB Swap in MB (default: 512)
# --disk GB Disk size in GB (default: 32)
# --cores N CPU cores (default: 2)
# --storage NAME Storage for container disk (default: auto)
# --bridge NAME Network bridge (default: auto, first vmbr*)
# --ip CIDR IP address, e.g. 10.0.99.110/24 (default: dhcp)
# --gateway IP Gateway (required if --ip is static)
# --password PASS Root password (default: auto-generated)
#
# Usage:
# bash scripts/proxmox-lxc-create.sh
# bash scripts/proxmox-lxc-create.sh --hostname strix --memory 4096 --cores 4
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CT_ID=""
CT_HOSTNAME="strix"
CT_MEMORY="2048"
CT_SWAP="512"
CT_DISK="32"
CT_CORES="2"
CT_STORAGE=""
CT_BRIDGE=""
CT_IP="dhcp"
CT_GATEWAY=""
CT_PASSWORD=""
TEMPLATE_STORAGE=""
TEMPLATE=""
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--id) CT_ID="$2"; shift 2 ;;
--hostname) CT_HOSTNAME="$2"; shift 2 ;;
--memory) CT_MEMORY="$2"; shift 2 ;;
--swap) CT_SWAP="$2"; shift 2 ;;
--disk) CT_DISK="$2"; shift 2 ;;
--cores) CT_CORES="$2"; shift 2 ;;
--storage) CT_STORAGE="$2"; shift 2 ;;
--bridge) CT_BRIDGE="$2"; shift 2 ;;
--ip) CT_IP="$2"; shift 2 ;;
--gateway) CT_GATEWAY="$2"; shift 2 ;;
--password) CT_PASSWORD="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done_ok() {
local data="$1"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
}
emit_done_fail() {
local error="$1"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
}
# Cleanup on failure: destroy container if it was partially created
cleanup_on_fail() {
local id="$1"
local msg="$2"
if pct status "$id" &>/dev/null; then
pct stop "$id" &>/dev/null || true
pct destroy "$id" --purge &>/dev/null || true
emit "ok" "Rolled back: container ${id} destroyed"
fi
emit "error" "$msg"
emit_done_fail "$msg"
}
# ---------------------------------------------------------------------------
# 1. Verify Proxmox environment
# ---------------------------------------------------------------------------
check_proxmox() {
emit "check" "Verifying Proxmox environment"
if ! command -v pct &>/dev/null; then
emit "error" "pct not found -- this script must run on a Proxmox host"
emit_done_fail "Not a Proxmox host"
fi
if ! command -v pveam &>/dev/null; then
emit "error" "pveam not found -- this script must run on a Proxmox host"
emit_done_fail "Not a Proxmox host"
fi
local pve_ver
pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown")
emit "ok" "Proxmox VE ${pve_ver}" "{\"pve_version\":\"${pve_ver}\"}"
}
# ---------------------------------------------------------------------------
# 2. Auto-detect container ID
# ---------------------------------------------------------------------------
resolve_ct_id() {
emit "check" "Resolving container ID"
if [[ -n "$CT_ID" ]]; then
# Verify it's free
if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then
emit "error" "Container/VM ID ${CT_ID} is already in use"
emit_done_fail "CT ID ${CT_ID} already in use"
fi
emit "ok" "Using specified ID: ${CT_ID}"
else
CT_ID=$(pvesh get /cluster/nextid 2>/dev/null || echo "")
if [[ -z "$CT_ID" ]]; then
emit "error" "Failed to get next free container ID"
emit_done_fail "Cannot get next free CT ID"
fi
# Double-check it's actually free
if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then
CT_ID=$((CT_ID + 1))
fi
emit "ok" "Auto-assigned ID: ${CT_ID}" "{\"id\":\"${CT_ID}\"}"
fi
}
# ---------------------------------------------------------------------------
# 3. Auto-detect storage
# ---------------------------------------------------------------------------
resolve_storage() {
# Container storage (rootdir)
emit "check" "Resolving container storage"
if [[ -n "$CT_STORAGE" ]]; then
if ! pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$CT_STORAGE"; then
emit "error" "Storage '${CT_STORAGE}' not found"
emit_done_fail "Storage ${CT_STORAGE} not found"
fi
emit "ok" "Using specified storage: ${CT_STORAGE}"
else
# Find first storage that supports rootdir content
CT_STORAGE=$(pvesm status -content rootdir 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}')
if [[ -z "$CT_STORAGE" ]]; then
# Fallback: try local-lvm, then local
if pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "local-lvm"; then
CT_STORAGE="local-lvm"
else
CT_STORAGE="local"
fi
fi
emit "ok" "Auto-detected storage: ${CT_STORAGE}" "{\"storage\":\"${CT_STORAGE}\"}"
fi
# Template storage (vztmpl)
emit "check" "Resolving template storage"
TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}')
if [[ -z "$TEMPLATE_STORAGE" ]]; then
TEMPLATE_STORAGE="local"
fi
emit "ok" "Template storage: ${TEMPLATE_STORAGE}" "{\"template_storage\":\"${TEMPLATE_STORAGE}\"}"
}
# ---------------------------------------------------------------------------
# 4. Check free space
# ---------------------------------------------------------------------------
check_free_space() {
emit "check" "Checking free space on ${CT_STORAGE}"
local avail_kb
avail_kb=$(pvesm status 2>/dev/null | awk -v s="$CT_STORAGE" '$1==s{print $6}')
if [[ -n "$avail_kb" ]]; then
local avail_gb=$((avail_kb / 1024 / 1024))
local required_gb=$CT_DISK
if [[ "$avail_gb" -lt "$required_gb" ]]; then
emit "error" "Not enough space: ${avail_gb}GB available, ${required_gb}GB required"
emit_done_fail "Not enough disk space on ${CT_STORAGE}"
fi
emit "ok" "${avail_gb}GB available, ${required_gb}GB required"
else
emit "ok" "Could not determine free space, proceeding"
fi
}
# ---------------------------------------------------------------------------
# 5. Auto-detect network bridge
# ---------------------------------------------------------------------------
resolve_bridge() {
emit "check" "Resolving network bridge"
if [[ -n "$CT_BRIDGE" ]]; then
emit "ok" "Using specified bridge: ${CT_BRIDGE}"
return
fi
# Find first vmbr* interface
CT_BRIDGE=$(ip link show 2>/dev/null | grep -oP 'vmbr\d+' | head -1)
if [[ -z "$CT_BRIDGE" ]]; then
CT_BRIDGE="vmbr0"
emit "ok" "Defaulting to bridge: vmbr0"
else
emit "ok" "Auto-detected bridge: ${CT_BRIDGE}" "{\"bridge\":\"${CT_BRIDGE}\"}"
fi
}
# ---------------------------------------------------------------------------
# 6. Generate password
# ---------------------------------------------------------------------------
resolve_password() {
if [[ -n "$CT_PASSWORD" ]]; then
return
fi
emit "check" "Generating root password"
CT_PASSWORD=$(openssl rand -base64 12 2>/dev/null | tr -d '/+=' | head -c 16)
if [[ -z "$CT_PASSWORD" ]]; then
# Fallback if openssl not available
CT_PASSWORD=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 16)
fi
emit "ok" "Root password generated"
}
# ---------------------------------------------------------------------------
# 7. Download Ubuntu template
# ---------------------------------------------------------------------------
download_template() {
emit "check" "Searching for Ubuntu template"
# Check if already downloaded locally
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null \
| awk '$1 ~ /ubuntu-24\.04.*-standard_/ {print $1}' \
| sed 's|.*/||' \
| sort -V \
| tail -1)
if [[ -n "$TEMPLATE" ]]; then
emit "ok" "Template found locally: ${TEMPLATE}"
return
fi
# Not local, try online
emit "miss" "No local Ubuntu 24.04 template"
emit "install" "Updating template catalog"
if command -v timeout &>/dev/null; then
timeout 30 pveam update &>/dev/null || true
else
pveam update &>/dev/null || true
fi
# Search for Ubuntu 24.04
TEMPLATE=$(pveam available --section system 2>/dev/null \
| awk '$2 ~ /ubuntu-24\.04.*-standard_/ {print $2}' \
| sort -V \
| tail -1)
# Fallback to 22.04
if [[ -z "$TEMPLATE" ]]; then
emit "miss" "Ubuntu 24.04 not available, trying 22.04"
TEMPLATE=$(pveam available --section system 2>/dev/null \
| awk '$2 ~ /ubuntu-22\.04.*-standard_/ {print $2}' \
| sort -V \
| tail -1)
fi
if [[ -z "$TEMPLATE" ]]; then
emit "error" "No Ubuntu template found"
emit_done_fail "No Ubuntu template available"
fi
emit "install" "Downloading template: ${TEMPLATE}"
local attempt
for attempt in 1 2 3; do
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" &>/dev/null; then
emit "ok" "Template downloaded: ${TEMPLATE}"
return
fi
if [[ "$attempt" -lt 3 ]]; then
emit "check" "Download failed, retrying (${attempt}/3)"
sleep $((attempt * 5))
fi
done
emit "error" "Template download failed after 3 attempts"
emit_done_fail "Template download failed"
}
# ---------------------------------------------------------------------------
# 8. Ensure subuid/subgid (required for unprivileged containers)
# ---------------------------------------------------------------------------
fix_subuid_subgid() {
emit "check" "Checking subuid/subgid mappings"
local changed=false
if ! grep -q "root:100000:65536" /etc/subuid 2>/dev/null; then
echo "root:100000:65536" >> /etc/subuid
changed=true
fi
if ! grep -q "root:100000:65536" /etc/subgid 2>/dev/null; then
echo "root:100000:65536" >> /etc/subgid
changed=true
fi
if [[ "$changed" == true ]]; then
emit "ok" "subuid/subgid mappings added"
else
emit "ok" "subuid/subgid mappings present"
fi
}
# ---------------------------------------------------------------------------
# 9. Create container
# ---------------------------------------------------------------------------
create_container() {
emit "install" "Creating LXC container ${CT_ID}"
# Build network string
local net_string="name=eth0,bridge=${CT_BRIDGE}"
if [[ "$CT_IP" == "dhcp" ]]; then
net_string="${net_string},ip=dhcp,ip6=dhcp"
else
net_string="${net_string},ip=${CT_IP}"
[[ -n "$CT_GATEWAY" ]] && net_string="${net_string},gw=${CT_GATEWAY}"
fi
local pct_cmd=(
pct create "$CT_ID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}"
-hostname "$CT_HOSTNAME"
-cores "$CT_CORES"
-memory "$CT_MEMORY"
-swap "$CT_SWAP"
-rootfs "${CT_STORAGE}:${CT_DISK}"
-net0 "$net_string"
-features "nesting=1,keyctl=1"
-unprivileged 1
-onboot 1
-password "$CT_PASSWORD"
)
if "${pct_cmd[@]}" &>/dev/null; then
emit "ok" "Container ${CT_ID} created"
else
# Retry once -- could be race condition on ID
if pct status "$CT_ID" &>/dev/null; then
emit "error" "Container ID ${CT_ID} was claimed by another process"
CT_ID=$((CT_ID + 1))
pct_cmd[2]="$CT_ID"
if "${pct_cmd[@]}" &>/dev/null; then
emit "ok" "Container ${CT_ID} created (reassigned ID)"
else
emit "error" "Container creation failed"
emit_done_fail "pct create failed"
fi
else
emit "error" "Container creation failed"
emit_done_fail "pct create failed"
fi
fi
}
# ---------------------------------------------------------------------------
# 10. Start container
# ---------------------------------------------------------------------------
start_container() {
emit "install" "Starting container ${CT_ID}"
if pct start "$CT_ID" &>/dev/null; then
emit "ok" "Container ${CT_ID} started"
else
cleanup_on_fail "$CT_ID" "Failed to start container ${CT_ID}"
fi
}
# ---------------------------------------------------------------------------
# 11. Setup autologin for Proxmox console
# ---------------------------------------------------------------------------
setup_autologin() {
emit "check" "Configuring console autologin"
# Wait a moment for systemd to initialize inside the container
sleep 2
pct exec "$CT_ID" -- bash -c '
mkdir -p /etc/systemd/system/container-getty@1.service.d
cat > /etc/systemd/system/container-getty@1.service.d/override.conf <<AUTOLOGIN
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 \$TERM
AUTOLOGIN
systemctl daemon-reload
systemctl restart container-getty@1.service
' &>/dev/null
if [[ $? -eq 0 ]]; then
emit "ok" "Console autologin enabled"
else
# Non-fatal -- container works fine without it
emit "ok" "Console autologin skipped (non-critical)"
fi
}
# ---------------------------------------------------------------------------
# 12. Select fastest apt mirror and update
# ---------------------------------------------------------------------------
setup_apt_mirror() {
emit "check" "Selecting fastest apt mirror"
# Wait for network inside container first
local net_ready=false
for (( i = 1; i <= 15; i++ )); do
if pct exec "$CT_ID" -- ping -c 1 -W 2 archive.ubuntu.com &>/dev/null; then
net_ready=true
break
fi
sleep 1
done
if [[ "$net_ready" == false ]]; then
emit "ok" "Network not ready, skipping mirror selection"
return
fi
# Ping mirrors in parallel, pick fastest
local best_mirror="archive.ubuntu.com"
local best_time=9999
local mirrors=(
"archive.ubuntu.com"
"mirror.yandex.ru"
"de.archive.ubuntu.com"
"nl.archive.ubuntu.com"
"us.archive.ubuntu.com"
"mirror.linux-ia64.org"
)
local tmpdir
tmpdir=$(pct exec "$CT_ID" -- mktemp -d 2>/dev/null || echo "/tmp/mirror-test")
# Launch all pings in parallel inside the container
pct exec "$CT_ID" -- bash -c "
mkdir -p ${tmpdir}
for m in ${mirrors[*]}; do
(ping -c 1 -W 2 \$m 2>/dev/null | grep -oP 'time=\K[0-9.]+' > ${tmpdir}/\$m || echo 9999 > ${tmpdir}/\$m) &
done
wait
" &>/dev/null
# Read results
for m in "${mirrors[@]}"; do
local ms
ms=$(pct exec "$CT_ID" -- cat "${tmpdir}/${m}" 2>/dev/null | head -1)
ms="${ms:-9999}"
# Compare as integers (strip decimal)
local ms_int="${ms%%.*}"
ms_int="${ms_int:-9999}"
if [[ "$ms_int" -lt "$best_time" ]]; then
best_time="$ms_int"
best_mirror="$m"
fi
done
# Cleanup
pct exec "$CT_ID" -- rm -rf "$tmpdir" &>/dev/null
emit "ok" "Fastest mirror: ${best_mirror} (${best_time}ms)" "{\"mirror\":\"${best_mirror}\",\"latency_ms\":${best_time}}"
# Apply mirror if different from default
if [[ "$best_mirror" != "archive.ubuntu.com" ]]; then
emit "install" "Configuring apt mirror: ${best_mirror}"
pct exec "$CT_ID" -- bash -c "
sed -i 's|http://archive.ubuntu.com|http://${best_mirror}|g' /etc/apt/sources.list
" &>/dev/null
emit "ok" "Apt mirror set to ${best_mirror}"
fi
# Run apt update
emit "install" "Updating package lists"
if pct exec "$CT_ID" -- bash -c "apt-get update -qq" &>/dev/null; then
emit "ok" "Package lists updated"
else
emit "ok" "Package lists update had warnings (non-critical)"
fi
}
# ---------------------------------------------------------------------------
# 13. Wait for network and get IP
# ---------------------------------------------------------------------------
wait_for_network() {
emit "check" "Waiting for network"
local ip=""
local retries=30
for (( i = 1; i <= retries; i++ )); do
ip=$(pct exec "$CT_ID" -- ip -4 -o addr show dev eth0 2>/dev/null \
| awk '{print $4}' \
| cut -d/ -f1 \
| head -1)
if [[ -n "$ip" && "$ip" != "127.0.0.1" ]]; then
emit "ok" "Container IP: ${ip}" "{\"ip\":\"${ip}\"}"
CT_ACTUAL_IP="$ip"
return
fi
sleep 1
done
# Fallback: no IP but container is running
CT_ACTUAL_IP="unknown"
emit "ok" "Container running but IP not detected (check network manually)"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Verify we're on Proxmox
check_proxmox
# 2. Container ID
resolve_ct_id
# 3. Storage
resolve_storage
# 4. Free space
check_free_space
# 5. Network bridge
resolve_bridge
# 6. Password
resolve_password
# 7. Template
download_template
# 8. subuid/subgid
fix_subuid_subgid
# 9. Create
create_container
# 10. Start
start_container
# 11. Autologin
setup_autologin
# 12. Apt mirror + update
setup_apt_mirror
# 13. Network
wait_for_network
# 14. Done
emit_done_ok "{\"id\":\"${CT_ID}\",\"hostname\":\"${CT_HOSTNAME}\",\"ip\":\"${CT_ACTUAL_IP}\",\"password\":\"${CT_PASSWORD}\",\"memory\":\"${CT_MEMORY}\",\"swap\":\"${CT_SWAP}\",\"disk\":\"${CT_DISK}\",\"cores\":\"${CT_CORES}\",\"storage\":\"${CT_STORAGE}\",\"bridge\":\"${CT_BRIDGE}\",\"template\":\"${TEMPLATE}\"}"
}
main
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- proxmox.sh (navigator for Proxmox)
# =============================================================================
set -o pipefail
BACKTITLE="Strix Installer | Proxmox mode"
WT_H=16
WT_W=60
command -v whiptail &>/dev/null || { echo "whiptail required"; exit 1; }
# Dark theme for whiptail
export NEWT_COLORS='
root=,black
window=,black
border=white,black
textbox=white,black
button=black,white
actbutton=white,magenta
compactbutton=white,black
listbox=white,black
actlistbox=white,magenta
title=magenta,black
roottext=white,black
emptyscale=,black
fullscale=,magenta
helpline=white,black
'
# Parameters
INSTALL_MODE=""
FRIGATE_URL=""
GO2RTC_URL=""
STRIX_PORT="4567"
LXC_HOSTNAME="strix"
LXC_MEMORY="2048"
LXC_CORES="2"
LXC_DISK="32"
LXC_SWAP="512"
LXC_IP="dhcp"
LXC_GATEWAY=""
LXC_BRIDGE=""
LXC_STORAGE=""
# ---------------------------------------------------------------------------
# Simple flow
# ---------------------------------------------------------------------------
simple_flow() {
local step=1
while true; do
case $step in
1) # Mode
INSTALL_MODE=$(whiptail --backtitle "$BACKTITLE" --title " Install Mode " \
--menu "" $WT_H $WT_W 3 \
"1" "Strix only" \
"2" "Strix + Frigate" \
"3" "Advanced setup" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$INSTALL_MODE" in
1) INSTALL_MODE="strix"; step=2 ;;
2) INSTALL_MODE="strix-frigate"; step=3 ;;
3) advanced_flow; return ;;
esac
;;
2) # Frigate URL (strix only)
FRIGATE_URL=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):\n\nExample: http://192.168.1.100:5000" \
$WT_H $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$FRIGATE_URL" == "http://" || "$FRIGATE_URL" == "https://" ]] && FRIGATE_URL=""
step=3
;;
3) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
s+="\nLXC: auto (${LXC_HOSTNAME}, ${LXC_MEMORY}MB, ${LXC_CORES}cpu, ${LXC_DISK}GB)"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Advanced flow
# ---------------------------------------------------------------------------
advanced_flow() {
local step=1
INSTALL_MODE="${INSTALL_MODE:-strix}"
while true; do
case $step in
1) # Mode
local choice
choice=$(whiptail --backtitle "$BACKTITLE" --title " Mode " \
--menu "" $WT_H $WT_W 2 \
"strix" "Strix only" \
"strix-frigate" "Strix + Frigate" \
--default-item "$INSTALL_MODE" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
INSTALL_MODE="$choice"; step=2 ;;
2) # Port
local val
val=$(whiptail --backtitle "$BACKTITLE" --title " Port " \
--inputbox "Strix port:" 9 $WT_W "$STRIX_PORT" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_PORT="${val:-4567}"; step=3 ;;
3) # Frigate
val=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):" 9 $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
FRIGATE_URL="$val"; step=4 ;;
4) # go2rtc
val=$(whiptail --backtitle "$BACKTITLE" --title " go2rtc " \
--inputbox "go2rtc URL (empty to skip):" 9 $WT_W "${GO2RTC_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
GO2RTC_URL="$val"; step=5 ;;
5) # Hostname
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Hostname " \
--inputbox "Hostname:" 9 $WT_W "$LXC_HOSTNAME" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_HOSTNAME="${val:-strix}"; step=6 ;;
6) # RAM
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC RAM " \
--inputbox "RAM (MB):" 9 $WT_W "$LXC_MEMORY" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_MEMORY="${val:-2048}"; step=7 ;;
7) # CPU
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC CPU " \
--inputbox "CPU cores:" 9 $WT_W "$LXC_CORES" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_CORES="${val:-2}"; step=8 ;;
8) # Disk
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Disk " \
--inputbox "Disk (GB):" 9 $WT_W "$LXC_DISK" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_DISK="${val:-32}"; step=9 ;;
9) # Swap
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Swap " \
--inputbox "Swap (MB):" 9 $WT_W "$LXC_SWAP" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_SWAP="${val:-512}"; step=10 ;;
10) # IP
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Network " \
--inputbox "IP (dhcp or CIDR e.g. 10.0.20.110/24):" 9 $WT_W "$LXC_IP" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_IP="${val:-dhcp}"
[[ "$LXC_IP" != "dhcp" ]] && step=11 || step=12
;;
11) # Gateway
val=$(whiptail --backtitle "$BACKTITLE" --title " Gateway " \
--inputbox "Gateway:" 9 $WT_W "$LXC_GATEWAY" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_GATEWAY="$val"; step=12 ;;
12) # Bridge
val=$(whiptail --backtitle "$BACKTITLE" --title " Bridge " \
--inputbox "Network bridge (empty=auto):" 9 $WT_W "$LXC_BRIDGE" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_BRIDGE="$val"; step=13 ;;
13) # Storage
val=$(whiptail --backtitle "$BACKTITLE" --title " Storage " \
--inputbox "Storage (empty=auto):" 9 $WT_W "$LXC_STORAGE" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_STORAGE="$val"; step=14 ;;
14) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
[[ -n "$GO2RTC_URL" ]] && s+="go2rtc: ${GO2RTC_URL}\n"
s+="\nLXC:\n"
s+=" ${LXC_HOSTNAME} | ${LXC_MEMORY}MB | ${LXC_CORES}cpu | ${LXC_DISK}GB\n"
s+=" IP: ${LXC_IP}"
[[ -n "$LXC_GATEWAY" ]] && s+=" gw ${LXC_GATEWAY}"
s+="\n"
[[ -n "$LXC_BRIDGE" ]] && s+=" Bridge: ${LXC_BRIDGE}\n" || s+=" Bridge: auto\n"
[[ -n "$LXC_STORAGE" ]] && s+=" Storage: ${LXC_STORAGE}\n" || s+=" Storage: auto\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" 18 $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
C_RESET="\033[0m"
C_BOLD="\033[1m"
C_DIM="\033[2m"
C_GREEN="\033[32m"
C_RED="\033[31m"
C_YELLOW="\033[33m"
C_CYAN="\033[36m"
C_WHITE="\033[97m"
C_MAGENTA="\033[35m"
# ---------------------------------------------------------------------------
# Worker runner: streams JSON events and prints status lines
# ---------------------------------------------------------------------------
SCRIPTS_BASE="https://raw.githubusercontent.com/eduard256/Strix/main/scripts"
# Download a worker script to /tmp
download_worker() {
local name="$1"
local dest="/tmp/strix-${name}"
curl -fsSL "${SCRIPTS_BASE}/${name}" -o "$dest" 2>/dev/null
echo "$dest"
}
# Run a worker and display its JSON events as status lines
run_worker() {
local script="$1"
shift
local label="$1"
shift
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- ${label} ---${C_RESET}"
echo ""
bash "$script" "$@" 2>/dev/null | while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
done) ;; # handled after loop
esac
done
echo ""
}
# Extract a field from JSON done line
json_field() {
echo "$1" | grep -oP "\"$2\"\s*:\s*\"\K[^\"]*" | head -1
}
# ---------------------------------------------------------------------------
# Show final URLs
# ---------------------------------------------------------------------------
show_urls() {
local ip="$1"
local port="$2"
local mode="$3"
echo ""
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD} Installation Complete${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo ""
echo -e " ${C_WHITE}${C_BOLD}Strix:${C_RESET} ${C_CYAN}http://${ip}:${port}${C_RESET}"
if [[ "$mode" == "strix-frigate" ]]; then
echo -e " ${C_WHITE}${C_BOLD}Frigate:${C_RESET} ${C_CYAN}http://${ip}:8971${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}Frigate API:${C_RESET} ${C_CYAN}http://${ip}:5000${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}go2rtc:${C_RESET} ${C_CYAN}http://${ip}:1984${C_RESET}"
fi
echo ""
echo -e " ${C_DIM}Press Enter to exit${C_RESET}"
read -r
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
simple_flow
clear
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}STRIX INSTALLER${C_RESET}"
echo -e " ${C_DIM}Mode: ${INSTALL_MODE} | Port: ${STRIX_PORT}${C_RESET}"
echo ""
# Step 1: Create LXC container
echo -e " ${C_MAGENTA}${C_BOLD}--- Creating LXC Container ---${C_RESET}"
echo ""
lxc_script=$(download_worker "proxmox-lxc-create.sh")
lxc_args=""
[[ -n "$LXC_HOSTNAME" ]] && lxc_args="$lxc_args --hostname $LXC_HOSTNAME"
[[ -n "$LXC_MEMORY" ]] && lxc_args="$lxc_args --memory $LXC_MEMORY"
[[ -n "$LXC_CORES" ]] && lxc_args="$lxc_args --cores $LXC_CORES"
[[ -n "$LXC_DISK" ]] && lxc_args="$lxc_args --disk $LXC_DISK"
[[ -n "$LXC_SWAP" ]] && lxc_args="$lxc_args --swap $LXC_SWAP"
[[ -n "$LXC_BRIDGE" ]] && lxc_args="$lxc_args --bridge $LXC_BRIDGE"
[[ -n "$LXC_STORAGE" ]] && lxc_args="$lxc_args --storage $LXC_STORAGE"
[[ "$LXC_IP" != "dhcp" && -n "$LXC_IP" ]] && lxc_args="$lxc_args --ip $LXC_IP"
[[ -n "$LXC_GATEWAY" ]] && lxc_args="$lxc_args --gateway $LXC_GATEWAY"
# Run LXC creation and capture full output
lxc_output=$(bash "$lxc_script" $lxc_args 2>/dev/null)
lxc_done=$(echo "$lxc_output" | grep '"type":"done"')
# Display LXC creation events
echo "$lxc_output" | while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
# Check if LXC creation succeeded
lxc_ok=$(echo "$lxc_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$lxc_ok" != "true" ]]; then
echo ""
echo -e " ${C_RED}${C_BOLD}LXC creation failed. Aborting.${C_RESET}"
rm -f "$lxc_script"
exit 1
fi
# Extract LXC data
CT_ID=$(json_field "$lxc_done" "id")
CT_IP=$(json_field "$lxc_done" "ip")
CT_PASS=$(json_field "$lxc_done" "password")
echo ""
echo -e " ${C_GREEN}${C_BOLD}LXC ${CT_ID} ready${C_RESET} -- IP: ${C_WHITE}${CT_IP}${C_RESET}"
# Step 2: Run prepare.sh inside LXC (install Docker)
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Installing Docker ---${C_RESET}"
echo ""
prepare_script=$(download_worker "prepare.sh")
pct push "$CT_ID" "$prepare_script" /tmp/prepare.sh &>/dev/null
pct exec "$CT_ID" -- bash /tmp/prepare.sh 2>/dev/null | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
# Step 3: Deploy Strix (or Strix + Frigate)
if [[ "$INSTALL_MODE" == "strix-frigate" ]]; then
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix + Frigate ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix-frigate.sh")
pct push "$CT_ID" "$deploy_script" /tmp/deploy.sh &>/dev/null
deploy_args="--port $STRIX_PORT"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
deploy_output=$(pct exec "$CT_ID" -- bash /tmp/deploy.sh $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
else
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix.sh")
pct push "$CT_ID" "$deploy_script" /tmp/deploy.sh &>/dev/null
deploy_args="--port $STRIX_PORT"
[[ -n "$FRIGATE_URL" ]] && deploy_args="$deploy_args --frigate-url $FRIGATE_URL"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
deploy_output=$(pct exec "$CT_ID" -- bash /tmp/deploy.sh $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
fi
# Show final URLs
deploy_ok=$(echo "$deploy_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$deploy_ok" == "true" ]]; then
show_urls "$CT_IP" "$STRIX_PORT" "$INSTALL_MODE"
else
echo ""
echo -e " ${C_RED}${C_BOLD}Deployment failed.${C_RESET}"
echo -e " ${C_DIM}LXC ${CT_ID} (${CT_IP}) is still running. Check logs inside.${C_RESET}"
echo ""
fi
# Cleanup
rm -f "$lxc_script" "$prepare_script" "$deploy_script" 2>/dev/null
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- strix-frigate.sh (worker)
#
# Deploys Strix + Frigate together via Docker Compose.
# Generates docker-compose.yml dynamically (devices depend on hardware),
# creates .env, pulls images, starts containers, runs healthchecks.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --port PORT Strix listen port (default: 4567)
# --tag TAG Strix image tag (default: latest)
# --log-level LEVEL Log level: debug, info, warn, error, trace
# --go2rtc-url URL External go2rtc URL
# --shm-size SIZE Frigate shm_size (default: 512mb)
# --frigate-tag TAG Frigate image tag (default: stable)
# --dir DIR Working directory (default: /opt/strix)
#
# Usage:
# bash scripts/strix-frigate.sh
# bash scripts/strix-frigate.sh --port 4567 --frigate-tag stable-tensorrt
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
STRIX_PORT="4567"
STRIX_TAG="latest"
STRIX_LOG_LEVEL=""
STRIX_GO2RTC_URL=""
FRIGATE_SHM="512mb"
FRIGATE_TAG="stable"
STRIX_DIR="/opt/strix"
STRIX_IMAGE="eduard256/strix"
FRIGATE_IMAGE="ghcr.io/blakeblackshear/frigate"
# Detected devices (populated by detect_devices)
DEVICES=()
DEVICE_NAMES=()
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--port) STRIX_PORT="$2"; shift 2 ;;
--tag) STRIX_TAG="$2"; shift 2 ;;
--log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;;
--go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;;
--shm-size) FRIGATE_SHM="$2"; shift 2 ;;
--frigate-tag) FRIGATE_TAG="$2"; shift 2 ;;
--dir) STRIX_DIR="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done_ok() {
# Accepts raw JSON data string
local data="$1"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
}
emit_done_fail() {
local error="$1"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
}
# ---------------------------------------------------------------------------
# Detect LAN IP
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# 1. Working directory
# ---------------------------------------------------------------------------
setup_dir() {
emit "check" "Checking working directory ${STRIX_DIR}"
if [[ -d "$STRIX_DIR" ]]; then
emit "ok" "Directory exists: ${STRIX_DIR}"
else
emit "install" "Creating directory ${STRIX_DIR}"
if mkdir -p "$STRIX_DIR" 2>/dev/null; then
emit "ok" "Directory created: ${STRIX_DIR}"
else
emit "error" "Failed to create directory ${STRIX_DIR}"
emit_done_fail "Cannot create ${STRIX_DIR}"
fi
fi
# Frigate subdirectories
emit "check" "Checking Frigate directories"
mkdir -p "${STRIX_DIR}/frigate/config" 2>/dev/null
mkdir -p "${STRIX_DIR}/frigate/storage" 2>/dev/null
if [[ -d "${STRIX_DIR}/frigate/config" ]] && [[ -d "${STRIX_DIR}/frigate/storage" ]]; then
emit "ok" "Frigate directories ready"
else
emit "error" "Failed to create Frigate directories"
emit_done_fail "Cannot create Frigate directories"
fi
}
# ---------------------------------------------------------------------------
# 2. Detect hardware devices
# ---------------------------------------------------------------------------
detect_devices() {
emit "check" "Detecting hardware accelerators"
local found=0
# USB Coral
emit "check" "Checking for USB Coral"
if command -v lsusb &>/dev/null && lsusb 2>/dev/null | grep -qE "1a6e:089a|18d1:9302"; then
DEVICES+=("/dev/bus/usb:/dev/bus/usb")
DEVICE_NAMES+=("usb_coral")
emit "ok" "USB Coral detected" "{\"device\":\"usb_coral\",\"path\":\"/dev/bus/usb\"}"
found=$((found + 1))
else
emit "miss" "USB Coral not found"
fi
# PCIe Coral
emit "check" "Checking for PCIe Coral"
if [[ -e /dev/apex_0 ]]; then
DEVICES+=("/dev/apex_0:/dev/apex_0")
DEVICE_NAMES+=("pcie_coral")
emit "ok" "PCIe Coral detected" "{\"device\":\"pcie_coral\",\"path\":\"/dev/apex_0\"}"
found=$((found + 1))
else
emit "miss" "PCIe Coral not found"
fi
# Intel / AMD GPU
emit "check" "Checking for Intel/AMD GPU"
if [[ -e /dev/dri/renderD128 ]]; then
DEVICES+=("/dev/dri:/dev/dri")
DEVICE_NAMES+=("gpu")
emit "ok" "GPU detected (Intel/AMD)" "{\"device\":\"gpu\",\"path\":\"/dev/dri\"}"
found=$((found + 1))
else
emit "miss" "Intel/AMD GPU not found"
fi
# Intel NPU
emit "check" "Checking for Intel NPU"
if [[ -e /dev/accel ]]; then
DEVICES+=("/dev/accel:/dev/accel")
DEVICE_NAMES+=("intel_npu")
emit "ok" "Intel NPU detected" "{\"device\":\"intel_npu\",\"path\":\"/dev/accel\"}"
found=$((found + 1))
else
emit "miss" "Intel NPU not found"
fi
# Raspberry Pi 4 video
emit "check" "Checking for Raspberry Pi video device"
if [[ -e /dev/video11 ]]; then
DEVICES+=("/dev/video11:/dev/video11")
DEVICE_NAMES+=("rpi_video")
emit "ok" "Raspberry Pi video device detected" "{\"device\":\"rpi_video\",\"path\":\"/dev/video11\"}"
found=$((found + 1))
else
emit "miss" "Raspberry Pi video device not found"
fi
if [[ "$found" -eq 0 ]]; then
emit "ok" "No hardware accelerators found, using CPU only"
else
emit "ok" "${found} hardware accelerator(s) detected"
fi
}
# ---------------------------------------------------------------------------
# 3. Generate docker-compose.yml
# ---------------------------------------------------------------------------
generate_compose() {
emit "check" "Generating docker-compose.yml"
# Build devices section
local devices_block=""
if [[ ${#DEVICES[@]} -gt 0 ]]; then
devices_block=" devices:"
for dev in "${DEVICES[@]}"; do
devices_block="${devices_block}
- ${dev}"
done
fi
# Build compose file
cat > "${STRIX_DIR}/docker-compose.yml" <<EOF
# Strix + Frigate
# Generated by strix-frigate.sh
services:
strix:
container_name: strix
image: ${STRIX_IMAGE}:\${STRIX_TAG:-latest}
network_mode: host
restart: unless-stopped
env_file: .env
depends_on:
frigate:
condition: service_started
frigate:
container_name: frigate
image: ${FRIGATE_IMAGE}:${FRIGATE_TAG}
privileged: true
network_mode: host
restart: unless-stopped
stop_grace_period: 30s
shm_size: "${FRIGATE_SHM}"
${devices_block}
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate/config:/config
- ./frigate/storage:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
FRIGATE_RTSP_PASSWORD: "password"
EOF
emit "ok" "docker-compose.yml generated" "{\"frigate_tag\":\"${FRIGATE_TAG}\",\"shm_size\":\"${FRIGATE_SHM}\"}"
}
# ---------------------------------------------------------------------------
# 4. Generate .env
# ---------------------------------------------------------------------------
generate_env() {
emit "check" "Generating .env configuration"
cat > "${STRIX_DIR}/.env" <<EOF
# Strix configuration -- generated by strix-frigate.sh
STRIX_TAG=${STRIX_TAG}
STRIX_LISTEN=:${STRIX_PORT}
STRIX_FRIGATE_URL=http://localhost:5000
EOF
emit "ok" "Frigate URL: http://localhost:5000 (internal API)"
if [[ -n "$STRIX_GO2RTC_URL" ]]; then
echo "STRIX_GO2RTC_URL=${STRIX_GO2RTC_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}"
fi
if [[ -n "$STRIX_LOG_LEVEL" ]]; then
echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env"
emit "ok" "Log level: ${STRIX_LOG_LEVEL}"
fi
emit "ok" ".env generated (port ${STRIX_PORT})"
}
# ---------------------------------------------------------------------------
# 5. Pull images
# ---------------------------------------------------------------------------
pull_images() {
emit "check" "Pulling Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG} (this may take a while)"
if docker pull "${FRIGATE_IMAGE}:${FRIGATE_TAG}" &>/dev/null; then
emit "ok" "Frigate image pulled: ${FRIGATE_TAG}"
else
emit "error" "Failed to pull Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG}"
emit_done_fail "Frigate image pull failed"
fi
emit "check" "Pulling Strix image ${STRIX_IMAGE}:${STRIX_TAG}"
if docker pull "${STRIX_IMAGE}:${STRIX_TAG}" &>/dev/null; then
emit "ok" "Strix image pulled: ${STRIX_TAG}"
else
emit "error" "Failed to pull Strix image ${STRIX_IMAGE}:${STRIX_TAG}"
emit_done_fail "Strix image pull failed"
fi
}
# ---------------------------------------------------------------------------
# 6. Start containers
# ---------------------------------------------------------------------------
start_containers() {
local running_frigate=false
local running_strix=false
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^frigate$' && running_frigate=true
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$' && running_strix=true
if [[ "$running_frigate" == true ]] || [[ "$running_strix" == true ]]; then
emit "check" "Existing containers found, recreating"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d --force-recreate &>/dev/null; then
emit "ok" "Containers recreated"
else
emit "error" "Failed to recreate containers"
emit_done_fail "Container recreate failed"
fi
else
emit "install" "Starting Frigate and Strix containers"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d &>/dev/null; then
emit "ok" "Containers started"
else
emit "error" "Failed to start containers"
emit_done_fail "Container start failed"
fi
fi
}
# ---------------------------------------------------------------------------
# 7. Healthchecks
# ---------------------------------------------------------------------------
healthcheck_frigate() {
emit "check" "Waiting for Frigate to respond on port 5000"
local retries=30
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then
emit "ok" "Frigate is running on port 5000"
return 0
fi
sleep 2
done
emit "error" "Frigate healthcheck failed after ${retries} attempts"
emit_done_fail "Frigate healthcheck failed"
}
healthcheck_strix() {
emit "check" "Waiting for Strix to respond on port ${STRIX_PORT}"
local retries=15
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then
local version
version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\"}"
return 0
fi
sleep 1
done
emit "error" "Strix healthcheck failed after ${retries} attempts"
emit_done_fail "Strix healthcheck failed"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Working directory
setup_dir
# 2. Detect hardware
detect_devices
# 3. Generate compose (with detected devices)
generate_compose
# 4. Generate .env
generate_env
# 5. Pull images
pull_images
# 6. Start containers
start_containers
# 7. Healthchecks
healthcheck_frigate
healthcheck_strix
# 8. Done -- all URLs
local lan_ip
lan_ip=$(detect_lan_ip)
local strix_version
strix_version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
# Build device names JSON array
local devices_json="["
local first=true
for name in "${DEVICE_NAMES[@]}"; do
[[ "$first" == true ]] && first=false || devices_json="${devices_json},"
devices_json="${devices_json}\"${name}\""
done
devices_json="${devices_json}]"
emit_done_ok "{\"ip\":\"${lan_ip}\",\"strix_url\":\"http://${lan_ip}:${STRIX_PORT}\",\"strix_version\":\"${strix_version}\",\"frigate_url\":\"http://${lan_ip}:8971\",\"frigate_internal\":\"http://${lan_ip}:5000\",\"go2rtc_url\":\"http://${lan_ip}:1984\",\"frigate_tag\":\"${FRIGATE_TAG}\",\"port\":\"${STRIX_PORT}\",\"devices\":${devices_json}}"
}
main
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- strix.sh (worker)
#
# Deploys Strix container via Docker Compose.
# Downloads docker-compose.yml from GitHub (if not already present),
# generates .env from parameters, pulls image, starts container, healthchecks.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Field "msg" is always human-readable.
# - Field "data" is optional, carries machine-readable details.
# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --port PORT Strix listen port (default: 4567)
# --frigate-url URL Frigate URL, e.g. http://192.168.1.50:5000
# --go2rtc-url URL go2rtc URL, e.g. http://192.168.1.50:1984
# --log-level LEVEL Log level: debug, info, warn, error, trace (default: info)
# --tag TAG Docker image tag (default: latest)
# --dir DIR Working directory (default: /opt/strix)
#
# Usage:
# bash scripts/strix.sh
# bash scripts/strix.sh --port 4567 --frigate-url http://192.168.1.50:5000
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
STRIX_PORT="4567"
STRIX_FRIGATE_URL=""
STRIX_GO2RTC_URL=""
STRIX_LOG_LEVEL=""
STRIX_TAG="latest"
STRIX_DIR="/opt/strix"
COMPOSE_URL="https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml"
IMAGE="eduard256/strix"
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--port) STRIX_PORT="$2"; shift 2 ;;
--frigate-url) STRIX_FRIGATE_URL="$2"; shift 2 ;;
--go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;;
--log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;;
--tag) STRIX_TAG="$2"; shift 2 ;;
--dir) STRIX_DIR="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers (same protocol as prepare.sh)
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done() {
local ok="$1"
shift
if [[ "$ok" == "true" ]]; then
# Remaining args are key:value pairs for data
local data="{"
local first=true
while [[ $# -ge 2 ]]; do
local key="$1" val="$2"; shift 2
val="${val//\\/\\\\}"
val="${val//\"/\\\"}"
[[ "$first" == true ]] && first=false || data="${data},"
data="${data}\"${key}\":\"${val}\""
done
data="${data}}"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
else
local error="${1:-unknown}"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
fi
}
# ---------------------------------------------------------------------------
# Detect LAN IP
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# Working directory
# ---------------------------------------------------------------------------
setup_dir() {
emit "check" "Checking working directory ${STRIX_DIR}"
if [[ -d "$STRIX_DIR" ]]; then
emit "ok" "Directory exists: ${STRIX_DIR}"
else
emit "install" "Creating directory ${STRIX_DIR}"
if mkdir -p "$STRIX_DIR" 2>/dev/null; then
emit "ok" "Directory created: ${STRIX_DIR}"
else
emit "error" "Failed to create directory ${STRIX_DIR}"
emit_done "false" "Cannot create ${STRIX_DIR}"
fi
fi
}
# ---------------------------------------------------------------------------
# Download docker-compose.yml
# ---------------------------------------------------------------------------
download_compose() {
emit "check" "Checking docker-compose.yml"
if [[ -f "${STRIX_DIR}/docker-compose.yml" ]]; then
emit "ok" "docker-compose.yml already exists"
return
fi
emit "install" "Downloading docker-compose.yml from GitHub"
if curl -fsSL "$COMPOSE_URL" -o "${STRIX_DIR}/docker-compose.yml" 2>/dev/null; then
emit "ok" "docker-compose.yml downloaded"
else
emit "error" "Failed to download docker-compose.yml"
emit_done "false" "docker-compose.yml download failed"
fi
}
# ---------------------------------------------------------------------------
# Generate .env
# ---------------------------------------------------------------------------
generate_env() {
emit "check" "Generating .env configuration"
cat > "${STRIX_DIR}/.env" <<EOF
# Strix configuration -- generated by strix.sh
STRIX_LISTEN=:${STRIX_PORT}
EOF
if [[ -n "$STRIX_FRIGATE_URL" ]]; then
echo "STRIX_FRIGATE_URL=${STRIX_FRIGATE_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "Frigate URL: ${STRIX_FRIGATE_URL}" "{\"frigate_url\":\"${STRIX_FRIGATE_URL}\"}"
fi
if [[ -n "$STRIX_GO2RTC_URL" ]]; then
echo "STRIX_GO2RTC_URL=${STRIX_GO2RTC_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}" "{\"go2rtc_url\":\"${STRIX_GO2RTC_URL}\"}"
fi
if [[ -n "$STRIX_LOG_LEVEL" ]]; then
echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env"
emit "ok" "Log level: ${STRIX_LOG_LEVEL}"
fi
emit "ok" ".env generated (port ${STRIX_PORT})" "{\"port\":\"${STRIX_PORT}\"}"
}
# ---------------------------------------------------------------------------
# Pull image
# ---------------------------------------------------------------------------
pull_image() {
emit "check" "Pulling image ${IMAGE}:${STRIX_TAG}"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" pull &>/dev/null; then
emit "ok" "Image pulled: ${IMAGE}:${STRIX_TAG}"
else
emit "error" "Failed to pull image ${IMAGE}:${STRIX_TAG}"
emit_done "false" "Image pull failed"
fi
}
# ---------------------------------------------------------------------------
# Start container
# ---------------------------------------------------------------------------
start_container() {
# Check if strix container is already running
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$'; then
emit "check" "Strix container is running, recreating"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d --force-recreate &>/dev/null; then
emit "ok" "Container recreated"
else
emit "error" "Failed to recreate container"
emit_done "false" "Container recreate failed"
fi
else
emit "install" "Starting Strix container"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d &>/dev/null; then
emit "ok" "Container started"
else
emit "error" "Failed to start container"
emit_done "false" "Container start failed"
fi
fi
}
# ---------------------------------------------------------------------------
# Healthcheck
# ---------------------------------------------------------------------------
healthcheck() {
emit "check" "Waiting for Strix to respond"
local retries=15
local i
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then
local version
version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\",\"port\":\"${STRIX_PORT}\"}"
return 0
fi
sleep 1
done
emit "error" "Healthcheck failed after ${retries} attempts"
emit_done "false" "Healthcheck failed"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Working directory
setup_dir
# 2. Download compose file (if not present)
download_compose
# 3. Generate .env from parameters
generate_env
# 4. Pull image
pull_image
# 5. Start / recreate container
start_container
# 6. Healthcheck
healthcheck
# 7. Done -- include URL for navigator
local lan_ip
lan_ip=$(detect_lan_ip)
local url="http://${lan_ip}:${STRIX_PORT}"
emit_done "true" "url" "$url" "version" "$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")" "port" "$STRIX_PORT" "ip" "$lan_ip"
}
main
+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) --
+6 -5
View File
@@ -328,8 +328,9 @@
// Pre-populate custom streams from "url" query parameter (supports multiple)
params.getAll('url').forEach(function(u) {
if (!u || typeof u !== 'string') return;
u = u.trim();
if (u && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
if (u && u !== 'undefined' && u !== 'null' && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
customStreams.push(u);
}
});
@@ -395,7 +396,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
addInput.value = pendingInput;
addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
@@ -404,7 +405,7 @@
function addCustom() {
var v = addInput.value.trim();
if (!v) return;
if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) {
showToast('URL must include protocol (rtsp://, http://, bubble://, ...)');
return;
@@ -592,7 +593,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
addInput.value = pendingInput;
addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.type = 'button';
@@ -600,7 +601,7 @@
function addCustom() {
var v = addInput.value.trim();
if (!v) return;
if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) { showToast('URL must include protocol (rtsp://, http://, bubble://, ...)'); return; }
if (customStreams.indexOf(v) !== -1 || dbStreams.indexOf(v) !== -1) { showToast('This URL is already in the list'); return; }
customStreams.push(v);
File diff suppressed because it is too large Load Diff
+404 -171
View File
@@ -4,7 +4,7 @@
<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 - HomeKit Device</title>
<title>Strix - HomeKit Pairing</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -21,6 +21,8 @@
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--error: #ef4444;
--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;
@@ -40,15 +42,31 @@
}
.screen {
min-height: 100vh; padding: 1.5rem;
display: flex; align-items: flex-start; justify-content: center;
min-height: 100vh;
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-base);
}
.container { max-width: 520px; width: 100%; margin-top: 6vh; }
.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 {
@@ -56,203 +74,255 @@
background: none; border: none;
color: var(--text-secondary); font-size: 0.875rem;
font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0; margin-bottom: 2rem;
padding: 0.5rem 0;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.hero { text-align: center; margin-bottom: 2.5rem; }
.card-icon {
width: 48px; height: 48px;
margin: 0 auto 1.25rem;
color: var(--purple-light);
}
.card-title {
.title {
font-size: 1.25rem; font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: 0.03em;
color: var(--text-primary);
}
.card-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 6px;
font-size: 0.75rem; font-weight: 600;
color: var(--purple-light);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1.5rem;
.homekit-logo {
width: 72px; height: 72px;
margin: 0 auto;
filter: drop-shadow(0 4px 16px rgba(255, 171, 31, 0.3));
}
.card-text {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.25rem;
text-align: left;
}
.form-group { margin-bottom: 1.5rem; }
.card-text strong { color: var(--text-primary); }
.device-info {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: left;
}
.device-row {
display: flex; justify-content: space-between;
padding: 0.375rem 0;
font-size: 0.8125rem;
}
.device-row:not(:last-child) {
border-bottom: 1px solid rgba(139, 92, 246, 0.07);
}
.device-label { color: var(--text-tertiary); }
.device-value { color: var(--text-primary); font-family: var(--font-mono); font-size: 0.75rem; }
.contact-links {
display: flex; flex-direction: column; gap: 0.5rem;
margin-bottom: 1.5rem;
text-align: left;
}
.contact-link {
.label {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--purple-light);
text-decoration: none;
font-size: 0.875rem;
transition: all var(--transition-fast);
font-size: 0.875rem; font-weight: 500;
color: var(--text-secondary); margin-bottom: 0.75rem;
}
.contact-link:hover {
border-color: var(--purple-primary);
background: rgba(139, 92, 246, 0.08);
/* 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);
}
.contact-link svg { width: 18px; height: 18px; flex-shrink: 0; }
.info-icon:hover { color: var(--purple-primary); }
.info-icon svg { width: 16px; height: 16px; }
.divider {
display: flex; align-items: center; gap: 1rem;
margin: 1.5rem 0;
.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: 320px; 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;
}
.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); }
/* PIN input */
.pin-row {
display: flex; align-items: center; justify-content: center;
gap: 0;
}
.pin-group { display: flex; gap: 0.375rem; }
.pin-separator {
font-size: 1.5rem; font-weight: 300;
color: var(--text-tertiary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0 0.5rem;
line-height: 1;
user-select: none;
}
.divider::before, .divider::after {
content: ''; flex: 1; height: 1px;
background: var(--border-color);
.pin-digit {
width: 57px; height: 69px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 1.375rem; font-weight: 600;
text-align: center;
outline: none;
transition: all var(--transition-fast);
caret-color: var(--purple-primary);
-moz-appearance: textfield;
}
.pin-digit::-webkit-inner-spin-button,
.pin-digit::-webkit-outer-spin-button {
-webkit-appearance: none; margin: 0;
}
.pin-digit:focus {
border-color: var(--purple-primary);
box-shadow: 0 0 0 3px var(--purple-glow);
}
.pin-digit.filled {
border-color: var(--purple-light);
background: rgba(139, 92, 246, 0.06);
}
.pin-digit.error {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
animation: shake 0.4s ease;
}
.pin-digit.success {
border-color: var(--success);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
@media (max-width: 400px) {
.pin-digit { width: 36px; height: 48px; font-size: 1.125rem; }
.pin-separator { padding: 0 0.25rem; font-size: 1.25rem; }
.pin-group { gap: 0.25rem; }
}
/* Error */
.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.5rem;
display: none;
animation: fadeIn var(--transition-fast);
}
.error-box.visible { display: block; }
/* Buttons */
.btn {
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);
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
border: none; outline: none; width: 100%;
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 {
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--purple-glow-strong);
}
.btn-primary:active { transform: translateY(0); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-large { width: 100%; padding: 1.5rem; font-size: 1.125rem; }
.btn-outline {
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);
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border-color);
border: 1px solid var(--border-color); width: 100%;
margin-top: 0.75rem;
}
.btn-outline:hover { border-color: var(--purple-primary); color: var(--purple-light); }
/* Pairing spinner */
.pairing-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;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
</style>
</head>
<body>
<div class="screen">
<div class="container">
<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="card">
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
<div class="screen">
<div class="container">
<div class="hero">
<svg class="homekit-logo" viewBox="100 120 824 780" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#FA9012" d="M883.2,413.1l-70.4-55.6l0,0V231.1c0-8.6-3.4-11-9.5-11h-64.4c-7,0-11.3,1.4-11.3,11v59.1l0,0 C634.5,216.7,533.6,137,529.8,134c-7.6-6-12.3-7.6-17.8-7.6c-5.4,0-10.1,1.6-17.8,7.6c-7.6,6-343.2,271.1-353.4,279.1 c-12.4,9.8-8.9,23.9,4.9,23.9h65.5v355.6c0,23,9.2,32.2,31.1,32.2h539.4c21.9,0,31.1-9.2,31.1-32.2V436.9h65.5 C892.1,436.9,895.6,422.9,883.2,413.1z M757.6,742.6c0,15.9-8.2,26.9-24.8,26.9H291.1c-16.6,0-24.8-11-24.8-26.9V410.3 c0-19.3,8.4-31.6,18.1-39.2l212.4-167.7c5.6-4.4,10.4-6.3,15.1-6.3s9.5,1.9,15.1,6.4l212.4,167.7c9.6,7.6,18.1,19.9,18.1,39.2 V742.6z"/>
<path fill="#FFAB1F" d="M739.6,371.1L527.1,203.3c-5.6-4.4-10.6-6.3-15.1-6.3c-4.6,0-9.5,1.9-15.1,6.4L284.4,371.1 c-9.6,7.6-18.1,19.9-18.1,39.2v332.3c0,15.9,8.2,26.9,24.8,26.9h441.7c16.6,0,24.8-11,24.8-26.9V410.3 C757.6,391,749.2,378.7,739.6,371.1z M702.6,692.7c0,14.8-8.4,21.7-20.7,21.7H342.2c-12.3,0-20.7-6.9-20.7-21.7V433.2 c0-14.4,3.4-22.6,13.6-30.7c5.8-4.6,160.3-126.6,164.4-129.8c4.1-3.3,8.5-4.9,12.5-4.9c4,0,8.4,1.7,12.5,4.9 c4.1,3.3,158.6,125.3,164.4,129.8c10.2,8.1,13.6,16.4,13.6,30.7L702.6,692.7z"/>
<path fill="#FFBE41" d="M688.9,402.5c-5.8-4.5-160.3-126.6-164.4-129.8c-4.1-3.3-8.5-4.9-12.5-4.9c-4,0-8.4,1.7-12.5,4.9 c-4.1,3.3-158.6,125.3-164.4,129.8c-10.2,8.1-13.6,16.4-13.6,30.7v259.5c0,14.8,8.4,21.7,20.7,21.7h339.7 c12.3,0,20.7-6.9,20.7-21.7V433.2C702.5,418.9,699.1,410.6,688.9,402.5z M647.4,642.8c0,11.9-6.6,16.5-15.6,16.5H392.2 c-9,0-15.6-4.6-15.6-16.5V456.2c0-8.3,0-14.9,9.1-22.2c6-4.8,113.2-89.4,116.4-91.9s6.4-3.8,9.9-3.8c3.6,0.1,7.1,1.5,9.9,3.8 c3.2,2.5,110.4,87.1,116.4,91.9c9.1,7.3,9.1,13.9,9.1,22.2L647.4,642.8z"/>
<path fill="#FFD260" d="M638.3,434c-6-4.8-113.2-89.4-116.4-91.9c-2.8-2.4-6.3-3.7-9.9-3.8c-3.5,0-6.7,1.3-9.9,3.8 S391.6,429.2,385.7,434c-9.1,7.3-9.1,13.9-9.1,22.2v186.6c0,11.9,6.6,16.5,15.6,16.5h239.5c9,0,15.6-4.6,15.6-16.5V456.2 C647.4,447.8,647.4,441.2,638.3,434z M592.3,593c0,9.2-4.6,11.2-11,11.2H442.8c-6.4,0-11-2.1-11-11.2V479.1 c0-6.4,2.9-12.6,7.8-16.6c2.8-2.3,63-49.4,65.1-51.1c4.2-3.5,10.4-3.5,14.6,0c2.2,1.7,62.3,48.8,65.1,51.1 c5,4.1,7.9,10.2,7.8,16.6L592.3,593z"/>
<path fill="#FFE780" d="M512,604.1h69.2c6.4,0,11-2.1,11-11.2V479.1c0-6.4-2.9-12.6-7.8-16.6c-2.8-2.3-63-49.4-65.1-51.1 c-4.2-3.5-10.4-3.5-14.6,0c-2.1,1.7-62.3,48.8-65.1,51.1c-5,4.1-7.9,10.2-7.8,16.6v113.8c0,9.2,4.6,11.2,11,11.2L512,604.1z"/>
</svg>
<h2 class="card-title">HomeKit Device Detected</h2>
<div class="card-badge">Apple HomeKit</div>
<div id="device-info" class="device-info"></div>
<p class="card-text">
We are working on adding <strong>Apple HomeKit camera support</strong> to Strix, but we don't have HomeKit cameras available for testing.
</p>
<p class="card-text">
The device at this IP supports HomeKit protocol. If you'd like to help us add support for HomeKit cameras, please reach out. Your contribution would be greatly appreciated.
</p>
<div class="contact-links">
<a class="contact-link" href="mailto:ceo@webaweba.com">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 4l-10 8L2 4"/>
</svg>
ceo@webaweba.com
</a>
<a class="contact-link" href="https://github.com/eduard256/Strix/issues/new/choose" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
Create GitHub Issue
</a>
<h1 class="title">Apple HomeKit</h1>
</div>
<div class="divider">or</div>
<button class="btn btn-primary" id="btn-standard">
Try Standard Discovery
</button>
<button class="btn btn-outline" id="btn-skip">
Back to Home
</button>
<div class="form-group">
<label class="label">
Pairing 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">HomeKit Pairing Code</div>
<p class="tooltip-text">The 8-digit code is usually printed on a label on the camera itself, on the packaging, or in the setup manual. It looks like XXX-XX-XXX.</p>
</div>
</span>
</label>
<div class="pin-row" id="pin-row">
<div class="pin-group" id="group-0"></div>
<span class="pin-separator">-</span>
<div class="pin-group" id="group-1"></div>
<span class="pin-separator">-</span>
<div class="pin-group" id="group-2"></div>
</div>
</div>
<div class="error-box" id="error-box"></div>
<button class="btn btn-primary btn-large" id="btn-pair" disabled>Pair Device</button>
<button class="btn-outline" id="btn-standard">Skip, use Standard Discovery</button>
</div>
</div>
<script>
var params = new URLSearchParams(location.search);
// all probe data passed through
var ip = params.get('ip') || '';
var ports = params.get('ports') || '';
var mac = params.get('mac') || '';
@@ -263,49 +333,209 @@
var mdnsName = params.get('mdns_name') || '';
var mdnsModel = params.get('mdns_model') || '';
var mdnsCategory = params.get('mdns_category') || '';
var mdnsPort = params.get('mdns_port') || '';
var mdnsPaired = params.get('mdns_paired') || '';
var mdnsDeviceId = params.get('mdns_device_id') || '';
// render device info
var infoDiv = document.getElementById('device-info');
var rows = [];
if (ip) rows.push(['IP Address', ip]);
if (mdnsName) rows.push(['Device Name', mdnsName]);
if (mdnsModel) rows.push(['Model', mdnsModel]);
if (mdnsCategory) rows.push(['Category', mdnsCategory]);
if (vendor) rows.push(['Vendor', vendor]);
if (mac) rows.push(['MAC', mac]);
if (hostname) rows.push(['Hostname', hostname]);
if (latency) rows.push(['Latency', latency + 'ms']);
if (ports) rows.push(['Open Ports', ports]);
// PIN input -- 8 digits: 3-2-3
var pinGroups = [3, 2, 3];
var inputs = [];
rows.forEach(function(r) {
var row = document.createElement('div');
row.className = 'device-row';
var label = document.createElement('span');
label.className = 'device-label';
label.textContent = r[0];
var value = document.createElement('span');
value.className = 'device-value';
value.textContent = r[1];
row.appendChild(label);
row.appendChild(value);
infoDiv.appendChild(row);
pinGroups.forEach(function(count, gi) {
var group = document.getElementById('group-' + gi);
for (var i = 0; i < count; i++) {
var input = document.createElement('input');
input.type = 'text';
input.inputMode = 'numeric';
input.pattern = '[0-9]';
input.maxLength = 1;
input.className = 'pin-digit';
input.autocomplete = 'off';
group.appendChild(input);
inputs.push(input);
}
});
if (rows.length === 0) infoDiv.style.display = 'none';
inputs.forEach(function(input, idx) {
input.addEventListener('input', function(e) {
var v = input.value.replace(/\D/g, '');
input.value = v ? v[0] : '';
// back
hideError();
if (v && idx < inputs.length - 1) {
inputs[idx + 1].focus();
}
updateState();
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Backspace') {
if (input.value === '' && idx > 0) {
inputs[idx - 1].focus();
inputs[idx - 1].value = '';
updateState();
e.preventDefault();
}
}
if (e.key === 'ArrowLeft' && idx > 0) {
inputs[idx - 1].focus();
e.preventDefault();
}
if (e.key === 'ArrowRight' && idx < inputs.length - 1) {
inputs[idx + 1].focus();
e.preventDefault();
}
if (e.key === 'Enter') {
doPair();
}
});
input.addEventListener('focus', function() {
input.select();
});
// paste support -- distribute digits across fields
input.addEventListener('paste', function(e) {
e.preventDefault();
var text = (e.clipboardData || window.clipboardData).getData('text');
var digits = text.replace(/\D/g, '');
for (var i = 0; i < digits.length && idx + i < inputs.length; i++) {
inputs[idx + i].value = digits[i];
}
var next = Math.min(idx + digits.length, inputs.length - 1);
inputs[next].focus();
updateState();
});
});
function getPin() {
var pin = '';
for (var i = 0; i < inputs.length; i++) {
pin += inputs[i].value;
}
return pin;
}
function updateState() {
var pin = getPin();
document.getElementById('btn-pair').disabled = pin.length !== 8;
inputs.forEach(function(input) {
if (input.value) {
input.classList.add('filled');
} else {
input.classList.remove('filled');
}
input.classList.remove('error');
input.classList.remove('success');
});
}
function showError(msg) {
var el = document.getElementById('error-box');
el.textContent = msg;
el.classList.add('visible');
inputs.forEach(function(input) { input.classList.add('error'); });
}
function hideError() {
document.getElementById('error-box').classList.remove('visible');
}
function showSuccess() {
inputs.forEach(function(input) {
input.classList.remove('error');
input.classList.add('success');
});
}
// pair
var btnPair = document.getElementById('btn-pair');
btnPair.addEventListener('click', doPair);
async function doPair() {
var pin = getPin();
if (pin.length !== 8) return;
var port = parseInt(mdnsPort, 10);
if (!ip || !port || !mdnsDeviceId) {
showError('Missing device information. Go back and re-probe.');
return;
}
hideError();
btnPair.disabled = true;
var origText = btnPair.textContent;
btnPair.textContent = '';
var spinner = document.createElement('div');
spinner.className = 'pairing-spinner';
btnPair.appendChild(spinner);
var label = document.createTextNode(' Pairing...');
btnPair.appendChild(label);
inputs.forEach(function(input) { input.disabled = true; });
try {
var r = await fetch('api/homekit/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip: ip,
port: port,
device_id: mdnsDeviceId,
pin: pin
})
});
if (!r.ok) {
var text = await r.text();
inputs.forEach(function(input) { input.disabled = false; });
btnPair.textContent = origText;
updateState();
showError(text || 'Pairing failed (HTTP ' + r.status + ')');
inputs[0].focus();
return;
}
var data = await r.json();
showSuccess();
btnPair.textContent = 'Paired!';
// redirect to create.html with the homekit URL
setTimeout(function() {
var p = new URLSearchParams();
p.set('url', data.url);
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (mdnsName) p.set('model', mdnsName);
if (hostname) p.set('hostname', hostname);
window.location.href = 'create.html?' + p.toString();
}, 800);
} catch (e) {
inputs.forEach(function(input) { input.disabled = false; });
btnPair.textContent = origText;
updateState();
showError('Connection error: ' + e.message);
}
}
// navigation
document.getElementById('btn-back').addEventListener('click', function() {
window.location.href = 'index.html';
});
document.getElementById('btn-skip').addEventListener('click', function() {
window.location.href = 'index.html';
});
// try standard discovery -- pass all params to standard.html
document.getElementById('btn-standard').addEventListener('click', function() {
var p = new URLSearchParams();
if (ip) p.set('ip', ip);
@@ -317,6 +547,9 @@
if (latency) p.set('latency', latency);
window.location.href = 'standard.html?' + p.toString();
});
// autofocus first input
inputs[0].focus();
</script>
</body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

+84 -2
View File
@@ -317,8 +317,8 @@
const data = await r.json();
if (data.type === 'standard' || (data.reachable && data.type !== 'homekit')) {
navigateStandard(ip, data);
if (data.type === 'onvif') {
navigateOnvif(ip, data);
return;
}
@@ -327,6 +327,16 @@
return;
}
if (data.type === 'xiaomi') {
navigateXiaomi(ip, data);
return;
}
if (data.type === 'standard' || data.reachable) {
navigateStandard(ip, data);
return;
}
if (data.type === 'unreachable') {
showUnreachable(ip);
return;
@@ -340,6 +350,46 @@
}
}
function navigateOnvif(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.onvif) {
if (probes.onvif.url) p.set('onvif_url', probes.onvif.url);
if (probes.onvif.port) p.set('onvif_port', probes.onvif.port);
if (probes.onvif.name) p.set('onvif_name', probes.onvif.name);
if (probes.onvif.hardware) p.set('onvif_hardware', probes.onvif.hardware);
}
if (probes.mdns) {
if (probes.mdns.name) p.set('mdns_name', probes.mdns.name);
if (probes.mdns.model) p.set('mdns_model', probes.mdns.model);
if (probes.mdns.category) p.set('mdns_category', probes.mdns.category);
if (probes.mdns.device_id) p.set('mdns_device_id', probes.mdns.device_id);
if (probes.mdns.port) p.set('mdns_port', probes.mdns.port);
p.set('mdns_paired', probes.mdns.paired ? '1' : '0');
}
window.location.href = 'onvif.html?' + p.toString();
}
function navigateStandard(ip, data) {
var p = new URLSearchParams();
p.set('ip', ip);
@@ -366,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);
@@ -393,6 +473,8 @@
if (probes.mdns.model) p.set('mdns_model', probes.mdns.model);
if (probes.mdns.category) p.set('mdns_category', probes.mdns.category);
if (probes.mdns.device_id) p.set('mdns_device_id', probes.mdns.device_id);
if (probes.mdns.port) p.set('mdns_port', probes.mdns.port);
p.set('mdns_paired', probes.mdns.paired ? '1' : '0');
}
window.location.href = 'homekit.html?' + p.toString();
+469
View File
@@ -0,0 +1,469 @@
<!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 - ONVIF 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;
--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; }
.onvif-word {
font-size: 2.5rem; font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 1rem;
}
.onvif-o {
background: linear-gradient(180deg, #00a0e9 0%, #1a237e 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.onvif-nvif {
color: var(--text-primary);
}
.title {
font-size: 1.25rem; font-weight: 600;
letter-spacing: 0.03em;
color: var(--text-primary);
}
.subtitle {
font-size: 0.8125rem; color: var(--text-tertiary);
margin-top: 0.375rem;
font-family: var(--font-mono);
}
.subtitle .highlight {
color: var(--purple-light);
}
/* Form */
.form-group { margin-bottom: 1.5rem; }
.label {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.875rem; font-weight: 500;
color: var(--text-secondary); margin-bottom: 0.5rem;
}
.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: 320px; 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;
}
.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); }
.input {
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 {
border-color: var(--purple-primary);
box-shadow: 0 0 0 3px var(--purple-glow);
}
.input::placeholder { color: var(--text-tertiary); }
.input-password { position: relative; }
.input-password .input { padding-right: 3rem; }
.btn-toggle-pass {
position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%);
background: none; border: none; padding: 0.5rem; cursor: pointer;
color: var(--text-tertiary); display: flex;
transition: color var(--transition-fast);
}
.btn-toggle-pass:hover { color: var(--purple-primary); }
/* 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.5rem; font-size: 1.125rem; }
.btn-outline {
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);
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); }
/* Checkbox */
.checkbox-row {
margin-bottom: 1.5rem;
}
.checkbox-label {
display: flex; align-items: center; gap: 0.625rem;
font-size: 0.875rem; color: var(--text-secondary);
cursor: pointer; user-select: none;
}
.checkbox-label input { display: none; }
.checkbox-custom {
width: 18px; height: 18px; flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
transition: all var(--transition-fast);
position: relative;
}
.checkbox-label input:checked + .checkbox-custom {
background: var(--purple-primary);
border-color: var(--purple-primary);
}
.checkbox-label input:checked + .checkbox-custom::after {
content: '';
position: absolute; top: 2px; left: 5px;
width: 5px; height: 9px;
border: solid white; border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 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">
<div class="onvif-word"><span class="onvif-o">O</span><span class="onvif-nvif">nvif</span></div>
<h1 class="title" id="title">ONVIF Camera</h1>
<p class="subtitle" id="subtitle"></p>
</div>
<div class="form-group">
<label class="label">
Username
<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">ONVIF Username</div>
<p class="tooltip-text">The username for your camera's ONVIF service. Usually the same as the web interface login. Common defaults: admin, root.</p>
</div>
</span>
</label>
<input type="text" id="f-user" class="input" value="admin" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label class="label">
Password
<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">ONVIF Password</div>
<p class="tooltip-text">The password for your camera's ONVIF service. Some cameras allow ONVIF access without a password -- leave empty to try.</p>
</div>
</span>
</label>
<div class="input-password">
<input type="password" id="f-pass" class="input" placeholder="Camera password" autocomplete="off">
<button class="btn-toggle-pass" id="btn-toggle-pass" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="checkbox-row" id="checkbox-row">
<label class="checkbox-label">
<input type="checkbox" id="cb-top1000" checked>
<span class="checkbox-custom"></span>
Also test popular stream patterns
</label>
</div>
<button class="btn btn-primary btn-large" id="btn-discover">Discover Streams</button>
<button class="btn-outline" id="btn-standard">Skip, use Standard Discovery</button>
<button class="btn-outline" id="btn-homekit" style="display:none">Try HomeKit Pairing</button>
</div>
</div>
<script>
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 onvifUrl = params.get('onvif_url') || '';
var onvifPort = params.get('onvif_port') || '';
var onvifName = params.get('onvif_name') || '';
var onvifHardware = params.get('onvif_hardware') || '';
var mdnsName = params.get('mdns_name') || '';
var mdnsModel = params.get('mdns_model') || '';
var mdnsCategory = params.get('mdns_category') || '';
var mdnsPort = params.get('mdns_port') || '';
var mdnsPaired = params.get('mdns_paired') || '';
var mdnsDeviceId = params.get('mdns_device_id') || '';
// title from ONVIF name
if (onvifName) {
document.getElementById('title').textContent = onvifName;
}
// subtitle with IP and hardware
var subtitleEl = document.getElementById('subtitle');
var parts = [];
if (ip) parts.push(ip);
if (onvifHardware && onvifHardware !== onvifName) parts.push(onvifHardware);
if (parts.length) {
while (subtitleEl.firstChild) subtitleEl.removeChild(subtitleEl.firstChild);
parts.forEach(function(text, i) {
if (i > 0) {
var sep = document.createElement('span');
sep.textContent = ' / ';
subtitleEl.appendChild(sep);
}
var span = document.createElement('span');
span.className = 'highlight';
span.textContent = text;
subtitleEl.appendChild(span);
});
}
// show HomeKit button only if mDNS data present
if (mdnsName && mdnsDeviceId) {
document.getElementById('btn-homekit').style.display = '';
}
// password toggle
document.getElementById('btn-toggle-pass').addEventListener('click', function() {
var input = document.getElementById('f-pass');
input.type = input.type === 'password' ? 'text' : 'password';
});
// discover streams -> build onvif:// URL and go to create.html
document.getElementById('btn-discover').addEventListener('click', function() {
var user = document.getElementById('f-user').value.trim();
var pass = document.getElementById('f-pass').value;
var auth = '';
if (user || pass) {
auth = encodeURIComponent(user) + ':' + encodeURIComponent(pass) + '@';
}
var host = ip;
var port = parseInt(onvifPort, 10) || 80;
if (port !== 80) {
host = ip + ':' + port;
}
var onvifStreamUrl = 'onvif://' + auth + host;
var p = new URLSearchParams();
p.set('url', onvifStreamUrl);
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (onvifName) p.set('model', onvifName);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (ports) p.set('ports', ports);
if (document.getElementById('cb-top1000').checked) {
p.set('ids', 'p:top-1000');
p.set('user', user);
if (pass) p.set('pass', pass);
}
window.location.href = 'create.html?' + p.toString();
});
// standard discovery
document.getElementById('btn-standard').addEventListener('click', function() {
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();
});
// homekit pairing
document.getElementById('btn-homekit').addEventListener('click', function() {
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);
if (mdnsName) p.set('mdns_name', mdnsName);
if (mdnsModel) p.set('mdns_model', mdnsModel);
if (mdnsCategory) p.set('mdns_category', mdnsCategory);
if (mdnsPort) p.set('mdns_port', mdnsPort);
if (mdnsPaired) p.set('mdns_paired', mdnsPaired);
if (mdnsDeviceId) p.set('mdns_device_id', mdnsDeviceId);
window.location.href = 'homekit.html?' + p.toString();
});
// back
document.getElementById('btn-back').addEventListener('click', function() {
window.location.href = 'index.html';
});
// autofocus password field (user is prefilled)
document.getElementById('f-pass').focus();
</script>
</body>
</html>
+1 -1
View File
@@ -2,5 +2,5 @@ package www
import "embed"
//go:embed *.html
//go:embed *.html icons
var Static embed.FS
+5 -3
View File
@@ -423,11 +423,13 @@
function classifyResult(r) {
var scheme = r.source.split('://')[0] || '';
var isRtsp = scheme === 'rtsp' || scheme === 'rtsps';
var isRecommended = scheme === 'rtsp' || scheme === 'rtsps' || scheme === 'onvif';
var isJpegOnly = r.codecs && r.codecs.length > 0 && r.codecs.indexOf('H264') === -1 && r.codecs.indexOf('H265') === -1;
var isHD = r.width >= 1280;
if (isRtsp && isHD) return 'rec-main';
if (isRtsp) return 'rec-sub';
if (isJpegOnly) return 'alt';
if (isRecommended && isHD) return 'rec-main';
if (isRecommended) return 'rec-sub';
return 'alt';
}
+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>