29 Commits

Author SHA1 Message Date
eduard256 0205e32257 Merge develop into main for v1.0.11 release 2026-03-19 11:33:03 +00:00
eduard256 c576b09e8b Release v1.0.11 2026-03-19 11:32:58 +00:00
eduard256 25f1907fc3 Register HEAD route for health endpoint in chi router 2026-03-19 11:25:57 +00:00
eduard256 cc8c3e4f14 Fix health endpoint HEAD method support, add icon assets
- Allow HEAD requests on /api/v1/health for Docker/CasaOS healthcheck
  compatibility (wget --spider sends HEAD)
- Add project icon in SVG, 192x192 PNG and 512x512 PNG formats
2026-03-19 11:17:22 +00:00
eduard256 4bd2de78dc Merge develop into main for v1.0.10 release 2026-03-17 07:27:07 +00:00
eduard256 89a7c87462 Release v1.0.10 2026-03-17 07:26:58 +00:00
eduard256 e40dccbb90 Remove CI/CD, unify Docker image for Docker Hub and HA add-on
- Remove GitHub Actions workflows (ci.yml, docker.yml, release.yml)
- Remove GoReleaser configuration
- Remove RELEASE.md (replaced by /release_strix skill)
- Add HA options.json support in config.go (reads /data/options.json)
- Add Version field to Config, pass real version to health endpoint
- Change Version from const to var, inject via ldflags at build time
- Add ARG VERSION to Dockerfile for build-time version injection
- Reset webui/package.json version to 0.0.0 (not used functionally)
- Clear probe fields on back navigation in frontend
- Add /release_strix and /release_strix_dev skills
2026-03-17 07:23:04 +00:00
eduard256 fe93aa329c Integrate probe endpoint into frontend
- Add ProbeAPI client (js/api/probe.js)
- Add reusable modal component (js/ui/modal.js) with overlay, animations
- Call GET /api/v1/probe after Check Address click
- Auto-fill Camera Model with vendor from ARP/OUI lookup
- Show modal on unreachable device with Change IP / Continue Anyway buttons
- Add modal CSS styles matching existing dark theme
2026-03-16 20:05:00 +00:00
eduard256 ddf2b4a373 Remove experimental SSE warning from Home Assistant Add-on section 2026-03-16 14:56:55 +00:00
eduard256 833da5cf48 Remove experimental SSE warning from Home Assistant Add-on section 2026-03-16 14:55:57 +00:00
eduard256 3fec89be7f Add HTTP prober, optimize mDNS timeout, add Trassir/ZOSI to OUI
- Add HTTPProber: parallel HEAD+GET on ports 80/8080, extracts Server header
- Reduce mDNS timeout from 1s to 100ms using context wrapper around
  mdns.Query (HomeKit devices respond in 2-10ms, no need to wait 1s)
- Add Trassir (F0:23:B9) and ZOSI (00:05:FE) to camera OUI database
- Probe response time improved from ~1s to ~110ms for reachable devices
2026-03-16 14:46:58 +00:00
eduard256 4d6c2fd878 Add GET /api/v1/probe endpoint for device inspection
Fast (~1-3s) endpoint that gathers network info about a device
before full stream discovery. Runs ping first, then parallel probes.

Features:
- Ping with ICMP + TCP fallback (works without root)
- Reverse DNS hostname lookup
- ARP table MAC address + OUI vendor identification (2403 entries, 51 camera vendors)
- mDNS HomeKit detection (camera/doorbell, paired status)
- Extensible Prober interface for adding new probe types
- 3-second overall timeout, parallel execution

Response includes "type" field:
- "unreachable" - device not responding
- "standard" - normal IP camera (RTSP/HTTP/ONVIF flow)
- "homekit" - Apple HomeKit camera (PIN pairing flow)
2026-03-16 13:57:41 +00:00
eduard256 eb8cc546c8 Merge main into develop: Add release documentation 2025-12-11 16:53:02 +00:00
eduard256 1fc345c569 Add release process documentation 2025-12-11 16:52:53 +00:00
eduard256 0c0d743594 Merge develop into main for v1.0.9 release 2025-12-11 16:40:39 +00:00
eduard256 787919d20b Release v1.0.9: Fix SSE real-time streaming in Home Assistant Ingress mode 2025-12-11 16:40:31 +00:00
eduard256 e9dc04178e Fix SSE real-time streaming in Home Assistant Ingress mode
Add padding to overcome aiohttp 64KB buffer in HA Supervisor.

Problem:
- HA Supervisor uses aiohttp with 64KB StreamResponse buffer
- Small SSE events (~200-500 bytes) were buffered until connection closed
- Users saw all streams appear at once instead of real-time updates

Solution:
- Detect Ingress mode via X-Ingress-Path header
- Add 64KB SSE comment padding to fill proxy buffers
- Increase progress interval to 3 sec in Ingress mode (reduce traffic)
- Normal mode (Docker/direct) unchanged - works exactly as before

Traffic impact:
- Normal mode: ~17KB per scan (unchanged)
- Ingress mode: ~2-3MB per scan (acceptable for real-time updates)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 16:34:05 +00:00
eduard256 915c1dec1b Add stream categorization: Recommended/Alternative with Main/Sub/Other subgroups
- Split discovered streams into Recommended (FFMPEG, ONVIF) and Alternative groups
- Add Main/Sub/Other classification within Recommended based on resolution:
  - Main: streams with width >= 720px (after clustering analysis)
  - Sub: streams with width < 720px or lower cluster
  - Other: streams without resolution info
- Implement smart auto-collapse based on selection mode:
  - Main selection: shows Recommended/Main, collapses rest
  - Sub selection: shows Recommended/Sub, collapses rest
  - Falls back to showing all if target category is empty
- Add collapsible groups/subgroups with chevron toggles
- User manual expand/collapse preserved until mode change
- Add stream count badges to all group headers
2025-12-11 09:49:46 +00:00
eduard256 e6828d8a22 Add ?mp4 parameter support for BUBBLE streams in Frigate config
- Add buildRtspPath() helper method to conditionally append ?mp4
- Only BUBBLE stream types get ?mp4 suffix for proper recording
- Other stream types (RTSP, ONVIF, JPEG, etc.) remain unchanged
- Handles both single and dual-stream configurations correctly
2025-11-26 15:29:56 +03:00
eduard256 eedce14731 Merge branch 'develop' 2025-11-26 13:01:32 +03:00
eduard256 9975aa71de Release v1.0.8: Use host network mode for Docker deployments 2025-11-26 13:01:27 +03:00
eduard256 38e4af230f Use host network mode for Docker deployments
- Update docker run command to use --network host
- Update docker-compose.yml to use network_mode: host
- Update docker-compose.full.yml to use network_mode: host
- Remove port mappings as they are not needed with host network
2025-11-26 12:57:45 +03:00
eduard256 031e494787 Merge develop into main for v1.0.7 release 2025-11-23 22:54:20 +03:00
eduard256 de389588ce Release v1.0.7: Fix Hikvision channel numbering and improve database
- Fixed channel numbering for Hikvision-style cameras (reported by @sergbond_com)
- Added universal [CHANNEL+1] placeholder support
- Supports both 0-based and 1-based channel numbering
- Updated 14 camera brands with universal patterns
- Fixed brand+model search matching
- Removed invalid test data from database
2025-11-23 22:54:03 +03:00
eduard256 4c03ad8d3c Add [CHANNEL+1] placeholder support for Hikvision-style channel numbering
- Added [CHANNEL+1], [channel+1], {CHANNEL+1}, {channel+1} placeholders to builder.go
- Updated 14 camera brands with universal channel patterns
- Hikvision: replaced 10 hardcoded patterns with 6 universal patterns
- Hiwatch: replaced 4 hardcoded patterns with 8 universal patterns (including ISAPI)
- Other brands: Annke, Swann, Abus, 7links, LevelOne, AlienDVR, Oswoo, AV102IP-40, Acvil, TBKVision, Deltaco, Night Owl
- Universal patterns placed first for faster discovery, hardcoded patterns kept as fallback
- Supports both 0-based (channel=0 -> 101) and 1-based (channel=1 -> 101) numbering
- Added 6 high-priority patterns to popular_stream_patterns.json
2025-11-23 22:39:20 +03:00
eduard256 d569a76700 Use intelligent brand+model search in stream discovery 2025-11-23 21:33:44 +03:00
eduard256 a405d6198f Merge main into develop: Add dynamic channel support for HiWatch cameras 2025-11-22 22:22:41 +03:00
eduard256 4143c267cd Remove invalid URL entry from Hikvision database
- Removed entry with embedded credentials and IP address
- Entry contained: rtsp://huntertech:Superuser01!@10.0.55.11:554
- This was likely test data that accidentally got committed
- Model "Bullet-4K" entry removed from database
2025-11-22 21:52:53 +03:00
eduard256 19e58db70f Add dynamic channel support for HiWatch cameras
- Added 5 new URL patterns with [CHANNEL] placeholder
- Supports channels 0-255 for multi-camera DVR/NVR systems
- Patterns include /Streaming/Channels/[CHANNEL]01, [CHANNEL]02
- ISAPI format support with dynamic channels
- All existing hardcoded patterns preserved for compatibility
2025-11-22 21:45:56 +03:00
57 changed files with 6669 additions and 1799 deletions
+169
View File
@@ -0,0 +1,169 @@
---
name: release_strix
description: Full release of Strix -- merge develop to main, tag, build multiarch Docker image, push to Docker Hub, update hassio-strix, create GitHub Release.
disable-model-invocation: true
---
# Strix Release
You are performing a full release of Strix. Follow every step exactly. Do NOT skip steps. Do NOT ask for confirmation except where explicitly noted below.
## Repositories
- Strix: `/home/user/Strix`
- hassio-strix: `/home/user/hassio-strix`
## Step 1: Gather information
```bash
cd /home/user/Strix
git checkout develop
git pull origin develop
git pull origin main
# Get last release tag
git tag --sort=-version:refname | head -1
# Show all commits since last release
git log main..develop --oneline
# Show changed files
git diff main..develop --stat
```
## Step 2: Ask for version (THE ONLY QUESTION)
Use AskUserQuestion to ask the user which version to release.
Show them:
- The last tag
- The list of commits from Step 1
Offer options:
- Next patch (e.g. 1.0.9 -> 1.0.10)
- Next minor (e.g. 1.0.9 -> 1.1.0)
- Next major (e.g. 1.0.9 -> 2.0.0)
- Other (user types custom version)
Wait for answer. Store the chosen version as VERSION (without "v" prefix).
## Step 3: Verify build
```bash
cd /home/user/Strix
go test ./...
go build ./...
```
If tests or build fail -- STOP and report the error. Do not continue.
## Step 4: Update CHANGELOG.md
Read `/home/user/Strix/CHANGELOG.md`. Add a new section at the top (after the header lines), based on the commits from Step 1. Follow the existing format exactly:
```markdown
## [VERSION] - YYYY-MM-DD
### Added
- ...
### Fixed
- ...
### Changed
- ...
```
Use today's date. Categorize commits into Added/Fixed/Changed/Technical sections. Only include sections that have entries. Write clear, user-facing descriptions (not raw commit messages).
## Step 5: Git -- commit, merge, tag, push
```bash
cd /home/user/Strix
git add CHANGELOG.md
git commit -m "Release v$VERSION"
git checkout main
git merge develop --no-ff -m "Merge develop into main for v$VERSION release"
git tag v$VERSION
git push origin main --tags
git checkout develop
git merge main
git push origin develop
```
## Step 6: Build and push Docker image
```bash
cd /home/user/Strix
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg VERSION=$VERSION \
-t eduard256/strix:$VERSION \
-t eduard256/strix:latest \
-t eduard256/strix:$(echo $VERSION | cut -d. -f1-2) \
-t eduard256/strix:$(echo $VERSION | cut -d. -f1) \
--push .
```
## Step 7: Verify Docker Hub
```bash
curl -s "https://hub.docker.com/v2/repositories/eduard256/strix/tags/?page_size=10" | jq '.results[].name'
docker manifest inspect eduard256/strix:$VERSION | jq '.manifests[].platform'
```
Verify the new version tag exists and both amd64 and arm64 platforms are present.
## Step 8: Smoke test
```bash
docker run --rm -d --name strix-smoke-test -p 14567:4567 eduard256/strix:$VERSION
sleep 5
curl -s http://localhost:14567/api/v1/health | jq '.version'
docker stop strix-smoke-test
```
Verify the health endpoint returns the correct version string.
## Step 9: Update hassio-strix
```bash
cd /home/user/hassio-strix
git pull origin main
```
Edit `/home/user/hassio-strix/strix/config.json` -- change `"version"` to the new VERSION.
Edit `/home/user/hassio-strix/strix/CHANGELOG.md` -- add the same CHANGELOG section as in Step 4.
```bash
cd /home/user/hassio-strix
git add strix/config.json strix/CHANGELOG.md
git commit -m "Release v$VERSION"
git push origin main
```
## Step 10: GitHub Release
```bash
cd /home/user/Strix
PREV_TAG=$(git tag --sort=-version:refname | sed -n '2p')
gh release create v$VERSION \
--title "v$VERSION" \
--notes "$(git log --oneline ${PREV_TAG}..v$VERSION)"
```
## Step 11: Final report
Output a summary:
```
Release v$VERSION complete:
- Git: tag v$VERSION pushed to main
- Docker Hub: eduard256/strix:$VERSION (amd64 + arm64)
- Health check: version "$VERSION" verified
- hassio-strix: config.json updated to $VERSION, pushed to main
- GitHub Release: <URL from gh release create>
```
+64
View File
@@ -0,0 +1,64 @@
---
name: release_strix_dev
description: Build and push dev Docker image for Strix, update hassio-strix dev add-on version.
disable-model-invocation: true
---
# Strix Dev Build
You are building and pushing a dev image of Strix. Follow every step exactly. Do NOT ask any questions -- this is fully automated.
## Repositories
- Strix: `/home/user/Strix`
- hassio-strix: `/home/user/hassio-strix`
## Step 1: Get commit hash
```bash
cd /home/user/Strix
git rev-parse --short HEAD
```
Store this as COMMIT_HASH (e.g. `fe93aa3`).
## Step 2: Build Docker image
```bash
cd /home/user/Strix
docker build --build-arg VERSION=dev-$COMMIT_HASH -t eduard256/strix:dev -t eduard256/strix:dev-$COMMIT_HASH .
```
## Step 3: Push to Docker Hub
```bash
docker push eduard256/strix:dev
docker push eduard256/strix:dev-$COMMIT_HASH
```
## Step 4: Update hassio-strix
```bash
cd /home/user/hassio-strix
git pull origin main
```
Edit `/home/user/hassio-strix/strix-dev/config.json` -- change `"version"` to `dev-$COMMIT_HASH`.
```bash
cd /home/user/hassio-strix
git add strix-dev/config.json
git commit -m "Dev build dev-$COMMIT_HASH"
git push origin main
```
## Step 5: Report
Output a summary:
```
Dev build complete:
- Commit: $COMMIT_HASH
- Docker Hub: eduard256/strix:dev, eduard256/strix:dev-$COMMIT_HASH (amd64)
- hassio-strix: strix-dev version updated to dev-$COMMIT_HASH
```
-54
View File
@@ -1,54 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Build
run: make build
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
-77
View File
@@ -1,77 +0,0 @@
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
env:
REGISTRY: docker.io
IMAGE_NAME: eduard256/strix
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
- name: Docker Hub Description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
repository: ${{ env.IMAGE_NAME }}
readme-filepath: ./README.md
short-description: "Smart IP Camera Stream Discovery System"
-32
View File
@@ -1,32 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-112
View File
@@ -1,112 +0,0 @@
# GoReleaser configuration for Strix
version: 2
before:
hooks:
- go mod tidy
- go mod download
builds:
- id: strix
main: ./cmd/strix/main.go
binary: strix
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
- arm
goarm:
- "7"
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.BuildDate={{.Date}}
- -X main.GitCommit={{.ShortCommit}}
archives:
- id: strix-archive
format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
- webui/**/*
- cameras/**/*
checksum:
name_template: 'checksums.txt'
algorithm: sha256
changelog:
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- typo
groups:
- title: '🚀 Features'
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: '🐛 Bug Fixes'
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: '📝 Documentation'
regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$'
order: 2
- title: '🔧 Other'
order: 999
release:
github:
owner: eduard256
name: Strix
draft: false
prerelease: auto
name_template: "v{{.Version}}"
header: |
## 🦉 Strix v{{.Version}}
Smart IP Camera Stream Discovery System
### Installation
Download the appropriate binary for your platform below and extract it.
### Usage
```bash
./strix
```
Then open http://localhost:4567 in your browser.
footer: |
**Full Changelog**: https://github.com/eduard256/Strix/compare/{{ .PreviousTag }}...{{ .Tag }}
snapshot:
name_template: "{{ incpatch .Version }}-next"
dist: dist
+78
View File
@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.11] - 2026-03-19
### Added
- Project icon assets (SVG, 192x192 PNG, 512x512 PNG) for use in app stores and integrations
### Fixed
- Health endpoint now accepts HEAD requests for Docker and CasaOS healthcheck compatibility
- Registered HEAD route in chi router for /api/v1/health endpoint
## [1.0.10] - 2026-03-17
### Added
- Device probe endpoint (GET /api/v1/probe) for network device inspection
- HTTP prober for detecting camera web interfaces
- mDNS discovery for local network devices
- ARP/OUI vendor identification with camera OUI database (2,400+ entries)
- Probe integration into frontend with modal UI
- Added Trassir and ZOSI to OUI database
### Changed
- Removed CI/CD pipelines (GitHub Actions), replaced with local Docker builds
- Removed GoReleaser, unified Docker image for Docker Hub and HA add-on
- Application version now injected at build time via ldflags
- HA add-on reads /data/options.json natively (no more entrypoint script)
- Optimized mDNS discovery timeout
### Fixed
- Removed experimental SSE warning from Home Assistant Add-on documentation
- Clear probe-filled fields when navigating back in frontend
## [1.0.9] - 2025-12-11
### Fixed
- Fixed real-time SSE streaming in Home Assistant Ingress mode
- SSE events now arrive immediately instead of being buffered until completion
### Technical
- Added automatic detection of Home Assistant Ingress via X-Ingress-Path header
- Implemented 64KB padding for SSE events to overcome aiohttp buffer in HA Supervisor
- Adjusted progress update interval to 3 seconds in Ingress mode to reduce traffic
- Normal mode (Docker/direct access) remains unchanged
## [1.0.8] - 2025-11-26
### Changed
- Updated Docker deployment to use host network mode for better compatibility
- Modified docker-compose.yml to use `network_mode: host`
- Updated installation commands to use `--network host` flag
- Removed port mappings as they are not needed with host network mode
### Improved
- Better compatibility with unprivileged LXC containers
- Simplified Docker networking configuration
- Direct network access for improved camera discovery performance
## [1.0.7] - 2025-11-23
### Fixed
- Fixed channel numbering for Hikvision-style cameras (reported by @sergbond_com)
- Removed invalid test data from Hikvision database
- Fixed brand+model search matching in stream discovery
### Added
- Universal `[CHANNEL+1]` placeholder support for flexible channel numbering
- Support for both 0-based (channel=0 → 101) and 1-based (channel=1 → 101) channel selection
- Added 6 high-priority Hikvision patterns to popular stream patterns database
### Changed
- Updated 14 camera brands with universal channel patterns (Hikvision, Hiwatch, Annke, Swann, Abus, 7links, LevelOne, AlienDVR, Oswoo, AV102IP-40, Acvil, TBKVision, Deltaco, Night Owl)
- Hikvision: replaced 10 hardcoded patterns with 6 universal patterns
- Hiwatch: replaced 4 hardcoded patterns with 8 universal patterns (including ISAPI variants)
- Universal patterns now tested first for faster discovery, hardcoded patterns kept as fallback
- Improved stream discovery performance with intelligent pattern ordering
### Technical
- Added support for `[CHANNEL+1]`, `[channel+1]`, `{CHANNEL+1}`, `{channel+1}` placeholders in URL builder
- Modified 16 files: +2448 additions, -1954 deletions
## [0.1.0] - 2025-11-06
### Added
+3 -1
View File
@@ -4,6 +4,8 @@
# Stage 1: Builder
FROM golang:1.24-alpine AS builder
ARG VERSION=dev
WORKDIR /build
# Install build dependencies
@@ -18,7 +20,7 @@ COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-ldflags="-s -w -X main.Version=docker" \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o strix \
cmd/strix/main.go
+2 -8
View File
@@ -38,7 +38,7 @@
### Ubuntu / Debian
```bash
sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && docker run -d --name strix -p 4567:4567 --restart unless-stopped eduard256/strix:latest
sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && docker run -d --name strix --network host --restart unless-stopped eduard256/strix:latest
```
Open **http://YOUR_SERVER_IP:4567**
@@ -49,9 +49,7 @@ Open **http://YOUR_SERVER_IP:4567**
sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh && command -v docker-compose >/dev/null 2>&1 || { sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && sudo chmod +x /usr/local/bin/docker-compose; } && curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml -o docker-compose.yml && docker-compose up -d
```
### Home Assistant Add-on (Beta)
⚠️ **Status:** Experimental (SSE has bugs, Docker recommended)
### Home Assistant Add-on
**Installation:**
@@ -63,10 +61,6 @@ sudo apt update && command -v docker >/dev/null 2>&1 || curl -fsSL https://get.d
6. Enable **"Start on boot"** and **"Show in sidebar"**
7. Click **Start**
**Known Issues:**
- Real-time progress may not display (Ingress SSE limitation)
- Use Docker installation for better experience
---
## How to Use
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" height="512" width="512" viewBox="0 0 512.001 512.001">
<g>
<path style="fill:#7E57C2;" d="M124.477,378.183L9.347,495.779c21.628,21.628,56.695,21.628,78.324,0L119,464.45 c21.628,21.628,56.696,21.628,78.324,0l28.375-28.375C187.373,426.203,151.825,405.106,124.477,378.183z"/>
<path style="fill:#7E57C2;" d="M447.27,55.383h-55.383V177.22c0.002-40.997,22.277-76.788,55.383-95.939 c16.293-9.425,35.207-14.822,55.383-14.822c0-6.982,0-11.077,0-11.077V0C472.065,0,447.27,24.796,447.27,55.383z"/>
</g>
<path style="fill:#9575CD;" d="M336.504,55.383C336.504,24.796,311.708,0,281.121,0v66.46c20.176,0,39.091,5.397,55.383,14.822 c33.107,19.153,55.383,54.946,55.383,95.945V55.383H336.504z"/>
<path style="fill:#E8E0F5;" d="M391.887,209.772v-27.116c0-3.312,0-5.432,0-5.432c0-40.997-22.276-76.791-55.383-95.942 c-16.293-9.425-35.207-14.822-55.383-14.822v155.073C311.213,191.443,357.554,187.532,391.887,209.772z M314.351,143.996 c0-12.234,9.918-22.153,22.153-22.153s22.153,9.919,22.153,22.153c0,12.235-9.918,22.153-22.153,22.153 S314.351,156.231,314.351,143.996z"/>
<path style="fill:#D1C4E9;" d="M391.887,177.221v32.551c5.151,3.336,10.037,7.246,14.55,11.76h96.216V66.46 c-20.176,0-39.091,5.397-55.383,14.822C414.164,100.434,391.888,136.225,391.887,177.221z M469.423,143.996 c0,12.235-9.918,22.153-22.153,22.153s-22.153-9.918-22.153-22.153c0-12.234,9.918-22.153,22.153-22.153 C459.504,121.843,469.423,131.762,469.423,143.996z"/>
<path style="fill:#9575CD;" d="M281.121,221.532l-10.2,10.2L281.121,221.532z"/>
<path style="fill:#B39DDB;" d="M406.438,221.533c34.606,34.606,34.606,90.712,0,125.319c-69.21,69.21-181.422,69.21-250.633,0.002 l-31.329,31.33c27.348,26.923,62.896,48.02,101.221,57.892c17.714,4.562,36.285,6.989,55.423,6.989 c122.349,0,221.532-99.182,221.532-221.531C502.653,221.533,406.437,221.532,406.438,221.533z"/>
<path style="fill:#9575CD;" d="M281.121,221.532l125.318,125.319c34.606-34.606,34.606-90.713,0-125.319 c-4.515-4.514-9.401-8.425-14.551-11.761C357.554,187.532,311.213,191.443,281.121,221.532z"/>
<path style="fill:#7E57C2;" d="M406.438,346.851L281.12,221.533l-10.199,10.2L155.802,346.851 C225.017,416.062,337.228,416.061,406.438,346.851z"/>
<circle style="fill:#9575CD;" cx="336.507" cy="143.996" r="22.153"/>
<circle style="fill:#7E57C2;" cx="447.274" cy="143.996" r="22.153"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+6 -6
View File
@@ -18,12 +18,12 @@ import (
"github.com/go-chi/chi/v5"
)
const (
// Version is the application version
Version = "1.0.4"
// Version is set at build time via ldflags:
//
// go build -ldflags="-X main.Version=1.0.10" ./cmd/strix
var Version = "dev"
// Banner is the application banner
Banner = `
const Banner = `
███████╗████████╗██████╗ ██╗██╗ ██╗
██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝
███████╗ ██║ ██████╔╝██║ ╚███╔╝
@@ -34,7 +34,6 @@ const (
Smart IP Camera Stream Discovery System
Version: %s
`
)
func main() {
// Print banner
@@ -43,6 +42,7 @@ func main() {
// Load configuration
cfg := config.Load()
cfg.Version = Version
// Setup logger
slogger := cfg.SetupLogger()
+64 -28
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"3628-675",
@@ -313,15 +349,6 @@
"port": 0,
"url": ""
},
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPC-340HD",
@@ -465,15 +492,6 @@
"port": 0,
"url": "snapshot.jpg"
},
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IP-CAM",
@@ -631,16 +649,6 @@
"port": 80,
"url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240"
},
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
},
{
"models": [
"PX-3615-675"
@@ -722,6 +730,34 @@
"protocol": "http",
"port": 82,
"url": "/cgi/mjpg/mjpg.cgi"
},
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
}
]
}
+61 -25
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"10550",
@@ -320,31 +356,6 @@
"port": 554,
"url": "/s2"
},
{
"models": [
"IPCA53000",
"IPCB42510B",
"IPCB44510A",
"IPCB64515B"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IPCB42550",
"IPCB78520",
"NVR10030",
"TVIP41500",
"TVIP52500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"IPCB54611B",
@@ -635,6 +646,31 @@
"protocol": "rtsp",
"port": 554,
"url": "/mpeg4/media.amp?resolution=640x480"
},
{
"models": [
"IPCA53000",
"IPCB42510B",
"IPCB44510A",
"IPCB64515B"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"IPCB42550",
"IPCB78520",
"NVR10030",
"TVIP41500",
"TVIP52500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"WIFI-5MP-30"
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"mega216"
+127 -91
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"NVR",
@@ -220,55 +256,6 @@
"port": 0,
"url": "snapshot.jpg?user=[USERNAME]&pwd=[PASSWORD]&strm=[CHANNEL]"
},
{
"models": [
"141CS",
"151DB",
"151de",
"151dj",
"151DM",
"191BS",
"2MP",
"4MP Bullet",
"4MP DOME",
"720P",
"AC500",
"AK-N48PIA0-68DT",
"c500",
"C800",
"DE81GB",
"DN41R",
"DN81R",
"DVR",
"DW81KD",
"i15dx",
"i51dm",
"I51DS",
"I51DX",
"I61BK",
"I61DR",
"I61FC",
"I61G",
"I91BD",
"I91BF",
"I91BM",
"I91F",
"l51DM",
"N481Y",
"N48PI",
"NC400",
"NC800",
"NCPT500",
"Other",
"P01",
"POE",
"VIEW"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"141CS",
@@ -498,39 +485,6 @@
"port": 554,
"url": "/onvif2"
},
{
"models": [
"191BS",
"AC500",
"c800",
"C800-4k",
"I51DX",
"I91BF",
"NC800"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/101"
},
{
"models": [
"2MP",
@@ -659,15 +613,6 @@
"port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]"
},
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
},
{
"models": [
"h264",
@@ -851,6 +796,97 @@
"protocol": "rtsp",
"port": 0,
"url": "/h264/ch1/main/av_stream"
},
{
"models": [
"141CS",
"151DB",
"151de",
"151dj",
"151DM",
"191BS",
"2MP",
"4MP Bullet",
"4MP DOME",
"720P",
"AC500",
"AK-N48PIA0-68DT",
"c500",
"C800",
"DE81GB",
"DN41R",
"DN81R",
"DVR",
"DW81KD",
"i15dx",
"i51dm",
"I51DS",
"I51DX",
"I61BK",
"I61DR",
"I61FC",
"I61G",
"I91BD",
"I91BF",
"I91BM",
"I91F",
"l51DM",
"N481Y",
"N48PI",
"NC400",
"NC800",
"NCPT500",
"Other",
"P01",
"POE",
"VIEW"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"191BS",
"AC500",
"c800",
"C800-4k",
"I51DX",
"I91BF",
"NC800"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/102"
},
{
"models": [
"191df"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"Other"
+36
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"Outdoor Smart Home Camera",
+1097 -1052
View File
File diff suppressed because it is too large Load Diff
+200 -83
View File
@@ -4,6 +4,123 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]"
},
{
"models": [
"040",
@@ -47,69 +164,6 @@
"port": 554,
"url": "/Streaming/Channels/1"
},
{
"models": [
"ALL",
"B220",
"C6T",
"D110",
"DS-H216Q",
"DS-I102",
"DS-I113",
"DS-I114",
"DS-I114W",
"DS-i126",
"ds-i200",
"DS-I200(D)",
"ds-i203",
"DS-I213",
"ds-i214",
"DS-I214(B)",
"ds-i214w(b)",
"ds-i223",
"DS-I400(C)",
"ds-l122",
"ds-n241w",
"i100",
"i110",
"I114",
"i114w",
"I120",
"IPC-B120-I",
"IPC-B140",
"IPC-B622-G2/ZS",
"IPC-D082-G2/S",
"IPC-D120",
"IPC-T640-Z",
"l110",
"Other",
"VDP-D2201",
"VDP-D2211W(B)",
"watch"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"ALL",
"DS-I102",
"ds-i200",
"Ds-i203",
"DS-I214(B)",
"DS-I214W(B)",
"DS-I253",
"ds-i458",
"HiWatch DS-N208(C)",
"i450s"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/101"
},
{
"models": [
"DC-I200",
@@ -221,16 +275,6 @@
"port": 554,
"url": "/h264_stream"
},
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{
"models": [
"Ds-i203"
@@ -240,16 +284,6 @@
"port": 8000,
"url": "/"
},
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
},
{
"models": [
"DS-I220",
@@ -310,6 +344,89 @@
"protocol": "rtsp",
"port": 554,
"url": "/onvif1"
},
{
"models": [
"ALL",
"B220",
"C6T",
"D110",
"DS-H216Q",
"DS-I102",
"DS-I113",
"DS-I114",
"DS-I114W",
"DS-i126",
"ds-i200",
"DS-I200(D)",
"ds-i203",
"DS-I213",
"ds-i214",
"DS-I214(B)",
"ds-i214w(b)",
"ds-i223",
"DS-I400(C)",
"ds-l122",
"ds-n241w",
"i100",
"i110",
"I114",
"i114w",
"I120",
"IPC-B120-I",
"IPC-B140",
"IPC-B622-G2/ZS",
"IPC-D082-G2/S",
"IPC-D120",
"IPC-T640-Z",
"l110",
"Other",
"VDP-D2201",
"VDP-D2211W(B)",
"watch"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/101"
},
{
"models": [
"ALL",
"DS-I102",
"ds-i200",
"Ds-i203",
"DS-I214(B)",
"DS-I214W(B)",
"DS-I253",
"ds-i458",
"HiWatch DS-N208(C)",
"i450s"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/101"
},
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
}
]
}
+27 -9
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"0010/0020",
@@ -647,15 +665,6 @@
"port": 0,
"url": "cam[CHANNEL]/h264"
},
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"FCS-4051",
@@ -770,6 +779,15 @@
"protocol": "http",
"port": 80,
"url": "/cgi-bin/video.jpg"
},
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
}
]
}
+36 -18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"0v600-365-kd",
@@ -153,24 +171,6 @@
"port": 0,
"url": "snapshot.jpg?account=[USERNAME]&password=[PASSWORD]"
},
{
"models": [
"BTD2",
"CAM2",
"DVR-FTD4-8",
"DVR-THD30B",
"FTD4",
"Other",
"WM-CAM-WAWNP2L",
"wmvr-wnip2",
"WNIP2-CM",
"WNIP-2lta-bs"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/301"
},
{
"models": [
"CAM-1",
@@ -375,6 +375,24 @@
"protocol": "http",
"port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=0&u=[USERNAME]&p=[PASSWORD]"
},
{
"models": [
"BTD2",
"CAM2",
"DVR-FTD4-8",
"DVR-THD30B",
"FTD4",
"Other",
"WM-CAM-WAWNP2L",
"wmvr-wnip2",
"WNIP2-CM",
"WNIP-2lta-bs"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/301"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 10554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 10554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"801",
+128 -92
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL+1]02"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/[CHANNEL]02"
},
{
"models": [
"005FTCD",
@@ -588,58 +624,6 @@
"port": 554,
"url": "/ch05/1"
},
{
"models": [
"7-12",
"8ch 3MP NVR",
"dv8-3425",
"DVR w/ Web Port",
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8",
"DVR8-4900",
"DVR8-8050",
"DVR8-8075",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"NHH-880CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SWIFI-FLOCAM2",
"swifi-spotcam",
"SWIFI-XTRCAM",
"SWWHD-OUTCAM",
"T855"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"880",
"DVR4 4350",
"DVR8-1500",
"DVR8-1525",
"DVR8-4500",
"DVR8-4900",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SPOTCAM",
"WIFI-PT"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"887"
@@ -874,37 +858,6 @@
"port": 0,
"url": "/Streaming/Unicast/channels/401"
},
{
"models": [
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8-8075",
"lv-9808",
"Other"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/701"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/601"
},
{
"models": [
"DVR4",
@@ -942,15 +895,6 @@
"port": 80,
"url": "/?action=stream"
},
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
},
{
"models": [
"DVR8-4500",
@@ -1314,6 +1258,98 @@
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/2"
},
{
"models": [
"7-12",
"8ch 3MP NVR",
"dv8-3425",
"DVR w/ Web Port",
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8",
"DVR8-4900",
"DVR8-8050",
"DVR8-8075",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"NHH-880CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SWIFI-FLOCAM2",
"swifi-spotcam",
"SWIFI-XTRCAM",
"SWWHD-OUTCAM",
"T855"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/101"
},
{
"models": [
"880",
"DVR4 4350",
"DVR8-1500",
"DVR8-1525",
"DVR8-4500",
"DVR8-4900",
"HDR8050",
"lv-9808",
"NHD-850CAM",
"nvr16-7090",
"NVR-7200",
"Other",
"SPOTCAM",
"WIFI-PT"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{
"models": [
"DVR W/ WEB PORT",
"DVR4 4350",
"DVR8-8075",
"lv-9808",
"Other"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/channels/101"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/701"
},
{
"models": [
"DVR-1500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/601"
},
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
}
]
}
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17",
"source": "ispyconnect.com",
"entries": [
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL+1]01"
},
{
"models": [
"ALL"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/[CHANNEL]01"
},
{
"models": [
"TBK-BUL8841Z"
+2407
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -31,6 +31,54 @@
"notes": "Common RTSP sub stream for ONVIF cameras",
"model_count": 9998
},
{
"url": "/Streaming/Channels/[CHANNEL+1]01",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision main stream - 0-based channel input (channel 0 -> 101, 1 -> 201)",
"model_count": 9500
},
{
"url": "/Streaming/Channels/[CHANNEL]01",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision main stream - 1-based channel input (channel 1 -> 101, 2 -> 201)",
"model_count": 9490
},
{
"url": "/Streaming/Channels/[CHANNEL+1]02",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision sub stream - 0-based channel input (channel 0 -> 102, 1 -> 202)",
"model_count": 9480
},
{
"url": "/Streaming/Channels/[CHANNEL]02",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision sub stream - 1-based channel input (channel 1 -> 102, 2 -> 202)",
"model_count": 9470
},
{
"url": "/Streaming/Channels/[CHANNEL+1]03",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision third stream - 0-based channel input (channel 0 -> 103, 1 -> 203)",
"model_count": 9460
},
{
"url": "/Streaming/Channels/[CHANNEL]03",
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"notes": "Hikvision third stream - 1-based channel input (channel 1 -> 103, 2 -> 203)",
"model_count": 9450
},
{
"url": "/ch2",
"type": "FFMPEG",
+1 -4
View File
@@ -9,13 +9,10 @@ services:
image: eduard256/strix:latest
container_name: strix
restart: unless-stopped
ports:
- "4567:4567"
network_mode: host
environment:
- STRIX_LOG_LEVEL=info
- STRIX_LOG_FORMAT=json
networks:
- cameras
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"]
interval: 30s
+1 -3
View File
@@ -7,9 +7,7 @@ services:
# build: .
container_name: strix
restart: unless-stopped
ports:
- "4567:4567"
network_mode: host
environment:
# Logging configuration
+16 -3
View File
@@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.9
require (
github.com/AlexxIT/go2rtc v1.9.14
github.com/IOTechSystems/onvif v1.2.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.28.0
@@ -20,8 +21,20 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
github.com/miekg/dns v1.1.70 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
+37 -9
View File
@@ -1,9 +1,12 @@
github.com/AlexxIT/go2rtc v1.9.14 h1:HjaZ2pR64nTkoTZcKM8Zjybg7swyCZbA8Biru1mbqcY=
github.com/AlexxIT/go2rtc v1.9.14/go.mod h1:fcN11KXBbIcExYRqMVDlW7amLZ4/z+hpr5+318fBA9U=
github.com/IOTechSystems/onvif v1.2.0 h1:vplyPdhFhMRtIdkEbQIkTlrKjXpeDj+WUTt5UW61ZcI=
github.com/IOTechSystems/onvif v1.2.0/go.mod h1:/dTr5BtFaGojYGJ2rEBIVWh3seGIcSuCJhcK9zwTsk0=
github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI=
github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elgs/gostrgen v0.0.0-20220325073726-0c3e00d082f6 h1:x9TA+vnGEyqmWY+eA9HfgxNRkOQqwiEpFE9IPXSGuEA=
@@ -22,36 +25,58 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -60,14 +85,17 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -43,7 +43,7 @@ func NewHealthHandler(version string, logger interface{ Info(string, ...any) })
// ServeHTTP handles health check requests
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
+82
View File
@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"net"
"net/http"
"github.com/eduard256/Strix/internal/camera/discovery"
)
// ProbeHandler handles device probe requests.
// GET /api/v1/probe?ip=192.168.1.50
type ProbeHandler struct {
probeService *discovery.ProbeService
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
}
}
// NewProbeHandler creates a new probe handler.
func NewProbeHandler(
probeService *discovery.ProbeService,
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
},
) *ProbeHandler {
return &ProbeHandler{
probeService: probeService,
logger: logger,
}
}
// ServeHTTP handles probe requests.
func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ip := r.URL.Query().Get("ip")
if ip == "" {
h.sendError(w, "Missing required parameter: ip", http.StatusBadRequest)
return
}
// Validate IP format
if net.ParseIP(ip) == nil {
h.sendError(w, "Invalid IP address: "+ip, http.StatusBadRequest)
return
}
h.logger.Info("probe requested", "ip", ip, "remote_addr", r.RemoteAddr)
// Run probe
result := h.probeService.Probe(r.Context(), ip)
// Send response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(result); err != nil {
h.logger.Error("failed to encode probe response", err)
}
}
// sendError sends a JSON error response.
func (h *ProbeHandler) sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := map[string]interface{}{
"error": true,
"message": message,
"code": statusCode,
}
_ = json.NewEncoder(w).Encode(response)
}
+26 -2
View File
@@ -20,6 +20,7 @@ type Server struct {
loader *database.Loader
searchEngine *database.SearchEngine
scanner *discovery.Scanner
probeService *discovery.ProbeService
sseServer *sse.Server
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
}
@@ -75,6 +76,23 @@ func NewServer(
// Initialize SSE server
sseServer := sse.NewServer(logger)
// Initialize OUI database for vendor identification
ouiDB := discovery.NewOUIDatabase()
if err := ouiDB.LoadFromFile(cfg.Database.OUIPath); err != nil {
logger.Error("failed to load OUI database, vendor lookup will be unavailable", err)
} else {
logger.Info("OUI database loaded", "entries", ouiDB.Size())
}
// Initialize ProbeService with all probers
probers := []discovery.Prober{
&discovery.DNSProber{},
discovery.NewARPProber(ouiDB),
&discovery.MDNSProber{},
&discovery.HTTPProber{},
}
probeService := discovery.NewProbeService(probers, logger)
// Create server
server := &Server{
router: chi.NewRouter(),
@@ -82,6 +100,7 @@ func NewServer(
loader: loader,
searchEngine: searchEngine,
scanner: scanner,
probeService: probeService,
sseServer: sseServer,
logger: logger,
}
@@ -119,14 +138,19 @@ func (s *Server) setupRoutes() {
})
// API routes (mounted at /api/v1 in main.go)
// Health check
s.router.Get("/health", handlers.NewHealthHandler("1.0.0", s.logger).ServeHTTP)
// Health check (GET + HEAD for Docker/CasaOS healthcheck compatibility)
healthHandler := handlers.NewHealthHandler(s.config.Version, s.logger).ServeHTTP
s.router.Get("/health", healthHandler)
s.router.Head("/health", healthHandler)
// Camera search
s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP)
// Stream discovery (SSE)
s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP)
// Device probe (ping + DNS + ARP/OUI + mDNS)
s.router.Get("/probe", handlers.NewProbeHandler(s.probeService, s.logger).ServeHTTP)
}
// ServeHTTP implements http.Handler
+76
View File
@@ -0,0 +1,76 @@
package discovery
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
)
// OUIDatabase provides MAC address prefix to vendor name lookup.
// Data is loaded from a JSON file containing camera/surveillance vendor OUI prefixes.
type OUIDatabase struct {
data map[string]string // "C0:56:E3" -> "Hikvision"
mu sync.RWMutex
}
// NewOUIDatabase creates an empty OUI database.
func NewOUIDatabase() *OUIDatabase {
return &OUIDatabase{
data: make(map[string]string),
}
}
// LoadFromFile loads OUI data from a JSON file.
// Expected format: {"C0:56:E3": "Hikvision", "54:EF:44": "Lumi/Aqara", ...}
func (db *OUIDatabase) LoadFromFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open OUI database: %w", err)
}
defer file.Close()
var data map[string]string
if err := json.NewDecoder(file).Decode(&data); err != nil {
return fmt.Errorf("failed to decode OUI database: %w", err)
}
// Normalize all keys to uppercase
normalized := make(map[string]string, len(data))
for k, v := range data {
normalized[strings.ToUpper(k)] = v
}
db.mu.Lock()
db.data = normalized
db.mu.Unlock()
return nil
}
// LookupVendor returns the vendor name for a given MAC address.
// MAC can be in any format: "C0:56:E3:AA:BB:CC", "c0:56:e3:aa:bb:cc", "C0-56-E3-AA-BB-CC".
// Returns empty string if not found.
func (db *OUIDatabase) LookupVendor(mac string) string {
if len(mac) < 8 {
return ""
}
// Normalize: uppercase and replace dashes with colons
prefix := strings.ToUpper(mac[:8])
prefix = strings.ReplaceAll(prefix, "-", ":")
db.mu.RLock()
vendor := db.data[prefix]
db.mu.RUnlock()
return vendor
}
// Size returns the number of entries in the database.
func (db *OUIDatabase) Size() int {
db.mu.RLock()
defer db.mu.RUnlock()
return len(db.data)
}
+184
View File
@@ -0,0 +1,184 @@
package discovery
import (
"context"
"sync"
"time"
"github.com/eduard256/Strix/internal/models"
)
const (
// ProbeTimeout is the overall timeout for all probes combined.
ProbeTimeout = 3 * time.Second
// ProbeTypeUnreachable indicates the device did not respond to ping.
ProbeTypeUnreachable = "unreachable"
// ProbeTypeStandard indicates a normal IP camera (RTSP/HTTP/ONVIF).
ProbeTypeStandard = "standard"
// ProbeTypeHomeKit indicates an Apple HomeKit camera that needs PIN pairing.
ProbeTypeHomeKit = "homekit"
)
// Prober is an interface for network probe implementations.
// Each prober discovers specific information about a device at a given IP.
// New probers can be added by implementing this interface and registering
// them with ProbeService.
type Prober interface {
// Name returns a unique identifier for this prober (e.g., "dns", "arp", "mdns").
Name() string
// Probe runs the probe against the given IP address.
// Must respect context cancellation/timeout.
// Returns nil result if nothing was found (not an error).
Probe(ctx context.Context, ip string) (any, error)
}
// ProbeService orchestrates multiple probers to gather information about a device.
// It first pings the device, then runs all registered probers in parallel.
type ProbeService struct {
pinger *PingProber
probers []Prober
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
}
}
// NewProbeService creates a new ProbeService with the given probers.
// The ping prober is always included and runs first.
func NewProbeService(
probers []Prober,
logger interface {
Debug(string, ...any)
Error(string, error, ...any)
Info(string, ...any)
},
) *ProbeService {
return &ProbeService{
pinger: &PingProber{},
probers: probers,
logger: logger,
}
}
// Probe runs ping + all registered probers against the given IP.
// Overall timeout is 3 seconds. Results are collected from whatever
// finishes in time; slow probers are omitted (nil in response).
func (s *ProbeService) Probe(ctx context.Context, ip string) *models.ProbeResponse {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
response := &models.ProbeResponse{
IP: ip,
Type: ProbeTypeStandard,
}
// Step 1: Ping
s.logger.Debug("probing device", "ip", ip)
pingResult, err := s.pinger.Ping(ctx, ip)
if err != nil || !pingResult.Reachable {
errMsg := "device unreachable"
if err != nil {
errMsg = err.Error()
}
s.logger.Debug("ping failed", "ip", ip, "error", errMsg)
response.Reachable = false
response.Type = ProbeTypeUnreachable
response.Error = errMsg
return response
}
response.Reachable = true
response.LatencyMs = pingResult.LatencyMs
s.logger.Debug("ping OK", "ip", ip, "latency_ms", pingResult.LatencyMs)
// Step 2: Run all probers in parallel
type probeResult struct {
name string
data any
err error
}
results := make(chan probeResult, len(s.probers))
var wg sync.WaitGroup
for _, p := range s.probers {
wg.Add(1)
go func(prober Prober) {
defer wg.Done()
data, err := prober.Probe(ctx, ip)
results <- probeResult{
name: prober.Name(),
data: data,
err: err,
}
}(p)
}
// Close results channel when all probers finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
if r.err != nil {
s.logger.Debug("prober failed", "prober", r.name, "error", r.err.Error())
continue
}
if r.data == nil {
continue
}
switch r.name {
case "dns":
if v, ok := r.data.(*models.DNSProbeResult); ok {
response.Probes.DNS = v
}
case "arp":
if v, ok := r.data.(*models.ARPProbeResult); ok {
response.Probes.ARP = v
}
case "mdns":
if v, ok := r.data.(*models.MDNSProbeResult); ok {
response.Probes.MDNS = v
}
case "http":
if v, ok := r.data.(*models.HTTPProbeResult); ok {
response.Probes.HTTP = v
}
}
}
// Step 3: Determine type based on probe results
response.Type = s.determineType(response)
s.logger.Info("probe completed",
"ip", ip,
"reachable", response.Reachable,
"type", response.Type,
"latency_ms", response.LatencyMs,
)
return response
}
// determineType decides the device type based on collected probe results.
func (s *ProbeService) determineType(response *models.ProbeResponse) string {
if !response.Reachable {
return ProbeTypeUnreachable
}
// HomeKit camera that is not yet paired
if response.Probes.MDNS != nil && !response.Probes.MDNS.Paired {
category := response.Probes.MDNS.Category
if category == "camera" || category == "doorbell" {
return ProbeTypeHomeKit
}
}
return ProbeTypeStandard
}
+80
View File
@@ -0,0 +1,80 @@
package discovery
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/eduard256/Strix/internal/models"
)
// ARPProber looks up the MAC address from the system ARP table
// and resolves it to a vendor name using the OUI database.
type ARPProber struct {
ouiDB *OUIDatabase
}
// NewARPProber creates a new ARP prober with the given OUI database.
func NewARPProber(ouiDB *OUIDatabase) *ARPProber {
return &ARPProber{ouiDB: ouiDB}
}
func (p *ARPProber) Name() string { return "arp" }
// Probe looks up the MAC address for the given IP in the ARP table.
// Returns nil if the IP is not in the ARP table (e.g., different subnet, VPN).
// This only works on Linux (reads /proc/net/arp).
func (p *ARPProber) Probe(ctx context.Context, ip string) (any, error) {
mac, err := p.lookupARP(ip)
if err != nil || mac == "" {
return nil, nil // Not in ARP table is not an error
}
vendor := ""
if p.ouiDB != nil {
vendor = p.ouiDB.LookupVendor(mac)
}
return &models.ARPProbeResult{
MAC: mac,
Vendor: vendor,
}, nil
}
// lookupARP reads /proc/net/arp to find the MAC address for the given IP.
//
// Format of /proc/net/arp:
//
// IP address HW type Flags HW address Mask Device
// 192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
func (p *ARPProber) lookupARP(ip string) (string, error) {
file, err := os.Open("/proc/net/arp")
if err != nil {
return "", fmt.Errorf("failed to open ARP table: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Scan() // Skip header line
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 4 {
continue
}
// fields[0] = IP address, fields[3] = HW address
if fields[0] == ip {
mac := fields[3]
// "00:00:00:00:00:00" means incomplete ARP entry
if mac == "00:00:00:00:00:00" {
return "", nil
}
return strings.ToUpper(mac), nil
}
}
return "", nil
}
+36
View File
@@ -0,0 +1,36 @@
package discovery
import (
"context"
"net"
"strings"
"github.com/eduard256/Strix/internal/models"
)
// DNSProber performs reverse DNS lookup to find the hostname of a device.
type DNSProber struct{}
func (p *DNSProber) Name() string { return "dns" }
// Probe performs a reverse DNS lookup on the given IP.
// Returns nil if no hostname is found (not an error).
func (p *DNSProber) Probe(ctx context.Context, ip string) (any, error) {
resolver := net.DefaultResolver
names, err := resolver.LookupAddr(ctx, ip)
if err != nil || len(names) == 0 {
return nil, nil // No hostname found is not an error
}
// LookupAddr returns FQDNs with trailing dot, remove it
hostname := strings.TrimSuffix(names[0], ".")
if hostname == "" {
return nil, nil
}
return &models.DNSProbeResult{
Hostname: hostname,
}, nil
}
+87
View File
@@ -0,0 +1,87 @@
package discovery
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"github.com/eduard256/Strix/internal/models"
)
// HTTPProber identifies the device by checking HTTP server headers.
// It sends HEAD and GET requests in parallel to port 80 (some devices
// like XMEye/JAWS don't respond to HEAD), and returns whichever
// responds first.
type HTTPProber struct{}
func (p *HTTPProber) Name() string { return "http" }
// Probe sends parallel HEAD+GET to port 80 and extracts Server header.
// Returns nil if no HTTP server is found.
func (p *HTTPProber) Probe(ctx context.Context, ip string) (any, error) {
ports := []int{80, 8080}
client := &http.Client{
// Don't follow redirects -- we want the original response headers
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
type result struct {
resp *http.Response
port int
err error
}
for _, port := range ports {
url := fmt.Sprintf("http://%s:%d/", ip, port)
ch := make(chan result, 2)
// HEAD and GET in parallel -- take whichever responds first
for _, method := range []string{"HEAD", "GET"} {
go func(method string) {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
ch <- result{err: err}
return
}
req.Header.Set("User-Agent", "Strix/1.0")
resp, err := client.Do(req)
ch <- result{resp: resp, port: port, err: err}
}(method)
}
// Wait for first success
for i := 0; i < 2; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
if r.err != nil {
continue
}
if r.resp.Body != nil {
r.resp.Body.Close()
}
server := r.resp.Header.Get("Server")
if server == "" && r.resp.StatusCode == 0 {
continue
}
return &models.HTTPProbeResult{
Port: r.port,
StatusCode: r.resp.StatusCode,
Server: server,
}, nil
}
}
}
return nil, nil
}
+95
View File
@@ -0,0 +1,95 @@
package discovery
import (
"context"
"time"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/AlexxIT/go2rtc/pkg/mdns"
"github.com/eduard256/Strix/internal/models"
)
const (
// mdnsTimeout is the maximum time to wait for mDNS response.
// HomeKit devices respond in 2-10ms. If no response in 100ms,
// the device is definitely not a HomeKit camera.
// The underlying mdns.Query has a 1s internal timeout, but we
// cut it short with this context-based wrapper.
mdnsTimeout = 100 * time.Millisecond
)
// MDNSProber performs mDNS unicast query to detect HomeKit devices.
// It sends a DNS query to ip:5353 for the _hap._tcp.local. service
// and parses TXT records to extract device information.
// Uses a 100ms timeout wrapper around go2rtc's mdns.Query to avoid
// waiting the full 1s on non-HomeKit devices.
type MDNSProber struct{}
func (p *MDNSProber) Name() string { return "mdns" }
// Probe queries the device for HomeKit (HAP) mDNS service.
// Returns nil if the device does not advertise HomeKit or is not a camera/doorbell.
func (p *MDNSProber) Probe(ctx context.Context, ip string) (any, error) {
// Run mdns.Query in a goroutine with 100ms timeout.
// mdns.Query has an internal 1s timeout and doesn't accept context,
// so we wrap it. The background goroutine will clean up on its own
// after the internal timeout expires (~1s, negligible resource cost).
type queryResult struct {
entry *mdns.ServiceEntry
err error
}
ch := make(chan queryResult, 1)
go func() {
entry, err := mdns.Query(ip, mdns.ServiceHAP)
ch <- queryResult{entry, err}
}()
// Wait for result or timeout
timer := time.NewTimer(mdnsTimeout)
defer timer.Stop()
var entry *mdns.ServiceEntry
select {
case r := <-ch:
if r.err != nil || r.entry == nil {
return nil, nil
}
entry = r.entry
case <-timer.C:
return nil, nil // No response within 100ms -- not a HomeKit device
case <-ctx.Done():
return nil, nil
}
// Check if it's complete (has IP, port, and TXT records)
if !entry.Complete() {
return nil, nil
}
// Check if it's a camera or doorbell
category := entry.Info[hap.TXTCategory]
if category != hap.CategoryCamera && category != hap.CategoryDoorbell {
return nil, nil // Not a camera/doorbell, ignore
}
// Map category ID to human-readable name
categoryName := "camera"
if category == hap.CategoryDoorbell {
categoryName = "doorbell"
}
// Determine paired status: sf=0 means paired, sf=1 means not paired
paired := entry.Info[hap.TXTStatusFlags] == hap.StatusPaired
return &models.MDNSProbeResult{
Name: entry.Name,
DeviceID: entry.Info[hap.TXTDeviceID],
Model: entry.Info[hap.TXTModel],
Category: categoryName,
Paired: paired,
Port: int(entry.Port),
Feature: entry.Info[hap.TXTFeatureFlags],
}, nil
}
+127
View File
@@ -0,0 +1,127 @@
package discovery
import (
"context"
"fmt"
"net"
"time"
)
// PingResult contains the result of a ping probe.
type PingResult struct {
Reachable bool
LatencyMs float64
}
// PingProber checks if a device is reachable on the network.
// It tries ICMP ping first (requires root/CAP_NET_RAW), then falls back
// to TCP connect on common camera ports (80, 554, 443, 8080).
type PingProber struct{}
// Ping checks if the device at the given IP is reachable.
func (p *PingProber) Ping(ctx context.Context, ip string) (*PingResult, error) {
// Try ICMP first (works if running as root or with CAP_NET_RAW)
result, err := p.tryICMP(ctx, ip)
if err == nil {
return result, nil
}
// Fallback: TCP connect on common camera ports
result, err = p.tryTCP(ctx, ip)
if err == nil {
return result, nil
}
return &PingResult{Reachable: false}, fmt.Errorf("device unreachable: %s", ip)
}
// tryICMP attempts an ICMP ping using raw socket.
func (p *PingProber) tryICMP(ctx context.Context, ip string) (*PingResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(2 * time.Second)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
// Cap ICMP timeout to 2 seconds to leave time for other probes
if timeout > 2*time.Second {
timeout = 2 * time.Second
}
start := time.Now()
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
if err != nil {
return nil, err
}
conn.Close()
return &PingResult{
Reachable: true,
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
}, nil
}
// tryTCP attempts TCP connect on common camera ports as a ping fallback.
// This works without root privileges and is reliable for cameras since
// they almost always have at least one of these ports open.
func (p *PingProber) tryTCP(ctx context.Context, ip string) (*PingResult, error) {
commonPorts := []int{80, 554, 443, 8080, 8443, 34567, 5353}
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(2 * time.Second)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
// Cap per-port timeout
perPortTimeout := timeout / time.Duration(len(commonPorts))
if perPortTimeout > 500*time.Millisecond {
perPortTimeout = 500 * time.Millisecond
}
type tcpResult struct {
latency time.Duration
err error
}
results := make(chan tcpResult, len(commonPorts))
for _, port := range commonPorts {
go func(port int) {
addr := fmt.Sprintf("%s:%d", ip, port)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, perPortTimeout)
if err != nil {
results <- tcpResult{err: err}
return
}
conn.Close()
results <- tcpResult{latency: time.Since(start)}
}(port)
}
// Wait for first success or all failures
var lastErr error
for range commonPorts {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-results:
if r.err == nil {
return &PingResult{
Reachable: true,
LatencyMs: float64(r.latency.Microseconds()) / 1000.0,
}, nil
}
lastErr = r.err
}
}
return nil, fmt.Errorf("all TCP ports closed: %w", lastErr)
}
+13 -4
View File
@@ -302,11 +302,13 @@ func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscovery
"model", req.Model,
"limit", req.ModelLimit)
// Search for similar models
cameras, err := s.searchEngine.SearchByModel(req.Model, 0.8, req.ModelLimit)
// Search for cameras using intelligent brand+model search
searchResp, err := s.searchEngine.Search(req.Model, req.ModelLimit)
if err != nil {
s.logger.Error("model search failed", err)
} else {
cameras := searchResp.Cameras
// Collect entries from all matching cameras
var entries []models.CameraEntry
for _, camera := range cameras {
@@ -409,7 +411,14 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
defer cancelProgress()
go func() {
ticker := time.NewTicker(1 * time.Second)
// Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
// Normal mode: 1 second, Ingress mode: 3 seconds
progressInterval := 1 * time.Second
if streamWriter.IsIngress() {
progressInterval = 3 * time.Second
}
ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for {
@@ -417,7 +426,7 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
case <-progressCtx.Done():
return
case <-ticker.C:
// Send progress every second to prevent WriteTimeout
// Send progress to prevent WriteTimeout and show scanning activity
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
Tested: int(atomic.LoadInt32(&tested)),
Found: int(atomic.LoadInt32(&found)),
+6 -2
View File
@@ -176,8 +176,12 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
replacements := map[string]string{
"[CHANNEL]": strconv.Itoa(ctx.Channel),
"[channel]": strconv.Itoa(ctx.Channel),
"{channel}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
"{CHANNEL}": strconv.Itoa(ctx.Channel),
"[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1), // For Hikvision-style channels (101, 201, 301...)
"[channel+1]": strconv.Itoa(ctx.Channel + 1),
"{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
"{channel}": strconv.Itoa(ctx.Channel),
"{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
"{channel+1}": strconv.Itoa(ctx.Channel + 1),
"[WIDTH]": strconv.Itoa(ctx.Width),
"[width]": strconv.Itoa(ctx.Width),
"[HEIGHT]": strconv.Itoa(ctx.Height),
+47 -1
View File
@@ -1,6 +1,7 @@
package config
import (
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -14,6 +15,7 @@ import (
// Config holds application configuration
type Config struct {
Version string // Application version, set by caller after Load()
Server ServerConfig
Database DatabaseConfig
Scanner ScannerConfig
@@ -33,6 +35,7 @@ type DatabaseConfig struct {
BrandsPath string
PatternsPath string
ParametersPath string
OUIPath string
CacheEnabled bool
CacheTTL time.Duration
}
@@ -80,6 +83,7 @@ func Load() *Config {
BrandsPath: filepath.Join(dataPath, "brands"),
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
CacheEnabled: true,
CacheTTL: 5 * time.Minute,
},
@@ -102,8 +106,14 @@ func Load() *Config {
},
}
// Load from strix.yaml if exists
// Load from Home Assistant options.json if running as HA add-on
// Priority: defaults < HA options < strix.yaml < ENV
configSource := "default"
if err := loadHAOptions(cfg); err == nil {
configSource = "/data/options.json (Home Assistant)"
}
// Load from strix.yaml if exists (overrides HA options)
if err := loadYAML(cfg); err == nil {
configSource = "strix.yaml"
}
@@ -148,6 +158,42 @@ func loadYAML(cfg *Config) error {
return nil
}
// haOptions represents the structure of Home Assistant /data/options.json.
// When Strix runs as a Home Assistant add-on, HA creates this file from the
// add-on configuration UI. Fields are optional -- zero values are ignored.
type haOptions struct {
LogLevel string `json:"log_level"`
Port int `json:"port"`
}
// loadHAOptions loads configuration from Home Assistant's /data/options.json.
// This file only exists when running inside the HA add-on environment.
// Returns an error if the file doesn't exist or can't be parsed (callers
// should treat errors as "not running in HA" and silently continue).
func loadHAOptions(cfg *Config) error {
data, err := os.ReadFile("/data/options.json")
if err != nil {
return err
}
var opts haOptions
if err := json.Unmarshal(data, &opts); err != nil {
return fmt.Errorf("failed to parse /data/options.json: %w", err)
}
if opts.LogLevel != "" {
cfg.Logger.Level = opts.LogLevel
}
if opts.Port > 0 {
cfg.Server.Listen = fmt.Sprintf(":%d", opts.Port)
}
// Home Assistant add-on always uses JSON logging for the HA log viewer
cfg.Logger.Format = "json"
return nil
}
// validateListen validates the listen address format and port range
func validateListen(listen string) error {
if listen == "" {
+53
View File
@@ -0,0 +1,53 @@
package models
// ProbeResponse represents the result of probing an IP address.
// The Type field determines which UI flow the frontend should use:
// - "unreachable" -- device did not respond to ping
// - "standard" -- normal IP camera (RTSP/HTTP/ONVIF)
// - "homekit" -- Apple HomeKit camera (needs PIN pairing)
type ProbeResponse struct {
IP string `json:"ip"`
Reachable bool `json:"reachable"`
LatencyMs float64 `json:"latency_ms,omitempty"`
Type string `json:"type"`
Error string `json:"error,omitempty"`
Probes ProbeResults `json:"probes"`
}
// ProbeResults contains results from all parallel probers.
// Nil fields mean the prober did not find anything or timed out.
type ProbeResults struct {
DNS *DNSProbeResult `json:"dns"`
ARP *ARPProbeResult `json:"arp"`
MDNS *MDNSProbeResult `json:"mdns"`
HTTP *HTTPProbeResult `json:"http"`
}
// HTTPProbeResult contains HTTP server identification from port 80.
type HTTPProbeResult struct {
Port int `json:"port"`
StatusCode int `json:"status_code"`
Server string `json:"server"`
}
// DNSProbeResult contains reverse DNS lookup result.
type DNSProbeResult struct {
Hostname string `json:"hostname"`
}
// ARPProbeResult contains ARP table lookup + OUI vendor identification.
type ARPProbeResult struct {
MAC string `json:"mac"`
Vendor string `json:"vendor"`
}
// MDNSProbeResult contains mDNS service discovery result (HomeKit).
type MDNSProbeResult struct {
Name string `json:"name"`
DeviceID string `json:"device_id"`
Model string `json:"model"`
Category string `json:"category"` // "camera", "doorbell"
Paired bool `json:"paired"`
Port int `json:"port"`
Feature string `json:"feature"`
}
+63 -1
View File
@@ -5,9 +5,20 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
const (
// IngressPaddingSize is the padding size for Home Assistant Ingress mode.
// HA Supervisor uses aiohttp with 64KB buffer for StreamResponse.
// We need to fill this buffer to force immediate delivery of SSE events.
IngressPaddingSize = 64 * 1024 // 64KB
// IngressHeader is the header that Home Assistant Ingress adds to requests
IngressHeader = "X-Ingress-Path"
)
// Event represents a Server-Sent Event
type Event struct {
ID string
@@ -255,6 +266,7 @@ func generateClientID() string {
type StreamWriter struct {
client *Client
server *Server
isIngress bool // True when running through Home Assistant Ingress proxy
}
// NewStreamWriter creates a new stream writer for a client
@@ -275,6 +287,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
// Send initial flush to establish connection
flusher.Flush()
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
isIngress := r.Header.Get(IngressHeader) != ""
// Create client
ctx, cancel := context.WithCancel(r.Context())
client := &Client{
@@ -289,6 +304,7 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
return &StreamWriter{
client: client,
server: s,
isIngress: isIngress,
}, nil
}
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
return fmt.Errorf("response does not support flushing")
}
return sw.server.writeEvent(sw.client.Response, flusher, event)
// Use Ingress-aware write method
return sw.writeEventWithIngress(sw.client.Response, flusher, event)
}
// writeEventWithIngress writes an event and adds padding for Ingress mode
func (sw *StreamWriter) writeEventWithIngress(w http.ResponseWriter, flusher http.Flusher, event Event) error {
// Write the event using standard method
if err := sw.server.writeEvent(w, flusher, event); err != nil {
return err
}
// In Ingress mode, add padding to fill the 64KB buffer and force immediate delivery
if sw.isIngress {
if err := sw.writePadding(w, flusher); err != nil {
return err
}
}
return nil
}
// writePadding writes SSE comment padding to fill proxy buffers.
// SSE comments (lines starting with ':') are ignored by clients.
func (sw *StreamWriter) writePadding(w http.ResponseWriter, flusher http.Flusher) error {
// Create padding using SSE comments which are ignored by clients
// Each line is ": " + padding content + "\n"
// We need ~64KB to fill the aiohttp StreamResponse buffer
const lineSize = 1024 // 1KB per line
const numLines = 64 // 64 lines = 64KB
paddingLine := ": " + strings.Repeat(".", lineSize-4) + "\n" // -4 for ": " and "\n"
for i := 0; i < numLines; i++ {
if _, err := fmt.Fprint(w, paddingLine); err != nil {
return err
}
}
// Flush the padding
flusher.Flush()
return nil
}
// SendJSON sends JSON data as an event
@@ -312,6 +369,11 @@ func (sw *StreamWriter) SendJSON(eventType string, v interface{}) error {
return sw.SendEvent(eventType, v)
}
// IsIngress returns true if running through Home Assistant Ingress proxy
func (sw *StreamWriter) IsIngress() bool {
return sw.isIngress
}
// SendMessage sends a simple message
func (sw *StreamWriter) SendMessage(message string) error {
return sw.SendEvent("message", map[string]string{"message": message})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "webui",
"version": "1.0.4",
"version": "0.0.0",
"type": "module",
"description": "",
"main": "index.js",
+210 -1
View File
@@ -590,10 +590,155 @@ body {
.streams-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
gap: var(--space-6);
padding: var(--space-2);
}
/* ===== STREAM GROUPS ===== */
.stream-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stream-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
}
.stream-group-header:hover {
color: var(--purple-primary);
}
.stream-group-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-tertiary);
transition: all var(--transition-fast);
}
.stream-group-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-group.collapsed .stream-group-toggle .chevron {
transform: rotate(-90deg);
}
.stream-group.collapsed .stream-group-content {
display: none;
}
.stream-group-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stream-group-count {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-tertiary);
}
.stream-group-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stream-group-empty {
padding: var(--space-4);
text-align: center;
color: var(--text-tertiary);
font-size: var(--text-sm);
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: 8px;
}
/* ===== STREAM SUBGROUPS (Main/Sub/Other within Recommended) ===== */
.stream-subgroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.stream-subgroup:not(:last-child) {
margin-bottom: var(--space-4);
}
.stream-subgroup-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding-left: var(--space-2);
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
}
.stream-subgroup-header:hover {
color: var(--purple-primary);
}
.stream-subgroup-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-tertiary);
transition: all var(--transition-fast);
}
.stream-subgroup-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-subgroup.collapsed .stream-subgroup-toggle .chevron {
transform: rotate(-90deg);
}
.stream-subgroup.collapsed .stream-subgroup-content {
display: none;
}
.stream-subgroup-title {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stream-subgroup-count {
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-disabled);
}
.stream-subgroup-content {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Custom scrollbar */
.streams-list::-webkit-scrollbar {
width: 8px;
@@ -948,6 +1093,70 @@ body {
display: none;
}
/* ===== MODAL ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
opacity: 0;
transition: opacity var(--transition-base);
padding: var(--space-4);
}
.modal-overlay.show {
opacity: 1;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: var(--space-8);
max-width: 400px;
width: 100%;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: transform var(--transition-base);
text-align: center;
}
.modal-overlay.show .modal {
transform: scale(1);
}
.modal-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--error);
margin-bottom: var(--space-4);
}
.modal-message {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-8);
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: var(--space-3);
}
.modal-actions .btn {
flex: 1;
padding: var(--space-4);
}
/* ===== ANIMATIONS ===== */
@keyframes fadeIn {
from {
+3
View File
@@ -643,6 +643,9 @@
</div>
</div>
<!-- Modal -->
<div id="modal-overlay" class="modal-overlay hidden"></div>
<!-- Toast Notification -->
<div id="toast" class="toast hidden"></div>
+24
View File
@@ -0,0 +1,24 @@
export class ProbeAPI {
constructor(baseURL = '') {
this.baseURL = baseURL;
}
/**
* Probe a device at the given IP address.
* Returns device info: reachable status, vendor, hostname, mDNS data.
* @param {string} ip - IP address to probe
* @returns {Promise<Object>} Probe response
*/
async probe(ip) {
const response = await fetch(
`${this.baseURL}api/v1/probe?ip=${encodeURIComponent(ip)}`
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return await response.json();
}
}
@@ -252,6 +252,15 @@ export class FrigateGenerator {
return lines;
}
/**
* Build RTSP path with optional ?mp4 suffix for BUBBLE streams
*/
static buildRtspPath(streamName, streamType) {
const basePath = `rtsp://127.0.0.1:8554/${streamName}`;
// Add ?mp4 parameter only for BUBBLE streams to enable recording in Frigate
return streamType === 'BUBBLE' ? `${basePath}?mp4` : basePath;
}
/**
* Generate camera lines for cameras section
*/
@@ -264,11 +273,14 @@ export class FrigateGenerator {
if (cameraInfo.subStream) {
// Use sub for detect, main for record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.subStreamName}`);
const subPath = this.buildRtspPath(cameraInfo.subStreamName, cameraInfo.subStream.type);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${subPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - record');
@@ -280,7 +292,9 @@ export class FrigateGenerator {
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
} else {
// Use main for both detect and record
lines.push(` - path: rtsp://127.0.0.1:8554/${cameraInfo.mainStreamName}`);
const mainPath = this.buildRtspPath(cameraInfo.mainStreamName, cameraInfo.mainStream.type);
lines.push(` - path: ${mainPath}`);
lines.push(' input_args: preset-rtsp-restream');
lines.push(' roles:');
lines.push(' - detect');
+104 -1
View File
@@ -1,5 +1,6 @@
import { CameraSearchAPI } from './api/camera-search.js';
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
import { ProbeAPI } from './api/probe.js';
import { MockCameraAPI } from './mock/mock-camera-api.js';
import { MockStreamAPI } from './mock/mock-stream-api.js';
import { SearchForm } from './ui/search-form.js';
@@ -7,6 +8,7 @@ import { StreamList } from './ui/stream-list.js';
import { ConfigPanel } from './ui/config-panel.js';
import { FrigateGenerator } from './config-generators/frigate/index.js';
import { showToast } from './utils/toast.js';
import { showModal } from './ui/modal.js';
class StrixApp {
constructor() {
@@ -29,6 +31,9 @@ class StrixApp {
this.streamAPI = new StreamDiscoveryAPI();
}
this.probeAPI = new ProbeAPI();
this.probeResult = null;
this.searchForm = new SearchForm();
this.streamList = new StreamList();
this.configPanel = new ConfigPanel();
@@ -82,6 +87,12 @@ class StrixApp {
// Screen 2: Configuration form
document.getElementById('btn-back-to-address').addEventListener('click', () => {
// Clear probe-filled fields so stale data doesn't persist
document.getElementById('camera-model').value = '';
document.getElementById('camera-model').disabled = false;
document.getElementById('camera-model').placeholder = 'Start typing...';
document.getElementById('model-disabled-hint').classList.add('hidden');
this.probeResult = null;
this.showScreen('address');
});
@@ -170,7 +181,88 @@ class StrixApp {
document.getElementById('address-validated').value = address;
}
// Extract IP for probe (from full URL or raw input)
const probeIP = this.extractIPForProbe(address);
// Probe the device before proceeding
const btn = document.getElementById('btn-check-address');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Checking...';
try {
this.probeResult = await this.probeAPI.probe(probeIP);
if (this.probeResult.reachable) {
// Auto-fill vendor into Camera Model if found
if (this.probeResult.probes.arp && this.probeResult.probes.arp.vendor) {
const modelInput = document.getElementById('camera-model');
if (!modelInput.disabled && !modelInput.value) {
modelInput.value = this.probeResult.probes.arp.vendor;
}
}
this.showScreen('config');
} else {
// Device unreachable -- show modal
const result = await showModal({
title: 'Device Unreachable',
message: `The device at ${probeIP} is not responding. It may be offline, on a different network, or the IP address may be incorrect.`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
// 'change' or null (overlay click) -- stay on address screen
input.focus();
input.select();
}
}
} catch (error) {
// Network/server error -- show modal
const result = await showModal({
title: 'Connection Error',
message: `Could not check the device: ${error.message}`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
input.focus();
}
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
/**
* Extract IP address from input for probe API call.
* Handles plain IPs and full URLs like rtsp://user:pass@192.168.1.50/stream
*/
extractIPForProbe(address) {
if (this.isFullURL(address)) {
try {
const urlObj = new URL(address);
return urlObj.hostname;
} catch (e) {
return address;
}
}
// Remove port if present (e.g., "192.168.1.50:554")
const colonIndex = address.lastIndexOf(':');
if (colonIndex > 0) {
return address.substring(0, colonIndex);
}
return address;
}
isFullURL(str) {
@@ -315,6 +407,11 @@ class StrixApp {
document.getElementById('progress-text').textContent = 'Starting scan...';
document.getElementById('streams-section').classList.add('hidden');
this.currentStreams = [];
// Reset stream list state for fresh discovery
this.streamList.selectionMode = 'main';
this.streamList.collapsedGroups.clear();
this.streamList.collapsedSubgroups.clear();
this.streamList.needsSmartDefaults = true;
}
handleProgress(data) {
@@ -334,7 +431,7 @@ class StrixApp {
streamsSection.classList.remove('hidden');
}
// Update stream list
// Update stream list (smart defaults applied automatically on first render)
this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index);
});
@@ -391,6 +488,12 @@ class StrixApp {
document.getElementById('frigate-output-section').classList.add('hidden');
document.getElementById('config-frigate').textContent = '';
// Set stream list to sub selection mode (will collapse Main, show Sub)
this.streamList.setSelectionMode('sub');
this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index);
});
showToast('Select a sub stream from available streams');
this.showScreen('discovery');
}
+207 -41
View File
@@ -2,6 +2,7 @@
export class MockStreamAPI {
constructor() {
this.mockStreams = [
// RTSP Main streams (1920x1080)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
path: "/Streaming/Channels/101",
@@ -12,6 +13,27 @@ export class MockStreamAPI {
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/live/main",
path: "/live/main",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 30,
bitrate: 4608000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/stream1",
path: "/stream1",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 25,
bitrate: 3584000,
has_audio: true
},
// JPEG snapshots (5 items in different positions)
{
url: "http://192.168.1.100/snap.jpg",
path: "/snap.jpg",
@@ -22,16 +44,124 @@ export class MockStreamAPI {
bitrate: 0,
has_audio: false
},
// RTSP Sub streams (640x480)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/102",
path: "/Streaming/Channels/102",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 5,
bitrate: 512000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/live/sub",
path: "/live/sub",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 768000,
has_audio: false
},
// JPEG #2
{
url: "http://192.168.1.100/cgi-bin/snapshot.cgi",
path: "/cgi-bin/snapshot.cgi",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
{
url: "rtsp://192.168.1.100:554/stream2",
path: "/stream2",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 15,
bitrate: 640000,
has_audio: false
},
// ONVIF streams
{
url: "rtsp://192.168.1.100:554/onvif/profile0",
path: "/onvif/profile0",
type: "ONVIF",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/onvif/profile1",
path: "/onvif/profile1",
type: "ONVIF",
resolution: "640x480",
codec: "H.264",
fps: 15,
bitrate: 512000,
has_audio: false
},
// JPEG #3
{
url: "http://192.168.1.100/image/jpeg.cgi",
path: "/image/jpeg.cgi",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// More RTSP variants
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
path: "/cam/realmonitor?channel=1&subtype=0",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 30,
bitrate: 5120000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=1",
path: "/cam/realmonitor?channel=1&subtype=1",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 512000,
has_audio: false
},
// MJPEG
{
url: "http://192.168.1.100/video.mjpg",
path: "/video.mjpg",
type: "MJPEG",
resolution: "1280x720",
resolution: "1920x1080",
codec: "MJPEG",
fps: 10,
bitrate: 2048000,
bitrate: 3072000,
has_audio: false
},
// JPEG #4
{
url: "http://192.168.1.100/Streaming/channels/1/picture",
path: "/Streaming/channels/1/picture",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// HLS
{
url: "http://192.168.1.100/stream/live.m3u8",
path: "/stream/live.m3u8",
@@ -42,16 +172,18 @@ export class MockStreamAPI {
bitrate: 3072000,
has_audio: true
},
// HTTP Video
{
url: "http://192.168.1.100/videostream.cgi?user=admin&pwd=12345",
path: "/videostream.cgi?user=admin&pwd=12345",
type: "HTTP_VIDEO",
resolution: "1280x960",
resolution: "1920x1080",
codec: "H.264",
fps: 20,
bitrate: 2048000,
bitrate: 2560000,
has_audio: false
},
// BUBBLE
{
url: "bubble://192.168.1.100:34567/bubble/live?ch=0&stream=0",
path: "/bubble/live?ch=0&stream=0",
@@ -59,33 +191,75 @@ export class MockStreamAPI {
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 3072000,
bitrate: 3584000,
has_audio: true
},
// JPEG #5
{
url: "http://192.168.1.100/tmpfs/auto.jpg",
path: "/tmpfs/auto.jpg",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
bitrate: 0,
has_audio: false
},
// Additional RTSP
{
url: "rtsp://192.168.1.100:554/h264_stream",
path: "/h264_stream",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 30,
bitrate: 4096000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/cam/realmonitor?channel=1&subtype=0",
path: "/cam/realmonitor?channel=1&subtype=0",
type: "ONVIF",
resolution: "2560x1440",
url: "rtsp://192.168.1.100:554/av0_0",
path: "/av0_0",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
bitrate: 3840000,
has_audio: true
},
{
url: "rtsp://192.168.1.100:554/av0_1",
path: "/av0_1",
type: "FFMPEG",
resolution: "640x480",
codec: "H.264",
fps: 10,
bitrate: 512000,
has_audio: false
},
{
url: "rtsp://192.168.1.100:554/unicast/c1/s0/live",
path: "/unicast/c1/s0/live",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.265",
fps: 30,
bitrate: 6144000,
fps: 25,
bitrate: 4608000,
has_audio: true
}
];
}
discover(request, callbacks) {
const totalToScan = 150;
const totalToScan = 450;
const streamsToFind = this.mockStreams;
let tested = 0;
let found = 0;
const startTime = Date.now();
// Simulate progressive discovery
const interval = setInterval(() => {
const increment = Math.floor(Math.random() * 8) + 3;
// Simulate progressive discovery - 1 stream per second
const progressInterval = setInterval(() => {
const increment = Math.floor(Math.random() * 15) + 10;
tested = Math.min(tested + increment, totalToScan);
const remaining = totalToScan - tested;
@@ -98,33 +272,9 @@ export class MockStreamAPI {
});
}
// Randomly find streams
if (found < streamsToFind.length && Math.random() > 0.6) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
}
// Complete when done
if (tested >= totalToScan) {
clearInterval(interval);
// Send any remaining streams
while (found < streamsToFind.length) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
}
clearInterval(progressInterval);
const duration = (Date.now() - startTime) / 1000;
@@ -136,7 +286,23 @@ export class MockStreamAPI {
});
}
}
}, 400);
}, 300);
// Find streams at ~1 per second
const streamInterval = setInterval(() => {
if (found < streamsToFind.length) {
const stream = streamsToFind[found];
found++;
if (callbacks.onStreamFound) {
callbacks.onStreamFound({
stream: stream
});
}
} else {
clearInterval(streamInterval);
}
}, 1000);
}
close() {
+83
View File
@@ -0,0 +1,83 @@
/**
* Simple modal dialog component.
* Shows a centered card with title, message, and configurable buttons.
* Returns a Promise that resolves with the clicked button's id.
*
* Usage:
* const result = await showModal({
* title: 'Device Unreachable',
* message: 'This IP is not responding.',
* buttons: [
* { id: 'change', label: 'Change IP', style: 'primary' },
* { id: 'continue', label: 'Continue Anyway', style: 'outline' }
* ]
* });
*/
let currentResolve = null;
export function showModal({ title, message, buttons }) {
return new Promise((resolve) => {
currentResolve = resolve;
const overlay = document.getElementById('modal-overlay');
// Clear previous content safely
overlay.replaceChildren();
// Build modal DOM using safe DOM methods
const modal = document.createElement('div');
modal.className = 'modal';
const titleEl = document.createElement('div');
titleEl.className = 'modal-title';
titleEl.textContent = title;
modal.appendChild(titleEl);
const messageEl = document.createElement('div');
messageEl.className = 'modal-message';
messageEl.textContent = message;
modal.appendChild(messageEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'modal-actions';
buttons.forEach(btnConfig => {
const btn = document.createElement('button');
btn.className = `btn btn-${btnConfig.style || 'outline'}`;
btn.textContent = btnConfig.label;
btn.addEventListener('click', () => {
hideModal();
resolve(btnConfig.id);
});
actionsEl.appendChild(btn);
});
modal.appendChild(actionsEl);
overlay.appendChild(modal);
// Close on overlay click (outside modal)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
hideModal();
resolve(null);
}
});
// Show with animation
overlay.classList.remove('hidden');
requestAnimationFrame(() => {
overlay.classList.add('show');
});
});
}
export function hideModal() {
const overlay = document.getElementById('modal-overlay');
overlay.classList.remove('show');
setTimeout(() => {
overlay.classList.add('hidden');
overlay.replaceChildren();
}, 200);
currentResolve = null;
}
+300 -3
View File
@@ -4,19 +4,277 @@ export class StreamList {
this.streams = [];
this.onUseCallback = null;
this.expandedIndex = null;
// Track collapsed state for groups and subgroups
this.collapsedGroups = new Set();
this.collapsedSubgroups = new Set();
// Selection mode: 'main' or 'sub'
this.selectionMode = 'main';
// Flag to apply smart defaults on first render after reset
this.needsSmartDefaults = true;
}
/**
* Set selection mode and apply smart defaults for collapsed state
* Only resets collapsed state when mode actually changes
*/
setSelectionMode(mode) {
if (this.selectionMode === mode) return;
this.selectionMode = mode;
this.applySmartDefaults();
}
/**
* Apply smart collapsed defaults based on current selection mode and available streams
*/
applySmartDefaults() {
// Get current stream classification
const recommended = this.streams.filter(s => this.isRecommended(s));
const { main, sub, other } = this.classifyRecommendedStreams(
recommended.map((stream, i) => ({ stream, index: i }))
);
// Reset all collapsed states
this.collapsedGroups.clear();
this.collapsedSubgroups.clear();
if (this.selectionMode === 'main') {
// Main mode: show Main, collapse Sub/Other/Alternative
if (main.length > 0) {
// Has main streams - collapse everything except Main
this.collapsedGroups.add('alternative');
this.collapsedSubgroups.add('recommended-sub');
this.collapsedSubgroups.add('recommended-other');
}
// If no main streams - leave everything open
} else {
// Sub mode: show Sub, collapse Main/Other/Alternative
if (sub.length > 0) {
// Has sub streams - collapse everything except Sub
this.collapsedGroups.add('alternative');
this.collapsedSubgroups.add('recommended-main');
this.collapsedSubgroups.add('recommended-other');
}
// If no sub streams - leave everything open
}
}
// Stream types considered "recommended" (standard video streams)
static RECOMMENDED_TYPES = ['FFMPEG', 'ONVIF'];
// Minimum width threshold for Main streams (HD quality)
static MIN_MAIN_WIDTH = 720;
// Minimum gap between resolutions to split Main/Sub
static MIN_GAP_FOR_SPLIT = 400;
isRecommended(stream) {
return StreamList.RECOMMENDED_TYPES.includes(stream.type);
}
/**
* Parse resolution string "1920x1080" to width number
* Returns null if resolution is missing or invalid
*/
parseResolutionWidth(resolution) {
if (!resolution) return null;
const match = resolution.match(/^(\d+)x(\d+)$/);
if (!match) return null;
return parseInt(match[1], 10);
}
/**
* Classify recommended streams into Main/Sub/Other using clustering algorithm
*
* Algorithm:
* 1. Streams with width >= 720 are candidates for Main
* 2. Streams with width < 720 go to Sub
* 3. Streams without resolution go to Other
* 4. Among Main candidates, find max gap between sorted resolutions
* 5. If gap > 400px, split into Main (higher) and Sub (lower)
*/
classifyRecommendedStreams(items) {
const main = [];
const sub = [];
const other = [];
// First pass: separate by resolution availability and threshold
const mainCandidates = []; // width >= 720
items.forEach(item => {
const width = this.parseResolutionWidth(item.stream.resolution);
if (width === null) {
other.push(item);
} else if (width < StreamList.MIN_MAIN_WIDTH) {
sub.push(item);
} else {
mainCandidates.push({ ...item, width });
}
});
// If no main candidates or only one, no need to cluster
if (mainCandidates.length <= 1) {
mainCandidates.forEach(item => main.push({ stream: item.stream, index: item.index }));
return { main, sub, other };
}
// Sort candidates by width descending
mainCandidates.sort((a, b) => b.width - a.width);
// Find the largest gap between adjacent resolutions
let maxGap = 0;
let splitIndex = -1;
for (let i = 0; i < mainCandidates.length - 1; i++) {
const gap = mainCandidates[i].width - mainCandidates[i + 1].width;
if (gap > maxGap) {
maxGap = gap;
splitIndex = i;
}
}
// If max gap is significant, split into Main and Sub
if (maxGap > StreamList.MIN_GAP_FOR_SPLIT && splitIndex >= 0) {
mainCandidates.forEach((item, i) => {
const cleanItem = { stream: item.stream, index: item.index };
if (i <= splitIndex) {
main.push(cleanItem);
} else {
sub.push(cleanItem);
}
});
} else {
// All candidates stay in Main
mainCandidates.forEach(item => {
main.push({ stream: item.stream, index: item.index });
});
}
return { main, sub, other };
}
render(streams, onUseCallback) {
this.streams = streams;
this.onUseCallback = onUseCallback;
// Render stream items
this.listContainer.innerHTML = streams.map((stream, index) => this.renderItem(stream, index)).join('');
// Apply smart defaults on first render after reset
if (this.needsSmartDefaults && streams.length > 0) {
this.needsSmartDefaults = false;
this.applySmartDefaults();
}
// Split streams into groups while preserving original indices
const recommended = [];
const alternative = [];
streams.forEach((stream, index) => {
if (this.isRecommended(stream)) {
recommended.push({ stream, index });
} else {
alternative.push({ stream, index });
}
});
// Render only non-empty groups
let html = '';
if (recommended.length > 0) {
html += this.renderRecommendedGroup(recommended);
}
if (alternative.length > 0) {
html += this.renderGroup('Alternative', alternative, 'alternative');
}
this.listContainer.innerHTML = html;
// Attach event listeners
this.attachEventListeners();
}
/**
* Render Recommended group with Main/Sub/Other subgroups
*/
renderRecommendedGroup(items) {
const { main, sub, other } = this.classifyRecommendedStreams(items);
const totalCount = items.length;
const isCollapsed = this.collapsedGroups.has('recommended');
let subgroupsHtml = '';
if (main.length > 0) {
subgroupsHtml += this.renderSubgroup('Main', main, 'recommended');
}
if (sub.length > 0) {
subgroupsHtml += this.renderSubgroup('Sub', sub, 'recommended');
}
if (other.length > 0) {
subgroupsHtml += this.renderSubgroup('Other', other, 'recommended');
}
return `
<div class="stream-group stream-group-recommended ${isCollapsed ? 'collapsed' : ''}">
<div class="stream-group-header" data-group="recommended">
<button class="stream-group-toggle" aria-label="Toggle group">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-group-title">Recommended</span>
<span class="stream-group-count">(${totalCount})</span>
</div>
<div class="stream-group-content">
${subgroupsHtml}
</div>
</div>
`;
}
/**
* Render a subgroup (Main/Sub/Other) within Recommended
*/
renderSubgroup(title, items, parentGroup) {
const subgroupKey = `${parentGroup}-${title.toLowerCase()}`;
const isCollapsed = this.collapsedSubgroups.has(subgroupKey);
return `
<div class="stream-subgroup ${isCollapsed ? 'collapsed' : ''}" data-subgroup="${subgroupKey}">
<div class="stream-subgroup-header" data-subgroup="${subgroupKey}">
<button class="stream-subgroup-toggle" aria-label="Toggle subgroup">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" class="chevron">
<path d="M2.5 3.75l2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-subgroup-title">${title}</span>
<span class="stream-subgroup-count">(${items.length})</span>
</div>
<div class="stream-subgroup-content">
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
</div>
</div>
`;
}
renderGroup(title, items, groupClass) {
const count = items.length;
const isCollapsed = this.collapsedGroups.has(groupClass);
return `
<div class="stream-group stream-group-${groupClass} ${isCollapsed ? 'collapsed' : ''}">
<div class="stream-group-header" data-group="${groupClass}">
<button class="stream-group-toggle" aria-label="Toggle group">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<span class="stream-group-title">${title}</span>
<span class="stream-group-count">(${count})</span>
</div>
<div class="stream-group-content">
${items.map(({ stream, index }) => this.renderItem(stream, index)).join('')}
</div>
</div>
`;
}
renderItem(stream, index) {
const icon = this.getStreamIcon(stream.type);
const isExpanded = this.expandedIndex === index;
@@ -225,7 +483,28 @@ export class StreamList {
}
attachEventListeners() {
// Click on header to toggle
// Group header toggle (Recommended, Alternative)
this.listContainer.querySelectorAll('.stream-group-header').forEach(header => {
header.addEventListener('click', (e) => {
const groupKey = header.dataset.group;
if (groupKey) {
this.toggleGroup(groupKey);
}
});
});
// Subgroup header toggle (Main, Sub, Other)
this.listContainer.querySelectorAll('.stream-subgroup-header').forEach(header => {
header.addEventListener('click', (e) => {
e.stopPropagation(); // Don't bubble to group header
const subgroupKey = header.dataset.subgroup;
if (subgroupKey) {
this.toggleSubgroup(subgroupKey);
}
});
});
// Click on stream item header to toggle details
this.listContainer.querySelectorAll('.stream-item-header').forEach(header => {
header.addEventListener('click', (e) => {
// Don't toggle if clicking "Use Stream" button
@@ -250,6 +529,24 @@ export class StreamList {
});
}
toggleGroup(groupKey) {
if (this.collapsedGroups.has(groupKey)) {
this.collapsedGroups.delete(groupKey);
} else {
this.collapsedGroups.add(groupKey);
}
this.render(this.streams, this.onUseCallback);
}
toggleSubgroup(subgroupKey) {
if (this.collapsedSubgroups.has(subgroupKey)) {
this.collapsedSubgroups.delete(subgroupKey);
} else {
this.collapsedSubgroups.add(subgroupKey);
}
this.render(this.streams, this.onUseCallback);
}
toggleExpand(index) {
if (this.expandedIndex === index) {
// Collapse if already expanded