52 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
eduard256 11e6ba9902 Merge develop: Fix SSE timeout issues 2025-11-22 20:35:48 +03:00
eduard256 a6e9cc2c5e Fix SSE timeout issues with long-running stream discovery
Problem:
- WriteTimeout was 30 seconds
- Progress only sent when values changed
- Long ffprobe tests (7-8s each) could cause 30+ seconds without writes
- Result: "curl: (18) transfer closed with outstanding read data remaining"

Solution:
- Increase WriteTimeout from 30s to 5 minutes
- Send progress every 1 second (instead of 3 seconds)
- Always send progress, even if values unchanged
- Guarantees write every second, preventing timeout

Changes:
- internal/config/config.go: WriteTimeout 30s → 5min
- internal/camera/discovery/scanner.go:
  - Progress ticker 3s → 1s
  - Remove "only if changed" check
  - Always send progress to keep connection alive

Testing:
- HiWatch camera with 591 streams: Previously timed out at ~338/591
- Should now complete all 591 streams without timeout
2025-11-22 19:48:03 +03:00
eduard256 12770ed5b9 Merge pull request #1 from eduard256/develop
WebUI Improvements - Mock Mode, Tooltips, and UX Enhancements
2025-11-22 00:38:56 +03:00
eduard256 90c4416709 Add informational tooltips for stream types and update mock data
- Add tooltips for all 7 stream types: FFMPEG, ONVIF, MJPEG, HLS, BUBBLE, JPEG, HTTP_VIDEO
- Each tooltip explains protocol features, use cases, and compatibility
- Add BUBBLE protocol icon and detailed description (XMEye/DVRIP cameras)
- Update mock streams to show one example of each type
- Remove unused mock-data.js file to reduce confusion
- Add CSS styles for stream type info icons
2025-11-22 00:29:07 +03:00
eduard256 d602c8dfca Improve WebUI UX with tooltips, auto-fill and button visibility
- Add informational tooltips to all configuration fields
- Reorder tabs: Frigate first, then Go2RTC, then URL
- Hide Copy/Download buttons on Frigate tab until config is generated
- Auto-fill username field with "admin" as default value
- Smart pre-fill network address based on server IP (first 3 octets)
- Add tooltips for Main Stream, Sub Stream, and all buttons
- Improve user guidance throughout the configuration flow
2025-11-22 00:03:54 +03:00
eduard256 596cf1ccdc Add interactive tooltips to camera configuration form
Добавлены информационные тултипы для всех полей формы настройки камеры с подробными описаниями, примерами использования и рекомендациями. Улучшает пользовательский опыт и помогает пользователям правильно заполнить форму.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:37:02 +03:00
eduard256 779ae33bac Redesign stream discovery UI with vertical list layout
- Replace carousel navigation with scrollable vertical list
- Remove statistics counters (Tested/Found/Remaining)
- Add collapsible stream details with expand/collapse toggle
- Show stream URL preview in header, full URL in details
- Position URL below stream type badge for better readability
- Add new StreamList component replacing StreamCarousel
- Update CSS with improved layout and hover effects

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:25:30 +03:00
eduard256 71d6f2aac8 Fix missing statistics elements in stream discovery UI
Restored stats block (Tested, Found, Remaining) that was accidentally
removed when adding mock mode functionality. This fixes JavaScript
errors where main.js tried to update non-existent DOM elements.
2025-11-21 23:02:39 +03:00
eduard256 56c06dfa98 Add mock mode for WebUI development
- Add mock API classes for camera search and stream discovery
- Add mock mode toggle via ?mock=true URL parameter
- Add visual mock mode indicator badge
- Add dev-server.sh script for local development
- Mock data includes 10 diverse streams (FFMPEG, ONVIF, JPEG, MJPEG, HLS, HTTP_VIDEO)
2025-11-21 22:57:37 +03:00
eduard256 8bf92e6598 Add mock mode for web UI development and testing
- Add mock data module with simulated camera search and stream discovery
- Enable mock mode via ?mock=true URL parameter
- Show MOCK MODE indicator when enabled
- Remove statistics cards from discovery screen, keep only progress bar
- Mock mode works independently from Go backend for easier UI testing
2025-11-21 22:40:38 +03:00
eduard256 522d274dd4 Fix CPU usage percentage in README 2025-11-20 19:19:59 +03:00
eduard256 8036d3e9be Add GitHub Stars badge to README 2025-11-18 17:03:54 +03:00
eduard256 5b2f80f057 Add Docker and Docker Compose auto-installation to compose command 2025-11-18 16:05:47 +03:00
eduard256 e2b9802fd8 Fix duplicate badges in README
Removed duplicate license and Docker pulls badges from README.
2025-11-18 16:04:23 +03:00
eduard256 65a198d119 Simplify RTSP URL description in README
Updated README to simplify the RTSP URL description and remove redundant lines.
2025-11-18 16:03:24 +03:00
eduard256 722c629c01 Move demo gif after badges for better visual flow 2025-11-18 16:01:30 +03:00
eduard256 c81d9a1e63 Complete README.md rewrite with improved structure and documentation 2025-11-18 15:59:59 +03:00
eduard256 06de1c198b Version bump to 1.0.4 2025-11-18 14:16:11 +03:00
eduard256 ded9b507d6 Fix stream discovery URL for Home Assistant Ingress
- Remove new URL() construction with window.location.origin
- Use simple relative path like camera-search API
- Fixes stream discovery in HA Ingress environment
- Maintains compatibility with direct Docker installations
- Version bump to 1.0.3
2025-11-18 13:59:54 +03:00
eduard256 90063c3f3a Fix API paths for Home Assistant Ingress compatibility
- Change absolute API paths to relative paths in camera-search.js
- Change absolute API paths to relative paths in stream-discovery.js
- Fixes resource loading in HA Ingress environment
- Maintains compatibility with direct Docker installations
- Version bump to 1.0.2
2025-11-18 13:35:29 +03:00
eduard256 1bab993a13 Fix CSS and JS paths for Home Assistant Ingress compatibility
- Change absolute paths (/css/, /js/) to relative paths (css/, js/)
- Maintains compatibility with direct Docker installations
- Fixes resource loading in HA Ingress environment
- API calls remain unchanged and work correctly
2025-11-18 00:58:11 +03:00
eduard256 1163af6fac Remove Home Assistant addon integration
- Remove homeassistant-addon directory
- Remove addon workflow
- Remove repository.yaml
- Update README to focus on Docker and source installation
- Keep ARM/Raspberry Pi support (arm64, armv7)
2025-11-18 00:09:00 +03:00
eduard256 95a0fc2096 Fix GitHub Actions permissions for add-on publishing
- Add packages:write permission to build job
- Add contents:write permission to update-repository job
- Bump version to 1.0.1
2025-11-17 23:45:34 +03:00
76 changed files with 8674 additions and 3900 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
```
-150
View File
@@ -1,150 +0,0 @@
name: Home Assistant Add-on
on:
push:
branches:
- main
paths:
- 'homeassistant-addon/**'
- 'cmd/**'
- 'internal/**'
- 'pkg/**'
- 'data/**'
- 'webui/**'
- 'go.mod'
- 'go.sum'
- 'Makefile'
- '.github/workflows/addon.yml'
tags:
- 'v*'
workflow_dispatch:
env:
BUILD_NAME: strix
jobs:
build:
name: Build Add-on
runs-on: ubuntu-latest
strategy:
matrix:
arch: [aarch64, amd64, armv7]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Get version
id: version
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
else
VERSION="dev-$(git rev-parse --short HEAD)"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Build binary for ${{ matrix.arch }}
run: |
case "${{ matrix.arch }}" in
aarch64)
GOARCH=arm64
;;
amd64)
GOARCH=amd64
;;
armv7)
GOARCH=arm
GOARM=7
;;
esac
CGO_ENABLED=0 GOOS=linux GOARCH=$GOARCH GOARM=${GOARM:-} go build \
-ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }}" \
-o homeassistant-addon/strix \
cmd/strix/main.go
- name: Copy required files
run: |
cp -r data homeassistant-addon/
cp -r webui homeassistant-addon/
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./homeassistant-addon
file: ./homeassistant-addon/Dockerfile
platforms: linux/${{ matrix.arch == 'aarch64' && 'arm64' || matrix.arch == 'amd64' && 'amd64' || 'arm/v7' }}
push: ${{ github.event_name != 'pull_request' }}
tags: |
ghcr.io/${{ github.repository_owner }}/strix-addon-${{ matrix.arch }}:latest
ghcr.io/${{ github.repository_owner }}/strix-addon-${{ matrix.arch }}:${{ steps.version.outputs.version }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-base:3.20
STRIX_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
update-repository:
name: Update Repository File
needs: build
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version
id: version
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
else
VERSION="dev-$(git rev-parse --short HEAD)"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update config.yaml version
run: |
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" homeassistant-addon/config.yaml
- name: Create/Update repository.yaml
run: |
cat > repository.yaml <<EOF
name: Strix Home Assistant Add-ons
url: https://github.com/${{ github.repository }}
maintainer: ${{ github.repository_owner }}
EOF
- name: Commit changes
if: startsWith(github.ref, 'refs/tags/')
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add homeassistant-addon/config.yaml repository.yaml
git diff --quiet && git diff --staged --quiet || git commit -m "Update add-on to version ${{ steps.version.outputs.version }}"
- name: Push changes
if: startsWith(github.ref, 'refs/tags/')
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: main
-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/), 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). 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 ## [0.1.0] - 2025-11-06
### Added ### Added
+3 -1
View File
@@ -4,6 +4,8 @@
# Stage 1: Builder # Stage 1: Builder
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
ARG VERSION=dev
WORKDIR /build WORKDIR /build
# Install build dependencies # Install build dependencies
@@ -18,7 +20,7 @@ COPY . .
# Build static binary # Build static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ 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 \ -o strix \
cmd/strix/main.go cmd/strix/main.go
-310
View File
@@ -1,310 +0,0 @@
# 🎉 Home Assistant Add-on для Strix - Готов!
## ✅ Что сделано
Создан полностью функциональный Home Assistant Add-on с автоматизацией через GitHub Actions.
### 📦 Созданные файлы
```
homeassistant-addon/
├── config.yaml # HA конфигурация аддона
├── Dockerfile # Multi-arch Docker сборка
├── build.yaml # Настройки сборки (aarch64/amd64/armv7)
├── run.sh # Стартовый скрипт с интеграцией HA
├── README.md # Документация для пользователей (EN)
├── README-RU.md # Документация для пользователей (RU)
├── DOCS.md # Подробная инструкция по использованию
├── CHANGELOG.md # История изменений
├── INSTALLATION.md # Гайд по установке и публикации
├── icon.png.todo # Заметка - нужна иконка 128x128px
└── logo.png.todo # Заметка - нужен логотип 256x256px
.github/workflows/
└── addon.yml # GitHub Actions для автосборки
repository.yaml # Файл репозитория для HA
README.md (обновлен) # Добавлена секция про HA Add-on
```
## 🚀 Как это работает
### Автоматизация через GitHub Actions
При пуше в `main` или создании тега `v*`:
1. **Сборка для каждой архитектуры** (aarch64, amd64, armv7):
- Компиляция Go бинарника
- Копирование data и webui
- Создание Docker образа
- Публикация в GitHub Container Registry (ghcr.io)
2. **Обновление репозитория**:
- Автоматическое обновление версии в config.yaml
- Обновление repository.yaml
- Коммит изменений (только для тегов)
### Docker образы
Публикуются автоматически:
- `ghcr.io/eduard256/strix-addon-aarch64:latest`
- `ghcr.io/eduard256/strix-addon-amd64:latest`
- `ghcr.io/eduard256/strix-addon-armv7:latest`
С версионированием:
- `ghcr.io/eduard256/strix-addon-aarch64:1.0.0`
- и т.д.
## 🎯 Что дальше
### 1. Добавить иконки (опционально, но рекомендуется)
```bash
# Создать или добавить:
homeassistant-addon/icon.png # 128x128px
homeassistant-addon/logo.png # 256x256px
# Стиль: сова или камера, синий/белый (Home Assistant theme)
```
### 2. Первый релиз
```bash
# Закоммитить все изменения
git add .
git commit -m "Add Home Assistant Add-on v1.0.0"
git push origin main
# Создать тег релиза
git tag v1.0.0
git push origin v1.0.0
```
### 3. Дождаться сборки
GitHub Actions автоматически:
- Соберет все архитектуры
- Создаст Docker образы
- Опубликует в ghcr.io
Проверить статус: https://github.com/eduard256/Strix/actions
### 4. Установить в Home Assistant
#### Для пользователей:
1. **Supervisor****Add-on Store****⋮** (меню) → **Repositories**
2. Добавить репозиторий: `https://github.com/eduard256/Strix`
3. Найти **Strix Camera Discovery** в списке аддонов
4. Нажать **Install**
5. Настроить (если нужно)
6. Нажать **Start**
7. Нажать **Open Web UI**
## 🎨 Особенности реализации
### 1. Максимальное использование существующей инфраструктуры
- ✅ Использует существующий Dockerfile как основу
- ✅ Переиспользует Docker workflow
- ✅ Встраивается в существующий CI/CD пайплайн
- ✅ Не требует дублирования кода
### 2. Автоматизация
- ✅ Автоматическая сборка при пуше/теге
- ✅ Multi-arch сборка (3 архитектуры параллельно)
- ✅ Автоматическая публикация образов
- ✅ Автоматическое обновление версий
### 3. Интеграция с Home Assistant
- ✅ Ingress поддержка (встроенный iframe)
- ✅ Иконка в боковой панели
- ✅ Настройка через HA UI
- ✅ Логирование в HA
- ✅ Health check мониторинг
- ✅ Auto-start при загрузке
### 4. Безопасность
- ✅ Non-root пользователь (UID 1000)
- ✅ Минимальный Alpine образ
- ✅ Credentials не сохраняются
- ✅ Работа только в локальной сети
- ✅ Health check для мониторинга
## ⚙️ Конфигурация для пользователей
```yaml
log_level: info # debug, info, warn, error
port: 4567 # Порт веб-интерфейса (default: 4567)
strict_validation: true # Строгая валидация потоков
```
## 📊 Возможности аддона
- 🔍 **3,600+ камер** в базе данных
- 🌐 **ONVIF discovery** - автопоиск камер
-**Real-time SSE** - живые обновления
- 🎨 **WebUI** - красивый интерфейс
- 🔌 **RESTful API** - для автоматизации
- 🚀 **Fast concurrent testing** - параллельная проверка
- 📦 **All-in-one** - всё включено
## 🔄 Обновления
### Разработка (dev builds)
```bash
git add .
git commit -m "feat: new feature"
git push origin main
```
Создаст dev-сборку с тегом `dev-<git-hash>`.
### Релизы (production)
```bash
# 1. Обновить версию
sed -i 's/^version:.*/version: "1.1.0"/' homeassistant-addon/config.yaml
# 2. Обновить CHANGELOG.md
nano homeassistant-addon/CHANGELOG.md
# 3. Закоммитить и создать тег
git add .
git commit -m "release: v1.1.0"
git tag v1.1.0
git push origin main v1.1.0
```
Создаст релиз-сборку с тегом `1.1.0`.
## 📚 Документация
| Файл | Описание |
|------|----------|
| `README.md` | Краткое описание для пользователей (EN) |
| `README-RU.md` | Краткое описание для пользователей (RU) |
| `DOCS.md` | Полная документация по использованию |
| `CHANGELOG.md` | История версий и изменений |
| `INSTALLATION.md` | Инструкция для разработчиков/публикации |
## 🐛 Troubleshooting
### Сборка не прошла
1. Проверить GitHub Actions: https://github.com/eduard256/Strix/actions
2. Проверить логи ошибок
3. Частые проблемы:
- Ошибки компиляции Go → исправить в коде
- Ошибки Docker → проверить Dockerfile
- Права доступа → проверить GITHUB_TOKEN permissions
### Аддон не устанавливается
1. Проверить repository.yaml в корне репозитория
2. Убедиться что config.yaml валиден (YAML syntax)
3. Проверить что Docker образы опубликованы в ghcr.io
4. Проверить что URL репозитория правильный
### Аддон не запускается
1. Открыть логи в HA: **Addon page****Log** tab
2. Частые проблемы:
- Порт 4567 занят → изменить port в настройках
- Отсутствуют файлы data → проверить сборку
- Permission denied → проверить права в Dockerfile
## ✅ Чеклист перед первым релизом
- [x] Создана структура аддона
- [x] Настроен Dockerfile
- [x] Настроен GitHub Actions workflow
- [x] Создана документация (EN)
- [x] Создана документация (RU)
- [x] Обновлен основной README
- [x] Создан repository.yaml
- [ ] Добавлены иконки (icon.png, logo.png)
- [ ] Протестирована локальная сборка
- [ ] Создан git tag v1.0.0
- [ ] Проверена публикация в ghcr.io
- [ ] Протестирована установка в HA
## 🎁 Дополнительные возможности (будущее)
Можно добавить в будущих версиях:
- ✨ Автоматическое добавление камер как entities в HA
- 🔧 Генератор конфигов для go2rtc
- 📹 Генератор конфигов для Frigate
- 🔔 ONVIF события и уведомления
- 📸 Галерея снимков камер
- 🔍 Сетевой сканер для массового поиска
## 🤝 Распространение
### Вариант 1: Кастомный репозиторий (рекомендуется)
Пользователи добавляют репозиторий вручную.
**Преимущества:**
- Полный контроль
- Быстрые обновления
- Нет процесса одобрения
**Недостатки:**
- Нужно добавлять вручную
- Не в официальном store
### Вариант 2: Home Assistant Community Add-ons
Подать заявку в официальный репозиторий:
https://github.com/home-assistant/addons
**Преимущества:**
- Официальное признание
- Легче найти пользователям
- Автообновления
**Недостатки:**
- Строгие требования
- Процесс ревью
- Медленные обновления
## 🎉 Готово к использованию!
Всё готово для первого релиза. После добавления иконок и создания тега v1.0.0, аддон будет полностью готов к работе.
### Команды для быстрого старта:
```bash
# 1. Добавить иконки (опционально)
# Поместить icon.png и logo.png в homeassistant-addon/
# 2. Закоммитить
git add .
git commit -m "Add Home Assistant Add-on v1.0.0"
git push origin main
# 3. Создать релиз
git tag v1.0.0
git push origin v1.0.0
# 4. Дождаться окончания GitHub Actions
# https://github.com/eduard256/Strix/actions
# 5. Установить в Home Assistant
# Добавить репозиторий: https://github.com/eduard256/Strix
```
---
**Поделиться с сообществом:**
- 💬 Home Assistant Community Forum
- 🔴 Reddit r/homeassistant
- 💭 GitHub Discussions
- 💬 Discord серверы
+503 -187
View File
@@ -1,234 +1,550 @@
# 🦉 Strix - Smart IP Camera Stream Discovery System # Strix
[![GitHub Stars](https://img.shields.io/github/stars/eduard256/strix?style=social)](https://github.com/eduard256/strix/stargazers)
![Strix Demo](assets/main.gif?v=2)
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://go.dev/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![API Version](https://img.shields.io/badge/API-v1-green.svg)](https://github.com/eduard256/Strix) [![Docker Pulls](https://img.shields.io/docker/pulls/eduard256/strix)](https://hub.docker.com/r/eduard256/strix)
Strix is an intelligent IP camera stream discovery system that acts as a bridge between users and streaming servers like go2rtc. It automatically discovers and validates camera streams, eliminating the need for manual URL configuration. ## Spent 2 years googling URL for your Chinese camera?
## 🎯 Features **Strix finds working streams automatically. In 30 seconds.**
- **Intelligent Camera Search**: Fuzzy search across 3,600+ camera models - **67,288** camera models
- **Automatic Stream Discovery**: ONVIF, database patterns, and popular URL detection - **3,636** brands (from Hikvision to AliExpress no-name)
- **Real-time Updates**: Server-Sent Events (SSE) for live discovery progress - **102,787** URL patterns (RTSP, HTTP, MJPEG, JPEG, BUBBLE)
- **Universal Protocol Support**: RTSP, HTTP, MJPEG, JPEG snapshots, and more
- **Smart URL Building**: Automatic placeholder replacement and authentication handling
- **Concurrent Testing**: Fast parallel stream validation with ffprobe
- **Memory Efficient**: Streaming JSON parsing for large camera databases
- **API-First Design**: RESTful API with comprehensive documentation
## 🚀 Quick Start ![Demo](assets/main.gif)
### Home Assistant Add-on (Recommended) ---
The easiest way to use Strix is as a Home Assistant Add-on: ## Your Problem?
1. Add this repository to your Home Assistant: - ❌ Bought ZOSI NVR, zero documentation
- Go to **Supervisor****Add-on Store** - ❌ Camera has no RTSP, only weird JPEG snapshots
- Click **⋮** (menu) → **Repositories** - ❌ Frigate eating 70% CPU
- Add: `https://github.com/eduard256/Strix` - ❌ Config breaks after adding each camera
- ❌ Don't understand Frigate syntax
2. Install the **Strix Camera Discovery** add-on ## Solution
3. Start the add-on and open the Web UI -**Auto-discovery** - tests 102,787 URL variations in parallel
-**Any protocol** - No RTSP? Finds HTTP MJPEG
-**Config generation** - ready Frigate.yml in 2 minutes
-**Sub/Main streams** - CPU from 30% → 8%
-**Smart merging** - adds camera to existing config with 500+ cameras
4. Start discovering your cameras! ---
For detailed installation instructions, see [Home Assistant Add-on Documentation](homeassistant-addon/DOCS.md). ## 🚀 Installation (One Command)
### Docker ### Ubuntu / Debian
```bash ```bash
# Using Docker Compose (recommended) 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
docker-compose up -d
# Or using Docker directly
docker run -d \
--name strix \
-p 4567:4567 \
eduard256/strix:latest
# Access at http://localhost:4567
``` ```
See [Docker documentation](DOCKER.md) for more options. Open **http://YOUR_SERVER_IP:4567**
### Build from Source ### Docker Compose
Prerequisites:
- Go 1.21 or higher
- ffprobe (optional, for enhanced stream validation)
```bash ```bash
# Clone the repository 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
git clone https://github.com/eduard256/Strix
cd strix
# Install dependencies
make deps
# Build the application
make build
# Run the application
make run
# The server will start on http://localhost:4567
# Open your browser and navigate to http://localhost:4567
``` ```
## 📡 API Endpoints ### Home Assistant Add-on
**Installation:**
1. Go to **Settings****Add-ons****Add-on Store**
2. Click **⋮** (top right) → **Repositories**
3. Add: `https://github.com/eduard256/hassio-strix`
4. Find **"Strix"** in store
5. Click **Install**
6. Enable **"Start on boot"** and **"Show in sidebar"**
7. Click **Start**
---
## How to Use
### Step 1: Open Web Interface
```
http://YOUR_SERVER_IP:4567
```
### Step 2: Enter Camera Details
- **IP Address**: `192.168.1.100`
- **Username**: `admin` (if required)
- **Password**: your camera password
- **Model**: optional, improves accuracy
### Step 3: Discover Streams
Click **"Discover Streams"**
Watch real-time progress:
- Which URL is being tested
- How many tested
- Found streams appear instantly
Wait 30-60 seconds.
### Step 4: Choose Stream
Strix shows details for each stream:
| Stream | Details |
|--------|---------|
| **Protocol** | RTSP, HTTP, MJPEG, JPEG |
| **Resolution** | 1920x1080, 640x480 |
| **FPS** | 25, 15, 10 |
| **Codec** | H264, H265, MJPEG |
| **Audio** | Yes / No |
### Step 5: Generate Frigate Config
Click **"Use Stream"** → **"Generate Frigate Config"**
You get ready config:
```yaml
go2rtc:
streams:
'192_168_1_100_main':
- http://admin:pass@192.168.1.100:8000/video.mjpg
'192_168_1_100_sub':
- http://admin:pass@192.168.1.100:8000/video2.mjpg
cameras:
camera_192_168_1_100:
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/192_168_1_100_sub
roles: [detect] # CPU 8% instead of 70%
- path: rtsp://127.0.0.1:8554/192_168_1_100_main
roles: [record] # HD recording
objects:
track: [person, car, cat, dog]
record:
enabled: true
```
**Smart Merging:**
- Paste your existing `frigate.yml` with 500 cameras
- Strix adds camera #501 correctly
- Doesn't break structure
- Preserves all settings
### Step 6: Add to Frigate
Copy config → Paste to `frigate.yml` → Restart Frigate
**Done!**
---
## Features
### Exotic Camera Support
90% of Chinese cameras don't have RTSP. Strix supports everything:
- **HTTP MJPEG** - most old cameras
- **JPEG snapshots** - auto-converted to stream via FFmpeg
- **RTSP** - if available
- **HTTP-FLV** - some Chinese brands
- **BUBBLE** - proprietary Chinese NVR/DVR protocol
- **ONVIF** - auto-discovery
### Camera Database
**67,288 models from 3,636 brands:**
- **Known brands**: Hikvision, Dahua, Axis, Foscam, TP-Link
- **Chinese no-names**: ZOSI, Escam, Sricam, Wanscam, Besder
- **AliExpress junk**: cameras without name, OEM models
- **Old systems**: NVR/DVR with proprietary protocols
### Discovery Methods
Strix tries all methods in parallel:
**1. ONVIF** (30% success rate)
- Asks camera directly for stream URLs
- Works for ONVIF-compatible cameras
**2. Database Lookup** (60% success rate)
- 67,288 models with known working URLs
- Brand and model-specific patterns
**3. Popular Patterns** (90% success rate)
- 206 most common URL paths
- Works even for unknown cameras
**Result: Finds stream for 95% of cameras**
### Frigate Config Generation
**What you get:**
**Main/Sub streams**
- Main (HD) for recording
- Sub (low res) for object detection
- CPU usage reduced 5-10x
**Ready go2rtc config**
- Stream multiplexing
- Protocol conversion
- JPEG → RTSP via FFmpeg
**Smart config merging**
- Add to existing config
- Preserve structure
- No manual YAML editing
**Pre-configured detection**
- person, car, cat, dog
- Ready motion recording
- 7 days retention
### Speed
- Tests **20 URLs in parallel**
- Average discovery time: **30-60 seconds**
- Complex cameras: **2-3 minutes**
- Real-time progress updates via SSE
---
## Advanced Configuration
### Docker Environment Variables
```yaml
environment:
- STRIX_API_LISTEN=:8080 # Custom port
- STRIX_LOG_LEVEL=debug # Detailed logs
- STRIX_LOG_FORMAT=json # JSON logging
```
### Config File
Create `strix.yaml`:
```yaml
api:
listen: ":8080"
```
Example: [strix.yaml.example](strix.yaml.example)
### Discovery Parameters
In web UI under **Advanced**:
- **Channel** - for NVR systems (usually 0)
- **Timeout** - max discovery time (default: 240s)
- **Max Streams** - stop after N streams (default: 10)
---
## FAQ
### No streams found?
**Check network:**
```bash
ping 192.168.1.100
```
Camera must be reachable.
**Verify credentials:**
- Username/password correct?
- Try without credentials (some cameras are open)
**Try without model:**
- Strix will run ONVIF + 206 popular patterns
- Works for cameras not in database
### Camera not in database?
**No problem.**
Strix will still find stream via:
1. ONVIF (if supported)
2. 206 popular URL patterns
3. Common ports and paths
4. HTTP MJPEG on various ports
5. JPEG snapshot endpoints
**Help the project:**
- Found working stream? [Create Issue](https://github.com/eduard256/Strix/issues)
- Share model and URL
- We'll add to database
### Found only JPEG snapshots?
**Normal for old cameras.**
Strix auto-converts JPEG to stream via FFmpeg:
```yaml
go2rtc:
streams:
camera_main:
- exec:ffmpeg -loop 1 -framerate 10 -i http://192.168.1.100/snapshot.jpg -c:v libx264 -f rtsp {output}
```
Frigate gets normal 10 FPS stream.
### Stream found but doesn't work in Frigate?
**Try another stream:**
- Strix usually finds 3-10 variants
- Some may need special FFmpeg parameters
**Use sub stream:**
- For object detection
- Less CPU load
- Better performance
### How does config generation work?
**For new config:**
- Strix creates complete `frigate.yml` from scratch
- Includes go2rtc, camera, object detection
**For existing config:**
- Paste your current `frigate.yml`
- Strix adds new camera
- Preserves all existing cameras
- Doesn't break structure
**Main/Sub streams:**
- Main (HD) - for recording
- Sub (low res) - for detection
- CPU savings 5-10x
### Is it safe to enter passwords?
**Yes.**
- Strix runs locally on your network
- Nothing sent to external servers
- Passwords not saved
- Open source - check the code yourself
### Works offline?
**Yes.**
- Database embedded in Docker image
- Internet only needed to download image
- Runs offline after that
---
## API Reference
REST API available for automation:
### Health Check ### Health Check
```bash ```bash
GET /api/v1/health GET /api/v1/health
``` ```
### Camera Search ### Search Cameras
```bash ```bash
POST /api/v1/cameras/search POST /api/v1/cameras/search
{ {
"query": "zosi zg23213m", "query": "hikvision",
"limit": 10 "limit": 10
} }
``` ```
### Stream Discovery (SSE) ### Discover Streams (SSE)
```bash ```bash
POST /api/v1/streams/discover POST /api/v1/streams/discover
{ {
"target": "192.168.1.100", # IP or stream URL "target": "192.168.1.100",
"model": "zosi zg23213m", # Optional camera model "username": "admin",
"username": "admin", # Optional "password": "12345",
"password": "password", # Optional "model": "DS-2CD2xxx",
"timeout": 240, # Seconds (default: 240) "timeout": 240,
"max_streams": 10, # Maximum streams to find "max_streams": 10
"channel": 0 # For NVR systems
} }
``` ```
## 🔍 How It Works Returns Server-Sent Events with real-time progress.
1. **Camera Search**: Intelligent fuzzy matching across brand and model database **Full API documentation:** [DOCKER.md](DOCKER.md)
2. **URL Collection**: Combines ONVIF discovery, model-specific patterns, and popular URLs
3. **Stream Validation**: Concurrent testing using ffprobe and HTTP requests
4. **Real-time Updates**: SSE streams provide instant feedback on discovered streams
5. **Smart Filtering**: Deduplicates URLs and prioritizes working streams
## 📁 Project Structure
```
strix/
├── cmd/strix/ # Application entry point
├── internal/ # Private application code
│ ├── api/ # HTTP handlers and routing
│ ├── camera/ # Camera database and discovery
│ │ ├── database/ # Database loading and search
│ │ ├── discovery/ # ONVIF and stream discovery
│ │ └── stream/ # URL building and validation
│ ├── config/ # Configuration management
│ └── models/ # Data structures
├── pkg/ # Public packages
│ └── sse/ # Server-Sent Events
├── data/ # Camera database (3,600+ models)
│ ├── brands/ # Brand-specific JSON files
│ ├── popular_stream_patterns.json
│ └── query_parameters.json
└── go.mod
```
## 🛠️ Configuration
Strix can be configured via `strix.yaml` file or environment variables.
### Configuration File (strix.yaml)
Create a `strix.yaml` file in the same directory as the binary:
```yaml
# API Server Configuration
api:
listen: ":4567" # Format: ":port" or "host:port"
```
Examples:
```yaml
api:
listen: ":4567" # All interfaces, port 4567 (default)
# listen: "127.0.0.1:4567" # Localhost only
# listen: ":8080" # Custom port
```
### Environment Variables
Environment variables override config file values:
```bash
STRIX_API_LISTEN=":4567" # Server listen address (overrides strix.yaml)
STRIX_LOG_LEVEL=info # Log level: debug, info, warn, error
STRIX_LOG_FORMAT=json # Log format: json, text
```
### Configuration Priority
1. **Environment variable** `STRIX_API_LISTEN` (highest priority)
2. **Config file** `strix.yaml`
3. **Default value** `:4567` (lowest priority)
### Quick Start with Custom Port
```bash
# Using environment variable
STRIX_API_LISTEN=":8080" ./strix
# Or using config file
cp strix.yaml.example strix.yaml
# Edit strix.yaml, then:
./strix
```
## 📊 Camera Database
The system includes a comprehensive database of camera models:
- **3,600+ camera brands**
- **150+ popular stream patterns**
- **258 query parameter variations**
- **Automatic placeholder replacement**
## 🔧 Development
```bash
# Run tests
make test
# Format code
make fmt
# Run linter
make lint
# Build for all platforms
make build-all
# Development mode with live reload
make dev
```
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- Camera database sourced from ispyconnect.com
- Inspired by go2rtc project
- Built with Go and Chi router
--- ---
Made with ❤️ for the home automation community ## Technical Details
### Architecture
- **Language:** Go 1.24
- **Database:** 3,636 JSON files
- **Image size:** 80-90 MB (Alpine Linux)
- **Dependencies:** FFmpeg/FFprobe for validation
- **Concurrency:** Worker pool (20 parallel tests)
- **Real-time:** Server-Sent Events (SSE)
### Build from Source
```bash
git clone https://github.com/eduard256/Strix
cd Strix
make build
./bin/strix
```
**Requirements:**
- Go 1.21+
- FFprobe (optional, for stream validation)
### Docker Platforms
- linux/amd64
- linux/arm64
Auto-built and published to Docker Hub on every push to `main`.
---
## Use Cases
### Home Automation
- Add cheap cameras to Home Assistant
- Integrate with Frigate NVR
- Object detection with low CPU
- Motion recording
### Security Systems
- Discover streams in old NVR systems
- Find backup cameras without docs
- Migrate from proprietary DVR to Frigate
- Reduce hardware requirements
### IP Camera Testing
- Test cameras before deployment
- Verify stream quality
- Find optimal resolution/FPS
- Check codec compatibility
---
## Troubleshooting
### Frigate still eating CPU?
**Use sub stream:**
1. Find both main and sub streams with Strix
2. Generate config with both
3. Sub for detect, main for record
4. CPU drops 5-10x
**Example:**
```yaml
inputs:
- path: rtsp://127.0.0.1:8554/camera_sub # 640x480 for detect
roles: [detect]
- path: rtsp://127.0.0.1:8554/camera_main # 1920x1080 for record
roles: [record]
```
### Can't find specific stream quality?
**In web UI:**
- Strix shows all found streams
- Filter by resolution
- Choose optimal FPS
- Select codec (H264 recommended for Frigate)
### Stream works but no audio in Frigate?
**Check Strix stream details:**
- "Has Audio" field shows if audio present
- Some cameras have video-only streams
- Try different stream URL from Strix results
### Discovery takes too long?
**Reduce search scope:**
- Specify exact camera model (faster database lookup)
- Lower "Max Streams" (stops after N found)
- Reduce timeout (default 240s)
**In Advanced settings:**
```
Max Streams: 5 (instead of 10)
Timeout: 120 (instead of 240)
```
---
## Contributing
### Add Your Camera
Found working stream for camera not in database?
1. [Create Issue](https://github.com/eduard256/Strix/issues)
2. Provide:
- Camera brand and model
- Working URL pattern
- Protocol (RTSP/HTTP/etc)
3. We'll add to database
### Report Bugs
- [GitHub Issues](https://github.com/eduard256/Strix/issues)
- Include logs (set `STRIX_LOG_LEVEL=debug`)
- Camera model and IP (if possible)
### Feature Requests
- [GitHub Discussions](https://github.com/eduard256/Strix/discussions)
- Describe use case
- Explain expected behavior
---
## Credits
- **Camera database:** [ispyconnect.com](https://www.ispyconnect.com)
- **Inspiration:** [go2rtc](https://github.com/AlexxIT/go2rtc) by AlexxIT
- **Community:** Home Assistant, Frigate NVR users
---
## License
MIT License - use commercially, modify, distribute freely.
See [LICENSE](LICENSE) file for details.
---
## Support
- **Issues:** [GitHub Issues](https://github.com/eduard256/Strix/issues)
- **Discussions:** [GitHub Discussions](https://github.com/eduard256/Strix/discussions)
- **Docker:** [Docker Hub](https://hub.docker.com/r/eduard256/strix)
---
**Made for people tired of cameras without documentation**
*Tested on Chinese AliExpress junk that finally works now.*
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" "github.com/go-chi/chi/v5"
) )
const ( // Version is set at build time via ldflags:
// Version is the application version //
Version = "1.0.0" // go build -ldflags="-X main.Version=1.0.10" ./cmd/strix
var Version = "dev"
// Banner is the application banner const Banner = `
Banner = `
███████╗████████╗██████╗ ██╗██╗ ██╗ ███████╗████████╗██████╗ ██╗██╗ ██╗
██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝ ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝
███████╗ ██║ ██████╔╝██║ ╚███╔╝ ███████╗ ██║ ██████╔╝██║ ╚███╔╝
@@ -34,7 +34,6 @@ const (
Smart IP Camera Stream Discovery System Smart IP Camera Stream Discovery System
Version: %s Version: %s
` `
)
func main() { func main() {
// Print banner // Print banner
@@ -43,6 +42,7 @@ func main() {
// Load configuration // Load configuration
cfg := config.Load() cfg := config.Load()
cfg.Version = Version
// Setup logger // Setup logger
slogger := cfg.SetupLogger() slogger := cfg.SetupLogger()
+64 -28
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"3628-675", "3628-675",
@@ -313,15 +349,6 @@
"port": 0, "port": 0,
"url": "" "url": ""
}, },
{
"models": [
"IPC-300"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/101"
},
{ {
"models": [ "models": [
"IPC-340HD", "IPC-340HD",
@@ -465,15 +492,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg" "url": "snapshot.jpg"
}, },
{
"models": [
"IPC-740"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 8554,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"IP-CAM", "IP-CAM",
@@ -631,16 +649,6 @@
"port": 80, "port": 80,
"url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240" "url": "/videostream.asf?user=[USERNAME]&pwd=[PASSWORD]&resolution=320x240"
}, },
{
"models": [
"PX3615",
"SK7008-T1F1"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/channels/401"
},
{ {
"models": [ "models": [
"PX-3615-675" "PX-3615-675"
@@ -722,6 +730,34 @@
"protocol": "http", "protocol": "http",
"port": 82, "port": 82,
"url": "/cgi/mjpg/mjpg.cgi" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"10550", "10550",
@@ -320,31 +356,6 @@
"port": 554, "port": 554,
"url": "/s2" "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": [ "models": [
"IPCB54611B", "IPCB54611B",
@@ -635,6 +646,31 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 554, "port": 554,
"url": "/mpeg4/media.amp?resolution=640x480" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"WIFI-5MP-30" "WIFI-5MP-30"
+18
View File
@@ -4,6 +4,24 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"mega216" "mega216"
+127 -91
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"NVR", "NVR",
@@ -220,55 +256,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg?user=[USERNAME]&pwd=[PASSWORD]&strm=[CHANNEL]" "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": [ "models": [
"141CS", "141CS",
@@ -498,39 +485,6 @@
"port": 554, "port": 554,
"url": "/onvif2" "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": [ "models": [
"2MP", "2MP",
@@ -659,15 +613,6 @@
"port": 80, "port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]" "url": "/cgi-bin/snapshot.cgi?chn=4&u=[USERNAME]&p=[PASSWORD]"
}, },
{
"models": [
"DVR"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/201"
},
{ {
"models": [ "models": [
"h264", "h264",
@@ -851,6 +796,97 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 0, "port": 0,
"url": "/h264/ch1/main/av_stream" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"Other" "Other"
+36
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"Outdoor Smart Home Camera", "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"040", "040",
@@ -47,69 +164,6 @@
"port": 554, "port": 554,
"url": "/Streaming/Channels/1" "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": [ "models": [
"DC-I200", "DC-I200",
@@ -221,16 +275,6 @@
"port": 554, "port": 554,
"url": "/h264_stream" "url": "/h264_stream"
}, },
{
"models": [
"ds-i200",
"VDP-D2201"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 555,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"Ds-i203" "Ds-i203"
@@ -240,16 +284,6 @@
"port": 8000, "port": 8000,
"url": "/" "url": "/"
}, },
{
"models": [
"DS-I214(B)",
"DS-I405"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/ISAPI/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"DS-I220", "DS-I220",
@@ -310,6 +344,89 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 554, "port": 554,
"url": "/onvif1" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"0010/0020", "0010/0020",
@@ -647,15 +665,6 @@
"port": 0, "port": 0,
"url": "cam[CHANNEL]/h264" "url": "cam[CHANNEL]/h264"
}, },
{
"models": [
"FCS-3084"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 0,
"url": "/Streaming/Channels/102"
},
{ {
"models": [ "models": [
"FCS-4051", "FCS-4051",
@@ -770,6 +779,15 @@
"protocol": "http", "protocol": "http",
"port": 80, "port": 80,
"url": "/cgi-bin/video.jpg" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"0v600-365-kd", "0v600-365-kd",
@@ -153,24 +171,6 @@
"port": 0, "port": 0,
"url": "snapshot.jpg?account=[USERNAME]&password=[PASSWORD]" "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": [ "models": [
"CAM-1", "CAM-1",
@@ -375,6 +375,24 @@
"protocol": "http", "protocol": "http",
"port": 80, "port": 80,
"url": "/cgi-bin/snapshot.cgi?chn=0&u=[USERNAME]&p=[PASSWORD]" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"801", "801",
+128 -92
View File
@@ -4,6 +4,42 @@
"last_updated": "2025-10-17", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"005FTCD", "005FTCD",
@@ -588,58 +624,6 @@
"port": 554, "port": 554,
"url": "/ch05/1" "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": [ "models": [
"887" "887"
@@ -874,37 +858,6 @@
"port": 0, "port": 0,
"url": "/Streaming/Unicast/channels/401" "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": [ "models": [
"DVR4", "DVR4",
@@ -942,15 +895,6 @@
"port": 80, "port": 80,
"url": "/?action=stream" "url": "/?action=stream"
}, },
{
"models": [
"DVR8-4500"
],
"type": "FFMPEG",
"protocol": "rtsp",
"port": 554,
"url": "/Streaming/Channels/301"
},
{ {
"models": [ "models": [
"DVR8-4500", "DVR8-4500",
@@ -1314,6 +1258,98 @@
"protocol": "rtsp", "protocol": "rtsp",
"port": 0, "port": 0,
"url": "/Streaming/Channels/2" "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", "last_updated": "2025-10-17",
"source": "ispyconnect.com", "source": "ispyconnect.com",
"entries": [ "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": [ "models": [
"TBK-BUL8841Z" "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", "notes": "Common RTSP sub stream for ONVIF cameras",
"model_count": 9998 "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", "url": "/ch2",
"type": "FFMPEG", "type": "FFMPEG",
+1 -4
View File
@@ -9,13 +9,10 @@ services:
image: eduard256/strix:latest image: eduard256/strix:latest
container_name: strix container_name: strix
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "4567:4567"
environment: environment:
- STRIX_LOG_LEVEL=info - STRIX_LOG_LEVEL=info
- STRIX_LOG_FORMAT=json - STRIX_LOG_FORMAT=json
networks:
- cameras
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4567/api/v1/health"]
interval: 30s interval: 30s
+1 -3
View File
@@ -7,9 +7,7 @@ services:
# build: . # build: .
container_name: strix container_name: strix
restart: unless-stopped restart: unless-stopped
network_mode: host
ports:
- "4567:4567"
environment: environment:
# Logging configuration # Logging configuration
+17 -4
View File
@@ -5,10 +5,12 @@ go 1.24.0
toolchain go1.24.9 toolchain go1.24.9
require ( require (
github.com/AlexxIT/go2rtc v1.9.14
github.com/IOTechSystems/onvif v1.2.0 github.com/IOTechSystems/onvif v1.2.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
github.com/lithammer/fuzzysearch v1.1.8 github.com/lithammer/fuzzysearch v1.1.8
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -19,9 +21,20 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.7.0 // 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 github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect github.com/miekg/dns v1.1.70 // indirect
golang.org/x/sys v0.36.0 // indirect github.com/pion/randutil v0.1.0 // indirect
golang.org/x/text v0.29.0 // indirect github.com/pion/rtp v1.10.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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 -8
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 h1:vplyPdhFhMRtIdkEbQIkTlrKjXpeDj+WUTt5UW61ZcI=
github.com/IOTechSystems/onvif v1.2.0/go.mod h1:/dTr5BtFaGojYGJ2rEBIVWh3seGIcSuCJhcK9zwTsk0= 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 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI=
github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= 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 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= 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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 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.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.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-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-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.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.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-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.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.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-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-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-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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -60,13 +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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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-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.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.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.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= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-62
View File
@@ -1,62 +0,0 @@
# Changelog
All notable changes to this Home Assistant add-on 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.0] - 2025-01-15
### Added
- Initial release of Strix Home Assistant Add-on
- Support for aarch64, amd64, and armv7 architectures
- Web UI integration with Home Assistant panel
- Ingress support for seamless integration
- Configurable port and logging options
- Strict validation mode toggle
- Health check monitoring
- Comprehensive documentation
- Multi-arch Docker builds via GitHub Actions
- Automatic updates through Home Assistant Supervisor
### Features
- 3,600+ camera models in database
- ONVIF discovery support
- Real-time stream discovery via SSE
- RESTful API for automation
- Fuzzy search for camera models
- Multiple stream protocol support (RTSP, HTTP, MJPEG, JPEG)
- FFProbe integration for stream validation
- Concurrent stream testing with worker pool
- Camera database with popular stream patterns
### Security
- Runs as non-root user (UID 1000)
- Minimal Alpine-based container
- No credential storage
- Local network only operation
- Read-only filesystem where possible
### Documentation
- Complete installation guide
- Configuration reference
- API documentation
- Troubleshooting guide
- Integration examples for HA, go2rtc, and Frigate
## [Unreleased]
### Planned
- Auto-discovery integration with Home Assistant
- Automatic camera entity creation
- go2rtc configuration generator
- Frigate configuration generator
- ONVIF event monitoring
- Motion detection API
- Camera snapshot gallery
- Network scanner for bulk discovery
- Custom camera database additions
---
**Full Changelog**: https://github.com/eduard256/Strix/commits/main/homeassistant-addon
-391
View File
@@ -1,391 +0,0 @@
# Strix Camera Discovery - Documentation
## Installation
### Method 1: Add Repository (Recommended)
1. Navigate to **Supervisor****Add-on Store** in your Home Assistant
2. Click the **⋮** menu (top right) → **Repositories**
3. Add repository URL: `https://github.com/eduard256/Strix`
4. Find **Strix Camera Discovery** in the store
5. Click **Install**
6. Configure the add-on (optional)
7. Click **Start**
8. Click **Open Web UI**
### Method 2: Manual Installation
1. SSH into your Home Assistant server
2. Navigate to the addons directory:
```bash
cd /addons
```
3. Clone the repository:
```bash
git clone https://github.com/eduard256/Strix
cd Strix/homeassistant-addon
```
4. Restart Home Assistant Supervisor
5. Find the add-on in the **Local Add-ons** section
## Configuration
The add-on can be configured through the Home Assistant UI:
```yaml
log_level: info
port: 4567
strict_validation: true
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `log_level` | string | `info` | Logging level: `debug`, `info`, `warn`, `error` |
| `port` | integer | `4567` | Port for web interface and API |
| `strict_validation` | boolean | `true` | Enable strict stream validation |
### Advanced Configuration
For advanced users, you can modify environment variables:
- `STRIX_LOG_LEVEL` - Log level (debug, info, warn, error)
- `STRIX_LOG_FORMAT` - Log format (json, text)
- `STRIX_API_LISTEN` - Server listen address (set via `port` option)
- `STRIX_DATA_PATH` - Camera database path (default: `/app/data`)
## Usage
### Quick Start Guide
1. **Open the Web UI**
- Click "Open Web UI" in the add-on panel
- Or navigate to: `http://homeassistant.local:4567`
2. **Find Your Camera Model**
- Use the search bar to find your camera
- Example: "Hikvision DS-2CD2032"
- Supports fuzzy search (typos are okay!)
3. **Discover Streams**
- Enter camera IP address (e.g., `192.168.1.100`)
- Enter credentials (username/password)
- Select discovered camera model
- Click "Discover Streams"
4. **Real-time Progress**
- Watch live updates as Strix tests different URLs
- See which streams are working
- Get detailed validation results
5. **Copy Stream URLs**
- Copy working URLs to use in Home Assistant
- Supports RTSP, HTTP, MJPEG, JPEG snapshots
### Camera Search
The search functionality includes:
- **3,600+ camera models** in database
- **Fuzzy matching** - handles typos and variations
- **Brand and model search** - search by manufacturer or model number
- **Popular cameras** - common models are prioritized
Example searches:
- "hikvision" - finds all Hikvision cameras
- "ds-2cd2032" - finds specific model
- "axis m1045" - finds AXIS camera
- "dahua ipc" - finds Dahua IP cameras
### Stream Discovery
Discovery process:
1. **ONVIF Discovery** - Attempts automatic detection via ONVIF protocol
2. **Model Patterns** - Tests URL patterns specific to camera model
3. **Popular Patterns** - Tests 150+ common stream URL patterns
4. **Validation** - Verifies each stream using ffprobe
Stream types discovered:
- RTSP streams (`rtsp://`)
- HTTP streams (`http://`)
- MJPEG streams (`http://.../video.cgi`)
- JPEG snapshots (`http://.../snapshot.jpg`)
### API Usage
#### Health Check
```bash
curl http://homeassistant.local:4567/api/v1/health
```
Response:
```json
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00Z"
}
```
#### Camera Search
```bash
curl -X POST http://homeassistant.local:4567/api/v1/cameras/search \
-H "Content-Type: application/json" \
-d '{
"query": "hikvision",
"limit": 10
}'
```
Response:
```json
{
"cameras": [
{
"brand": "Hikvision",
"model": "DS-2CD2032-I",
"score": 0.95
}
],
"count": 1
}
```
#### Stream Discovery (Server-Sent Events)
```bash
curl -N -X POST http://homeassistant.local:4567/api/v1/streams/discover \
-H "Content-Type: application/json" \
-d '{
"target": "192.168.1.100",
"model": "hikvision ds-2cd2032",
"username": "admin",
"password": "password",
"timeout": 240,
"max_streams": 10
}'
```
SSE Events:
```
event: progress
data: {"message": "Testing RTSP stream...", "percent": 25}
event: stream_found
data: {"url": "rtsp://192.168.1.100:554/stream1", "type": "rtsp"}
event: complete
data: {"total_found": 3, "duration": 45.2}
```
## Integration with Home Assistant
### Generic Camera Platform
```yaml
camera:
- platform: generic
name: Front Door
still_image_url: http://192.168.1.100/snapshot.jpg
stream_source: rtsp://admin:password@192.168.1.100:554/stream1
verify_ssl: false
```
### go2rtc Integration
```yaml
go2rtc:
streams:
front_door:
- rtsp://admin:password@192.168.1.100:554/stream1
back_yard:
- rtsp://admin:password@192.168.1.101:554/stream1
```
### Frigate Integration
```yaml
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://admin:password@192.168.1.100:554/stream1
roles:
- detect
- record
```
## Troubleshooting
### Add-on won't start
Check the logs:
1. Go to **Supervisor****Strix Camera Discovery** → **Log**
2. Look for error messages
3. Common issues:
- Port 4567 already in use
- Insufficient resources
- Database files missing
### Can't find camera model
- Try different search terms (brand name, model number)
- Use partial model numbers
- Check the full database at: `/app/data/brands/`
- If camera not in database, use "Generic" or similar brand camera
### Discovery finds no streams
Possible causes:
1. **Wrong IP address** - Verify camera is reachable: `ping 192.168.1.100`
2. **Wrong credentials** - Double-check username/password
3. **Firewall blocking** - Ensure RTSP port (554) is accessible
4. **ONVIF disabled** - Enable ONVIF in camera settings
5. **Network isolation** - Camera and HA must be on same network
Debug steps:
```bash
# Test if camera responds
curl -u admin:password http://192.168.1.100/
# Test RTSP stream manually
ffprobe rtsp://admin:password@192.168.1.100:554/stream1
```
### FFProbe warnings
If you see "ffprobe not found" warnings:
- This is normal if ffprobe isn't installed
- Stream validation will be limited to HTTP checks
- RTSP streams may not be validated properly
- The add-on includes ffprobe by default
### Slow discovery
Discovery can take 2-4 minutes because:
- Testing 150+ URL patterns
- Validating each stream with ffprobe
- Network latency to camera
- Camera response time
To speed up:
- Select specific camera model (reduces URLs to test)
- Reduce `timeout` value (default: 240 seconds)
- Reduce `max_streams` (stops after N streams found)
### Port conflicts
If port 4567 is in use:
1. Change the `port` option in add-on configuration
2. Restart the add-on
3. Access Web UI at new port
## Performance
### Resource Usage
Typical resource consumption:
- **Memory**: 50-100 MB
- **CPU**: Low (spikes during discovery)
- **Disk**: ~50 MB (including database)
- **Network**: Depends on discovery activity
### Concurrent Discoveries
The add-on can handle multiple concurrent discovery requests:
- Uses worker pool (20 concurrent workers)
- Queues excess requests
- No limit on simultaneous users
## Security
### Network Security
- Add-on runs in Home Assistant network
- No external internet access required
- All traffic is local to your network
### Credentials
- Camera credentials never stored
- Sent only during discovery session
- Not logged (even in debug mode)
- Transmitted over local network only
### Container Security
- Runs as non-root user (UID 1000)
- Minimal attack surface (Alpine base)
- No unnecessary packages
- Read-only filesystem where possible
## Database
### Camera Database
Location: `/app/data/brands/`
Contains:
- 3,600+ camera models
- Organized by brand (JSON files)
- Stream URL patterns
- Query parameter variations
Format example:
```json
{
"brand": "Hikvision",
"models": [
{
"model": "DS-2CD2032-I",
"patterns": [
"/Streaming/channels/101",
"/h264/ch1/main/av_stream"
]
}
]
}
```
### Updating Database
Database updates come with add-on updates:
- Check for updates in Add-on Store
- Updates include new camera models
- No manual database updates needed
## Support
### Getting Help
1. **Documentation**: Read this guide thoroughly
2. **Logs**: Check add-on logs for errors
3. **GitHub Issues**: https://github.com/eduard256/Strix/issues
4. **Community**: Home Assistant Community Forum
### Reporting Bugs
Include in bug reports:
1. Home Assistant version
2. Add-on version
3. Full logs from add-on
4. Camera brand/model
5. Steps to reproduce
### Feature Requests
Submit feature requests on GitHub with:
- Clear description of feature
- Use case / why it's needed
- Any relevant examples
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history.
## License
MIT License - See [LICENSE](https://github.com/eduard256/Strix/blob/main/LICENSE)
-44
View File
@@ -1,44 +0,0 @@
ARG BUILD_FROM
FROM ${BUILD_FROM}
# Install runtime dependencies
RUN apk add --no-cache \
ffmpeg \
ca-certificates \
tzdata \
wget \
&& rm -rf /var/cache/apk/*
# Set working directory
WORKDIR /app
# Copy binary from build context
COPY strix /app/strix
# Copy camera database (CRITICAL)
COPY data /app/data
# Copy WebUI files
COPY webui /app/webui
# Copy run script
COPY run.sh /
RUN chmod a+x /run.sh
# Create non-root user for security
RUN addgroup -g 1000 strix && \
adduser -D -u 1000 -G strix strix && \
chown -R strix:strix /app
# Switch to non-root user
USER strix
# Expose default port
EXPOSE 4567
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4567/api/v1/health || exit 1
# Start application
CMD ["/run.sh"]
-274
View File
@@ -1,274 +0,0 @@
# Home Assistant Add-on Installation Guide
This guide explains how to set up and publish the Strix Home Assistant Add-on.
## 📋 Overview
The add-on structure is ready and includes:
- ✅ `config.yaml` - Add-on configuration
- ✅ `Dockerfile` - Multi-arch Docker build
- ✅ `build.yaml` - Build configuration for aarch64/amd64/armv7
- ✅ `run.sh` - Startup script with HA integration
- ✅ `README.md` - User-facing documentation
- ✅ `DOCS.md` - Comprehensive usage guide
- ✅ `CHANGELOG.md` - Version history
- ✅ GitHub Actions workflow for automated builds
## 🚀 Quick Deployment
### Step 1: Enable GitHub Actions
The `.github/workflows/addon.yml` workflow will automatically:
1. Build binaries for all architectures (aarch64, amd64, armv7)
2. Create multi-arch Docker images
3. Push to GitHub Container Registry (ghcr.io)
4. Update version numbers on tags
No manual setup needed - just push to GitHub!
### Step 2: Create First Release
```bash
# Make sure all changes are committed
git add .
git commit -m "Add Home Assistant Add-on"
# Push to main branch (this will trigger a dev build)
git push origin main
# Create and push a version tag (this will trigger a release build)
git tag v1.0.0
git push origin v1.0.0
```
The GitHub Action will automatically build and publish Docker images to:
- `ghcr.io/eduard256/strix-addon-aarch64:latest`
- `ghcr.io/eduard256/strix-addon-amd64:latest`
- `ghcr.io/eduard256/strix-addon-armv7:latest`
### Step 3: Add Icons (Optional but Recommended)
Add these files to `homeassistant-addon/`:
- `icon.png` - 128x128px icon for the add-on store
- `logo.png` - 256x256px logo for the add-on page
Recommended: Simple owl or camera icon in Home Assistant style (blue/white theme).
### Step 4: Test Installation
After the GitHub Action completes:
1. In Home Assistant, go to **Supervisor** → **Add-on Store**
2. Click **⋮** (menu) → **Repositories**
3. Add: `https://github.com/eduard256/Strix`
4. Find "Strix Camera Discovery" in the store
5. Click **Install**
6. Configure and start the add-on
7. Click **Open Web UI**
## 🔄 Updating the Add-on
### For New Features/Fixes
```bash
# Make your changes to the codebase
git add .
git commit -m "feat: add new feature"
git push origin main
```
The dev build will automatically trigger, creating images tagged with `dev-<git-hash>`.
### For New Releases
```bash
# Update version in homeassistant-addon/config.yaml
sed -i 's/^version:.*/version: "1.1.0"/' homeassistant-addon/config.yaml
# Update CHANGELOG.md
# Add new version section
# Commit and tag
git add homeassistant-addon/config.yaml homeassistant-addon/CHANGELOG.md
git commit -m "release: v1.1.0"
git tag v1.1.0
git push origin main
git push origin v1.1.0
```
The release build will automatically create versioned images.
## 📦 Manual Build (Optional)
If you need to build locally for testing:
```bash
# Build for your architecture
cd homeassistant-addon
# Build the Go binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o strix \
../cmd/strix/main.go
# Copy required files
cp -r ../data .
cp -r ../webui .
# Build Docker image
docker build \
--build-arg BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.20 \
-t strix-addon:test .
# Test the image
docker run --rm -p 4567:4567 strix-addon:test
```
## 🔧 Configuration Options
Users can configure the add-on through the Home Assistant UI:
### Default Configuration
```yaml
log_level: info
port: 4567
strict_validation: true
```
### Advanced Options
Edit `homeassistant-addon/config.yaml` to add more options:
```yaml
options:
log_level: info
port: 4567
strict_validation: true
# Add new options here
schema:
log_level: list(debug|info|warn|error)
port: port
strict_validation: bool
# Add new option schemas here
```
Then update `run.sh` to use the new options:
```bash
NEW_OPTION=$(bashio::config 'new_option')
export STRIX_NEW_OPTION="${NEW_OPTION}"
```
## 🌐 Publishing to Community
### Option 1: Keep as Custom Repository (Recommended for Start)
Users add your repository manually:
```
https://github.com/eduard256/Strix
```
**Pros:**
- Full control
- Faster updates
- No approval process
**Cons:**
- Users must add repository manually
- Not in official add-on store
### Option 2: Submit to Home Assistant Community Add-ons
To get listed in the official community store:
1. Follow Home Assistant Add-on guidelines:
https://developers.home-assistant.io/docs/add-ons/
2. Submit to Community Add-ons repository:
https://github.com/home-assistant/addons
3. Wait for review and approval
**Pros:**
- Official recognition
- Easier for users to find
- Auto-update support
**Cons:**
- Strict guidelines
- Review process
- Slower updates
## 📊 Monitoring Builds
Check GitHub Actions status:
```
https://github.com/eduard256/Strix/actions
```
View published images:
```
https://github.com/eduard256/Strix/pkgs/container/strix-addon-amd64
https://github.com/eduard256/Strix/pkgs/container/strix-addon-aarch64
https://github.com/eduard256/Strix/pkgs/container/strix-addon-armv7
```
## 🐛 Troubleshooting
### Build Fails
Check GitHub Actions logs for errors. Common issues:
- Go build errors → Fix in main codebase
- Docker build errors → Check Dockerfile
- Permission errors → Ensure GITHUB_TOKEN has required permissions
### Add-on Won't Install
- Verify config.yaml syntax
- Check Docker images are published to ghcr.io
- Ensure repository.yaml is in root directory
- Verify architecture support matches user's system
### Add-on Won't Start
Check add-on logs in Home Assistant:
- Go to add-on page → **Log** tab
- Look for startup errors
- Common issues:
- Port already in use
- Missing data files
- Permission errors
## 📚 Resources
- [Home Assistant Add-on Documentation](https://developers.home-assistant.io/docs/add-ons/)
- [Add-on Configuration](https://developers.home-assistant.io/docs/add-ons/configuration)
- [Add-on Testing](https://developers.home-assistant.io/docs/add-ons/testing)
- [GitHub Actions](https://docs.github.com/en/actions)
## ✅ Checklist
Before first release:
- [ ] All code tested and working
- [ ] Version set in config.yaml
- [ ] CHANGELOG.md updated
- [ ] Icons added (icon.png, logo.png)
- [ ] README.md reviewed
- [ ] DOCS.md reviewed
- [ ] GitHub Actions workflow tested
- [ ] Repository URL correct in config files
- [ ] Git tag created (v1.0.0)
- [ ] Docker images published to ghcr.io
- [ ] Test installation in Home Assistant
## 🎉 You're Ready!
Once you've completed the checklist above, your Home Assistant Add-on is ready for users!
Share it with the community:
- Home Assistant Forums
- Reddit r/homeassistant
- GitHub Discussions
- Discord servers
-261
View File
@@ -1,261 +0,0 @@
# Strix - Home Assistant Add-on
## 🎯 Что создано
Полностью готовый Home Assistant Add-on для Strix с автоматической сборкой и публикацией.
## 📁 Структура
```
homeassistant-addon/
├── config.yaml # Конфигурация аддона для HA
├── Dockerfile # Multi-arch Docker образ
├── build.yaml # Настройки сборки (aarch64/amd64/armv7)
├── run.sh # Скрипт запуска с интеграцией HA
├── README.md # Документация для пользователей (EN)
├── README-RU.md # Документация для пользователей (RU)
├── DOCS.md # Подробная документация
├── CHANGELOG.md # История версий
├── INSTALLATION.md # Инструкция по установке и публикации
├── icon.png.todo # Заметка про иконку 128x128
└── logo.png.todo # Заметка про логотип 256x256
```
## ✨ Возможности
- ✅ **Автоматическая сборка** через GitHub Actions
- ✅ **Multi-arch поддержка**: aarch64, amd64, armv7
- ✅ **Web UI интеграция** в боковую панель Home Assistant
- ✅ **Ingress поддержка** для бесшовной интеграции
- ✅ **Настройка через UI** Home Assistant
- ✅ **Автообновления** через Supervisor
- ✅ **Полная документация** на русском и английском
## 🚀 Быстрый старт
### 1. Завершить подготовку
```bash
# Добавить иконки (опционально, но рекомендуется)
# - icon.png (128x128px)
# - logo.png (256x256px)
# Закоммитить все изменения
git add .
git commit -m "Add Home Assistant Add-on"
git push origin main
```
### 2. Создать релиз
```bash
# Создать тег версии
git tag v1.0.0
git push origin v1.0.0
```
GitHub Actions автоматически:
- Соберет бинарники для всех архитектур
- Создаст Docker образы
- Опубликует в GitHub Container Registry
### 3. Установить в Home Assistant
1. **Supervisor****Add-on Store****⋮** → **Repositories**
2. Добавить: `https://github.com/eduard256/Strix`
3. Найти **Strix Camera Discovery**
4. Нажать **Install**
5. Нажать **Start**
6. Нажать **Open Web UI**
## ⚙️ Конфигурация
Пользователи могут настроить через UI Home Assistant:
```yaml
log_level: info # debug, info, warn, error
port: 4567 # Порт веб-интерфейса
strict_validation: true # Строгая валидация потоков
```
## 🔄 Обновление
### Новые функции/исправления
```bash
git add .
git commit -m "feat: новая функция"
git push origin main
```
Автоматически создастся dev-сборка.
### Новый релиз
```bash
# Обновить версию
sed -i 's/^version:.*/version: "1.1.0"/' homeassistant-addon/config.yaml
# Обновить CHANGELOG.md
nano homeassistant-addon/CHANGELOG.md
# Закоммитить и создать тег
git add .
git commit -m "release: v1.1.0"
git tag v1.1.0
git push origin main v1.1.0
```
## 🏗️ Как это работает
### GitHub Actions Workflow
Файл: `.github/workflows/addon.yml`
При пуше в `main` или создании тега `v*`:
1. **Build Stage** (для каждой архитектуры):
- Собирает Go бинарник
- Копирует данные и WebUI
- Создает Docker образ
- Публикует в ghcr.io
2. **Update Repository**:
- Обновляет версию в config.yaml
- Обновляет repository.yaml
- Коммитит изменения (только для тегов)
### Docker Images
Публикуются в GitHub Container Registry:
- `ghcr.io/eduard256/strix-addon-aarch64:latest`
- `ghcr.io/eduard256/strix-addon-amd64:latest`
- `ghcr.io/eduard256/strix-addon-armv7:latest`
Версионные теги:
- `ghcr.io/eduard256/strix-addon-aarch64:1.0.0`
- и т.д.
## 📦 Что включено
### Runtime зависимости
- **ffmpeg** - для валидации RTSP потоков
- **ca-certificates** - для HTTPS
- **tzdata** - для корректных временных меток
- **wget** - для healthcheck
### Данные приложения
- **База камер** - 3,600+ моделей
- **WebUI** - встроенный веб-интерфейс
- **API** - RESTful API для автоматизации
## 🔒 Безопасность
- Запуск от non-root пользователя (UID 1000)
- Минимальный Alpine образ
- Отсутствие хранения credentials
- Работа только в локальной сети
## 📚 Документация
- **README.md** - Краткое описание для пользователей
- **DOCS.md** - Полная документация по использованию
- **CHANGELOG.md** - История изменений
- **INSTALLATION.md** - Инструкция для разработчиков
## 🎨 TODO (опционально)
1. **Иконки**:
- Создать `icon.png` (128x128px)
- Создать `logo.png` (256x256px)
- Стиль: сова или камера, синий/белый (Home Assistant style)
2. **Улучшения**:
- Добавить скриншоты в README.md
- Создать видео-инструкцию
- Перевести DOCS.md на русский
3. **Интеграции**:
- Автоматическое добавление камер в HA
- Генератор конфигов для go2rtc
- Генератор конфигов для Frigate
## 🤝 Публикация
### Вариант 1: Кастомный репозиторий (Рекомендуется)
Пользователи добавляют вручную:
```
https://github.com/eduard256/Strix
```
**Плюсы**:
- Полный контроль
- Быстрые обновления
- Нет процесса одобрения
### Вариант 2: Home Assistant Community Add-ons
Отправить в официальный репозиторий:
https://github.com/home-assistant/addons
**Плюсы**:
- Официальное признание
- Проще найти пользователям
- Автообновления
**Минусы**:
- Строгие требования
- Процесс ревью
- Медленные обновления
## 🐛 Решение проблем
### Сборка не прошла
Проверить GitHub Actions:
```
https://github.com/eduard256/Strix/actions
```
### Аддон не устанавливается
- Проверить синтаксис config.yaml
- Убедиться что образы опубликованы в ghcr.io
- Проверить repository.yaml в корне
### Аддон не запускается
Смотреть логи в Home Assistant:
- Страница аддона → вкладка **Log**
- Частые проблемы:
- Порт занят
- Отсутствуют файлы данных
- Ошибки прав доступа
## ✅ Чеклист перед релизом
- [ ] Код протестирован
- [ ] Версия установлена в config.yaml
- [ ] CHANGELOG.md обновлен
- [ ] Иконки добавлены (опционально)
- [ ] README.md проверен
- [ ] DOCS.md проверен
- [ ] GitHub Actions протестирован
- [ ] Создан git тег (v1.0.0)
- [ ] Docker образы опубликованы
- [ ] Тестовая установка в Home Assistant
## 🎉 Готово!
После выполнения чеклиста ваш Home Assistant Add-on готов к использованию!
Поделитесь с сообществом:
- Форум Home Assistant
- Reddit r/homeassistant
- GitHub Discussions
- Discord серверы
---
**Вопросы?** Создайте Issue на GitHub: https://github.com/eduard256/Strix/issues
-145
View File
@@ -1,145 +0,0 @@
# Home Assistant Add-on: Strix Camera Discovery
![Supports aarch64 Architecture][aarch64-shield]
![Supports amd64 Architecture][amd64-shield]
![Supports armv7 Architecture][armv7-shield]
Strix is a smart IP camera stream discovery system that automatically finds and validates camera streams. It eliminates the need for manual URL configuration by using ONVIF discovery, comprehensive camera database, and intelligent stream testing.
## About
This add-on provides Strix - an intelligent camera discovery service for Home Assistant. It includes:
- **3,600+ Camera Models Database** - Comprehensive coverage of IP camera brands and models
- **ONVIF Discovery** - Automatic camera detection on your network
- **Smart Stream Testing** - Validates RTSP, HTTP, MJPEG, and JPEG snapshot URLs
- **Real-time Updates** - Server-Sent Events (SSE) for live discovery progress
- **Web Interface** - Beautiful UI for easy camera management
- **RESTful API** - Integrate with your automation workflows
## Installation
1. Add this repository to your Home Assistant Add-on Store:
- Click on "Add-on Store" in the Home Assistant Supervisor panel
- Click the three dots menu (top right) and select "Repositories"
- Add the URL: `https://github.com/eduard256/Strix`
- Click "Add"
2. Find "Strix Camera Discovery" in the add-on store and click "Install"
3. After installation, click "Start" to run the add-on
4. Open the Web UI by clicking "Open Web UI" button
## Configuration
```yaml
log_level: info
port: 4567
strict_validation: true
```
### Option: `log_level`
The `log_level` option controls the level of log output by the addon.
- `debug` - Shows detailed debug information
- `info` - Normal (default) log level
- `warn` - Only warnings and errors
- `error` - Only errors
### Option: `port`
The `port` option allows you to change the port on which Strix runs. Default is `4567`.
### Option: `strict_validation`
When enabled (default), Strix performs stricter stream validation:
- Verifies minimum image sizes for snapshots
- Requires at least one video stream in RTSP sources
- Reduces false positives
## How to use
1. **Open the Web UI** - Click "Open Web UI" in the add-on panel or navigate to `http://homeassistant.local:4567`
2. **Search for Camera** - Enter your camera brand/model (e.g., "Hikvision DS-2CD2032")
3. **Discover Streams** - Enter camera IP, credentials, and click "Discover"
4. **Get Stream URLs** - Copy working stream URLs for use in Home Assistant
## Example: Adding discovered camera to Home Assistant
After discovering a camera stream, add it to your `configuration.yaml`:
```yaml
camera:
- platform: generic
name: Front Door Camera
still_image_url: http://192.168.1.100/snapshot.jpg
stream_source: rtsp://admin:password@192.168.1.100:554/stream1
```
Or use with go2rtc for better performance:
```yaml
go2rtc:
streams:
front_door: rtsp://admin:password@192.168.1.100:554/stream1
```
## API Endpoints
The add-on exposes the following API endpoints:
### Health Check
```bash
GET http://homeassistant.local:4567/api/v1/health
```
### Camera Search
```bash
POST http://homeassistant.local:4567/api/v1/cameras/search
Content-Type: application/json
{
"query": "hikvision",
"limit": 10
}
```
### Stream Discovery (SSE)
```bash
POST http://homeassistant.local:4567/api/v1/streams/discover
Content-Type: application/json
{
"target": "192.168.1.100",
"model": "hikvision ds-2cd2032",
"username": "admin",
"password": "password",
"timeout": 240,
"max_streams": 10
}
```
## Support
Got questions or issues?
- [GitHub Issues](https://github.com/eduard256/Strix/issues)
- [Home Assistant Community](https://community.home-assistant.io/)
## Contributing
This is an active open-source project. We are always open to people who want to
use the code or contribute to it.
## License
MIT License - see the [LICENSE](https://github.com/eduard256/Strix/blob/main/LICENSE) file for details
[aarch64-shield]: https://img.shields.io/badge/aarch64-yes-green.svg
[amd64-shield]: https://img.shields.io/badge/amd64-yes-green.svg
[armv7-shield]: https://img.shields.io/badge/armv7-yes-green.svg
-11
View File
@@ -1,11 +0,0 @@
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.20
amd64: ghcr.io/home-assistant/amd64-base:3.20
armv7: ghcr.io/home-assistant/armv7-base:3.20
labels:
org.opencontainers.image.title: "Strix Camera Discovery"
org.opencontainers.image.description: "Smart IP camera stream discovery system"
org.opencontainers.image.source: "https://github.com/eduard256/Strix"
org.opencontainers.image.licenses: "MIT"
args:
STRIX_VERSION: "1.0.0"
-33
View File
@@ -1,33 +0,0 @@
name: Strix - Camera Stream Discovery
version: "1.0.0"
slug: strix
description: Smart IP camera stream discovery system with ONVIF support and comprehensive camera database
url: https://github.com/eduard256/Strix
arch:
- aarch64
- amd64
- armv7
init: false
startup: application
boot: auto
host_network: true
panel_icon: mdi:cctv
panel_title: Strix
panel_admin: false
webui: http://[HOST]:4567
ingress: true
ingress_port: 4567
ingress_stream: true
ports:
4567/tcp: 4567
ports_description:
4567/tcp: Web interface and API
options:
log_level: info
port: 4567
strict_validation: true
schema:
log_level: list(debug|info|warn|error)
port: port
strict_validation: bool
image: ghcr.io/eduard256/strix-addon-{arch}
-6
View File
@@ -1,6 +0,0 @@
TODO: Add 128x128px PNG icon for the add-on
The icon should represent Strix (owl/camera theme)
Recommended: Use a simple owl silhouette or camera icon in Home Assistant style
Place the file as: homeassistant-addon/icon.png
For now, you can use any 128x128 PNG image as a placeholder.
-6
View File
@@ -1,6 +0,0 @@
TODO: Add 256x256px PNG logo for the add-on
The logo should represent Strix branding
Recommended: Higher resolution version of the icon
Place the file as: homeassistant-addon/logo.png
For now, you can use any 256x256 PNG image as a placeholder.
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/with-contenv bashio
# Get configuration from Home Assistant
LOG_LEVEL=$(bashio::config 'log_level')
PORT=$(bashio::config 'port')
STRICT_VALIDATION=$(bashio::config 'strict_validation')
# Print banner
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bashio::log.info " ____ _ _ "
bashio::log.info " / ___|| |_ _ __(_)_ __"
bashio::log.info " \___ \| __| '__| \ \/ /"
bashio::log.info " ___) | |_| | | |> < "
bashio::log.info " |____/ \__|_| |_/_/\_\\"
bashio::log.info ""
bashio::log.info " Smart IP Camera Stream Discovery System"
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Set environment variables
export STRIX_LOG_LEVEL="${LOG_LEVEL}"
export STRIX_LOG_FORMAT="json"
export STRIX_API_LISTEN=":${PORT}"
export STRIX_DATA_PATH="/app/data"
bashio::log.info "Starting Strix with the following configuration:"
bashio::log.info " - Log Level: ${LOG_LEVEL}"
bashio::log.info " - Port: ${PORT}"
bashio::log.info " - Strict Validation: ${STRICT_VALIDATION}"
bashio::log.info " - Data Path: ${STRIX_DATA_PATH}"
# Check if ffprobe is available
if command -v ffprobe &> /dev/null; then
bashio::log.info "FFProbe found: $(ffprobe -version | head -n1)"
else
bashio::log.warning "FFProbe not found, stream validation will be limited"
fi
# Start Strix
bashio::log.info "Starting Strix server..."
exec /app/strix
+1 -1
View File
@@ -43,7 +43,7 @@ func NewHealthHandler(version string, logger interface{ Info(string, ...any) })
// ServeHTTP handles health check requests // ServeHTTP handles health check requests
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return 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 loader *database.Loader
searchEngine *database.SearchEngine searchEngine *database.SearchEngine
scanner *discovery.Scanner scanner *discovery.Scanner
probeService *discovery.ProbeService
sseServer *sse.Server sseServer *sse.Server
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) } logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
} }
@@ -75,6 +76,23 @@ func NewServer(
// Initialize SSE server // Initialize SSE server
sseServer := sse.NewServer(logger) 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 // Create server
server := &Server{ server := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
@@ -82,6 +100,7 @@ func NewServer(
loader: loader, loader: loader,
searchEngine: searchEngine, searchEngine: searchEngine,
scanner: scanner, scanner: scanner,
probeService: probeService,
sseServer: sseServer, sseServer: sseServer,
logger: logger, logger: logger,
} }
@@ -119,14 +138,19 @@ func (s *Server) setupRoutes() {
}) })
// API routes (mounted at /api/v1 in main.go) // API routes (mounted at /api/v1 in main.go)
// Health check // Health check (GET + HEAD for Docker/CasaOS healthcheck compatibility)
s.router.Get("/health", handlers.NewHealthHandler("1.0.0", s.logger).ServeHTTP) healthHandler := handlers.NewHealthHandler(s.config.Version, s.logger).ServeHTTP
s.router.Get("/health", healthHandler)
s.router.Head("/health", healthHandler)
// Camera search // Camera search
s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP) s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP)
// Stream discovery (SSE) // Stream discovery (SSE)
s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP) 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 // 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)
}
+18 -15
View File
@@ -302,11 +302,13 @@ func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscovery
"model", req.Model, "model", req.Model,
"limit", req.ModelLimit) "limit", req.ModelLimit)
// Search for similar models // Search for cameras using intelligent brand+model search
cameras, err := s.searchEngine.SearchByModel(req.Model, 0.8, req.ModelLimit) searchResp, err := s.searchEngine.Search(req.Model, req.ModelLimit)
if err != nil { if err != nil {
s.logger.Error("model search failed", err) s.logger.Error("model search failed", err)
} else { } else {
cameras := searchResp.Cameras
// Collect entries from all matching cameras // Collect entries from all matching cameras
var entries []models.CameraEntry var entries []models.CameraEntry
for _, camera := range cameras { for _, camera := range cameras {
@@ -409,26 +411,27 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.
defer cancelProgress() defer cancelProgress()
go func() { go func() {
ticker := time.NewTicker(3 * time.Second) // Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
defer ticker.Stop() // Normal mode: 1 second, Ingress mode: 3 seconds
progressInterval := 1 * time.Second
if streamWriter.IsIngress() {
progressInterval = 3 * time.Second
}
lastTested := int32(0) ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for { for {
select { select {
case <-progressCtx.Done(): case <-progressCtx.Done():
return return
case <-ticker.C: case <-ticker.C:
currentTested := atomic.LoadInt32(&tested) // Send progress to prevent WriteTimeout and show scanning activity
// Only send if there's been progress _ = streamWriter.SendJSON("progress", models.ProgressMessage{
if currentTested != lastTested { Tested: int(atomic.LoadInt32(&tested)),
_ = streamWriter.SendJSON("progress", models.ProgressMessage{ Found: int(atomic.LoadInt32(&found)),
Tested: int(currentTested), Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
Found: int(atomic.LoadInt32(&found)), })
Remaining: len(streams) - int(currentTested),
})
lastTested = currentTested
}
} }
} }
}() }()
+32 -28
View File
@@ -174,34 +174,38 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
// Common placeholders // Common placeholders
replacements := map[string]string{ replacements := map[string]string{
"[CHANNEL]": strconv.Itoa(ctx.Channel), "[CHANNEL]": strconv.Itoa(ctx.Channel),
"[channel]": strconv.Itoa(ctx.Channel), "[channel]": strconv.Itoa(ctx.Channel),
"{channel}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel} "[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1), // For Hikvision-style channels (101, 201, 301...)
"{CHANNEL}": strconv.Itoa(ctx.Channel), "[channel+1]": strconv.Itoa(ctx.Channel + 1),
"[WIDTH]": strconv.Itoa(ctx.Width), "{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
"[width]": strconv.Itoa(ctx.Width), "{channel}": strconv.Itoa(ctx.Channel),
"[HEIGHT]": strconv.Itoa(ctx.Height), "{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
"[height]": strconv.Itoa(ctx.Height), "{channel+1}": strconv.Itoa(ctx.Channel + 1),
"[USERNAME]": ctx.Username, "[WIDTH]": strconv.Itoa(ctx.Width),
"[username]": ctx.Username, "[width]": strconv.Itoa(ctx.Width),
"[PASSWORD]": ctx.Password, "[HEIGHT]": strconv.Itoa(ctx.Height),
"[password]": ctx.Password, "[height]": strconv.Itoa(ctx.Height),
"[PASWORD]": ctx.Password, // Handle typo in database "[USERNAME]": ctx.Username,
"[pasword]": ctx.Password, "[username]": ctx.Username,
"[USER]": ctx.Username, "[PASSWORD]": ctx.Password,
"[user]": ctx.Username, "[password]": ctx.Password,
"[PASS]": ctx.Password, "[PASWORD]": ctx.Password, // Handle typo in database
"[pass]": ctx.Password, "[pasword]": ctx.Password,
"[PWD]": ctx.Password, "[USER]": ctx.Username,
"[pwd]": ctx.Password, "[user]": ctx.Username,
"[IP]": ctx.IP, "[PASS]": ctx.Password,
"[ip]": ctx.IP, "[pass]": ctx.Password,
"[PORT]": strconv.Itoa(ctx.Port), "[PWD]": ctx.Password,
"[port]": strconv.Itoa(ctx.Port), "[pwd]": ctx.Password,
"[AUTH]": auth, // base64(username:password) for basic auth "[IP]": ctx.IP,
"[auth]": auth, "[ip]": ctx.IP,
"[TOKEN]": "", // Empty for now "[PORT]": strconv.Itoa(ctx.Port),
"[token]": "", "[port]": strconv.Itoa(ctx.Port),
"[AUTH]": auth, // base64(username:password) for basic auth
"[auth]": auth,
"[TOKEN]": "", // Empty for now
"[token]": "",
} }
// Replace all placeholders // Replace all placeholders
+48 -2
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -14,6 +15,7 @@ import (
// Config holds application configuration // Config holds application configuration
type Config struct { type Config struct {
Version string // Application version, set by caller after Load()
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Scanner ScannerConfig Scanner ScannerConfig
@@ -33,6 +35,7 @@ type DatabaseConfig struct {
BrandsPath string BrandsPath string
PatternsPath string PatternsPath string
ParametersPath string ParametersPath string
OUIPath string
CacheEnabled bool CacheEnabled bool
CacheTTL time.Duration CacheTTL time.Duration
} }
@@ -73,13 +76,14 @@ func Load() *Config {
Server: ServerConfig{ Server: ServerConfig{
Listen: ":4567", // Default listen address Listen: ":4567", // Default listen address
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, // Increased for SSE long-polling
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
DataPath: dataPath, DataPath: dataPath,
BrandsPath: filepath.Join(dataPath, "brands"), BrandsPath: filepath.Join(dataPath, "brands"),
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"), PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
ParametersPath: filepath.Join(dataPath, "query_parameters.json"), ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
CacheEnabled: true, CacheEnabled: true,
CacheTTL: 5 * time.Minute, 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" 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 { if err := loadYAML(cfg); err == nil {
configSource = "strix.yaml" configSource = "strix.yaml"
} }
@@ -148,6 +158,42 @@ func loadYAML(cfg *Config) error {
return nil 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 // validateListen validates the listen address format and port range
func validateListen(listen string) error { func validateListen(listen string) error {
if listen == "" { 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"`
}
+67 -5
View File
@@ -5,9 +5,20 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "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 // Event represents a Server-Sent Event
type Event struct { type Event struct {
ID string ID string
@@ -253,8 +264,9 @@ func generateClientID() string {
// StreamWriter provides a simple interface for writing SSE events // StreamWriter provides a simple interface for writing SSE events
type StreamWriter struct { type StreamWriter struct {
client *Client client *Client
server *Server server *Server
isIngress bool // True when running through Home Assistant Ingress proxy
} }
// NewStreamWriter creates a new stream writer for a client // 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 // Send initial flush to establish connection
flusher.Flush() flusher.Flush()
// Detect Home Assistant Ingress mode by checking for X-Ingress-Path header
isIngress := r.Header.Get(IngressHeader) != ""
// Create client // Create client
ctx, cancel := context.WithCancel(r.Context()) ctx, cancel := context.WithCancel(r.Context())
client := &Client{ client := &Client{
@@ -287,8 +302,9 @@ func (s *Server) NewStreamWriter(w http.ResponseWriter, r *http.Request) (*Strea
} }
return &StreamWriter{ return &StreamWriter{
client: client, client: client,
server: s, server: s,
isIngress: isIngress,
}, nil }, nil
} }
@@ -304,7 +320,48 @@ func (sw *StreamWriter) SendEvent(eventType string, data interface{}) error {
return fmt.Errorf("response does not support flushing") 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 // 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) 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 // SendMessage sends a simple message
func (sw *StreamWriter) SendMessage(message string) error { func (sw *StreamWriter) SendMessage(message string) error {
return sw.SendEvent("message", map[string]string{"message": message}) return sw.SendEvent("message", map[string]string{"message": message})
-3
View File
@@ -1,3 +0,0 @@
name: Strix Home Assistant Add-ons
url: https://github.com/eduard256/Strix
maintainer: eduard256
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "webui", "name": "webui",
"version": "1.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
+589 -101
View File
@@ -79,6 +79,37 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
/* ===== MOCK MODE BADGE ===== */
.mock-badge {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(245, 158, 11, 0.15);
border: 1px solid var(--warning);
border-radius: 6px;
color: var(--warning);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.05em;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);
backdrop-filter: blur(10px);
animation: fadeIn var(--transition-base);
}
.mock-badge svg {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ===== LAYOUT ===== */ /* ===== LAYOUT ===== */
#app { #app {
min-height: 100vh; min-height: 100vh;
@@ -555,148 +586,318 @@ body {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
/* ===== CAROUSEL ===== */ /* ===== STREAMS LIST ===== */
.carousel-wrapper { .streams-list {
position: relative; display: flex;
flex-direction: column;
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; display: flex;
align-items: center; align-items: center;
gap: var(--space-4); 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); margin-bottom: var(--space-4);
} }
.carousel { .stream-subgroup-header {
flex: 1; 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;
}
.streams-list::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
.streams-list::-webkit-scrollbar-thumb {
background: var(--purple-primary);
border-radius: 4px;
}
.streams-list::-webkit-scrollbar-thumb:hover {
background: var(--purple-light);
}
/* Stream item */
.stream-item {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all var(--transition-base);
overflow: hidden; overflow: hidden;
} }
.carousel-track { .stream-item:hover {
display: flex;
transition: transform var(--transition-slow);
}
.stream-card {
flex: 0 0 100%;
width: 100%;
padding: var(--space-6);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
transition: all var(--transition-base);
}
.stream-card:hover {
border-color: var(--purple-primary); border-color: var(--purple-primary);
box-shadow: 0 8px 24px var(--purple-glow); box-shadow: 0 4px 12px var(--purple-glow);
} }
.stream-type { .stream-item.expanded {
border-color: var(--purple-primary);
}
/* Stream item header */
.stream-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
cursor: pointer;
}
.stream-item-main {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 1;
min-width: 0;
}
.stream-info-left {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.stream-type-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
color: var(--purple-primary); color: var(--purple-primary);
margin-bottom: var(--space-4);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap;
} }
.stream-type svg { .stream-type-badge svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
flex-shrink: 0;
} }
.stream-url { .stream-url-preview {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stream-toggle {
background: none;
border: none;
padding: var(--space-2);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
}
.stream-toggle:hover {
color: var(--purple-primary);
}
.stream-toggle .chevron {
transition: transform var(--transition-fast);
}
.stream-item.expanded .stream-toggle .chevron {
transform: rotate(180deg);
}
.btn-use-stream {
flex-shrink: 0;
white-space: nowrap;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
}
/* Stream item details */
.stream-item-details {
max-height: 0;
overflow: hidden;
transition: max-height var(--transition-base);
padding: 0 var(--space-4);
}
.stream-item-details.visible {
max-height: 500px;
padding: 0 var(--space-4) var(--space-4) var(--space-4);
}
.stream-url-full {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-primary); color: var(--text-primary);
word-break: break-all; word-break: break-all;
margin-bottom: var(--space-4); margin-bottom: var(--space-3);
padding: var(--space-3); padding: var(--space-3);
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border-color);
} }
.stream-meta { .stream-meta-item {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.stream-actions { .stream-meta-item:last-child {
margin-top: var(--space-6); margin-bottom: 0;
} }
.carousel-arrow { .meta-label {
flex-shrink: 0; font-weight: 600;
width: 48px; color: var(--text-primary);
height: 48px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
color: var(--text-secondary);
}
.carousel-arrow:hover:not(:disabled) {
background: var(--purple-primary);
border-color: var(--purple-primary);
color: white;
box-shadow: 0 4px 12px var(--purple-glow);
}
.carousel-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
@media (max-width: 767px) {
.carousel-wrapper {
flex-direction: column;
gap: var(--space-3);
}
.carousel-arrow {
display: none;
}
}
.carousel-info {
text-align: center;
}
.carousel-counter {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.carousel-dots {
display: flex;
justify-content: center;
gap: var(--space-2);
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(139, 92, 246, 0.3);
border: none;
cursor: pointer;
transition: all var(--transition-base);
padding: 0;
}
.carousel-dot.active {
width: 24px;
border-radius: 4px;
background: var(--purple-primary);
box-shadow: 0 0 8px var(--purple-glow);
} }
/* ===== SELECTED STREAM INFO ===== */ /* ===== SELECTED STREAM INFO ===== */
@@ -718,6 +919,9 @@ body {
} }
.stream-label { .stream-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs); font-size: var(--text-xs);
font-weight: 600; font-weight: 600;
color: var(--text-tertiary); color: var(--text-tertiary);
@@ -889,6 +1093,70 @@ body {
display: none; 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 ===== */ /* ===== ANIMATIONS ===== */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
@@ -976,6 +1244,104 @@ body {
transform: translateY(0); transform: translateY(0);
} }
/* Button with tooltip wrapper */
.button-with-tooltip {
position: relative;
width: 100%;
}
.button-with-tooltip .btn-generate {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
/* Button with tooltip in secondary-actions */
.secondary-actions .button-with-tooltip {
flex: 1.2;
width: auto;
}
.secondary-actions .button-with-tooltip .btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.secondary-actions .button-with-tooltip:last-child {
flex: 0.8;
}
/* Info icon inside button */
.info-icon-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: help;
color: rgba(255, 255, 255, 0.7);
transition: color var(--transition-fast);
}
.info-icon-button:hover {
color: rgba(255, 255, 255, 1);
}
.info-icon-button svg {
width: 18px;
height: 18px;
}
/* Info icon inside outline button */
.info-icon-button-outline {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon-button-outline:hover {
color: var(--purple-primary);
}
.info-icon-button-outline svg {
width: 18px;
height: 18px;
}
/* Info icon inside stream type badge */
.info-icon-stream {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: var(--space-2);
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon-stream:hover {
color: var(--purple-primary);
}
.info-icon-stream svg {
width: 16px;
height: 16px;
}
.frigate-output-section { .frigate-output-section {
margin-top: var(--space-6); margin-top: var(--space-6);
padding-top: var(--space-6); padding-top: var(--space-6);
@@ -987,6 +1353,128 @@ body {
display: none; display: none;
} }
/* ===== TOOLTIPS ===== */
.label-with-info {
display: flex;
align-items: center;
gap: var(--space-2);
}
.info-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
cursor: help;
color: var(--text-tertiary);
transition: color var(--transition-fast);
}
.info-icon:hover {
color: var(--purple-primary);
}
.info-icon svg {
width: 16px;
height: 16px;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated);
border: 1px solid var(--purple-primary);
border-radius: 8px;
padding: var(--space-4);
width: 320px;
max-width: 90vw;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
pointer-events: none;
}
/* Tooltip opens downward */
.tooltip.tooltip-down {
bottom: auto;
top: calc(100% + 8px);
}
.info-icon:hover .tooltip {
opacity: 1;
visibility: visible;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--purple-primary);
}
/* Arrow for downward tooltip */
.tooltip.tooltip-down::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--purple-primary);
}
.tooltip-title {
font-weight: 600;
color: var(--purple-primary);
margin-bottom: var(--space-2);
font-size: var(--text-sm);
}
.tooltip-text {
font-size: var(--text-xs);
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.tooltip-text:last-child {
margin-bottom: 0;
}
.tooltip-examples {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
.tooltip-examples-title {
font-weight: 600;
color: var(--text-primary);
font-size: var(--text-xs);
margin-bottom: var(--space-2);
}
.tooltip-example {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--purple-light);
background: var(--bg-secondary);
padding: var(--space-1) var(--space-2);
border-radius: 4px;
margin-bottom: var(--space-1);
display: block;
}
.tooltip-example:last-child {
margin-bottom: 0;
}
/* ===== UTILITIES ===== */ /* ===== UTILITIES ===== */
.hidden { .hidden {
display: none !important; display: none !important;
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Simple development server for Strix WebUI
# This allows you to test the UI without running the Go backend
PORT=${1:-8080}
echo "Starting development server on port $PORT"
echo "Open: http://localhost:$PORT?mock=true"
echo ""
echo "Press Ctrl+C to stop"
# Use Python's built-in HTTP server
cd "$(dirname "$0")"
python3 -m http.server $PORT
+377 -82
View File
@@ -5,9 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f"> <meta name="theme-color" content="#0a0a0f">
<title>Strix - Camera Stream Discovery</title> <title>Strix - Camera Stream Discovery</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="css/main.css">
</head> </head>
<body> <body>
<!-- Mock Mode Indicator -->
<div id="mock-mode-badge" class="mock-badge hidden">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
MOCK MODE
</div>
<div id="app"> <div id="app">
<!-- Screen 1: Initial Address Input --> <!-- Screen 1: Initial Address Input -->
<div id="screen-address" class="screen active"> <div id="screen-address" class="screen active">
@@ -33,7 +42,30 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="network-address" class="label">Network Address</label> <label for="network-address" class="label label-with-info">
Network Address
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Network Address</div>
<p class="tooltip-text">Enter the network location of your IP camera. This can be an IP address, hostname, or a complete RTSP URL.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Accepted formats:</div>
<code class="tooltip-example">192.168.1.100 - IP address only</code>
<code class="tooltip-example">camera.local - Hostname/mDNS</code>
<code class="tooltip-example">rtsp://user:pass@192.168.1.100/stream - Full URL</code>
</div>
<p class="tooltip-text"><strong>Where to find it:</strong><br>Check your camera's web interface, router's DHCP leases page, or network scanner app. Most cameras use addresses in the 192.168.x.x range.</p>
<p class="tooltip-text"><strong>Next steps:</strong><br>After entering the address, click "Check Address" to validate the camera connection and proceed to stream discovery.</p>
</div>
</span>
</label>
<input <input
type="text" type="text"
id="network-address" id="network-address"
@@ -73,7 +105,26 @@
<h2 class="screen-title">Camera Configuration</h2> <h2 class="screen-title">Camera Configuration</h2>
<div class="form-group"> <div class="form-group">
<label for="address-validated" class="label">Network Address</label> <label for="address-validated" class="label label-with-info">
Network Address
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Network Address</div>
<p class="tooltip-text">The IP address, hostname, or full RTSP URL of your camera. This is the network location where the camera can be reached.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Examples:</div>
<code class="tooltip-example">192.168.1.100</code>
<code class="tooltip-example">camera.local</code>
<code class="tooltip-example">rtsp://admin:pass@192.168.1.100</code>
</div>
<p class="tooltip-text">Find it in your camera's network settings or router's device list (DHCP leases).</p>
</div>
</span>
</label>
<div class="input-validated"> <div class="input-validated">
<input <input
type="text" type="text"
@@ -88,7 +139,27 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="camera-model" class="label">Camera Model <span class="optional">(optional)</span></label> <label for="camera-model" class="label label-with-info">
Camera Model <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Camera Model</div>
<p class="tooltip-text">The manufacturer and model of your IP camera. This helps the system use optimized stream paths for your specific camera brand.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Examples:</div>
<code class="tooltip-example">Hikvision: DS-2CD2142FWD</code>
<code class="tooltip-example">Dahua: IPC-HDW4433C</code>
<code class="tooltip-example">Amcrest: IP4M-1041</code>
<code class="tooltip-example">Reolink: RLC-410</code>
</div>
<p class="tooltip-text">Find it on the camera label, in the camera's web interface (Device Info), or in your purchase documentation. Leave empty for auto-detection.</p>
</div>
</span>
</label>
<div class="autocomplete-wrapper"> <div class="autocomplete-wrapper">
<input <input
type="text" type="text"
@@ -104,18 +175,52 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username" class="label">Username</label> <label for="username" class="label label-with-info">
Username
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Username</div>
<p class="tooltip-text">The authentication username for accessing your camera's RTSP stream. This is required for most IP cameras to access video feeds.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common defaults:</div>
<code class="tooltip-example">admin</code>
<code class="tooltip-example">root</code>
<code class="tooltip-example">user</code>
</div>
<p class="tooltip-text">Find it in your camera setup documentation or the camera's web interface under User Management. Change default credentials for security.</p>
</div>
</span>
</label>
<input <input
type="text" type="text"
id="username" id="username"
class="input" class="input"
value="admin"
placeholder="admin" placeholder="admin"
autocomplete="off" autocomplete="off"
> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="label">Password</label> <label for="password" class="label label-with-info">
Password
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Password</div>
<p class="tooltip-text">The authentication password for your camera's RTSP stream. This credential is used together with the username to access the video feed.</p>
<p class="tooltip-text">For security reasons, always use a strong, unique password and avoid default passwords like "12345" or "password".</p>
<p class="tooltip-text">Find it in your camera's documentation, setup guide, or change it via the camera's web interface under Security/User Management settings.</p>
</div>
</span>
</label>
<div class="input-password-wrapper"> <div class="input-password-wrapper">
<input <input
type="password" type="password"
@@ -134,23 +239,65 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="channel" class="label label-with-info">
Channel
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Channel Number</div>
<p class="tooltip-text">The channel number identifies which specific camera or video input to access on the device.</p>
<p class="tooltip-text"><strong>For standalone IP cameras:</strong> Always use 0 (default). Single cameras don't use channel numbers.</p>
<p class="tooltip-text"><strong>For NVR/DVR systems ONLY:</strong> Each connected camera has its own channel number. Channel numbering typically starts from 0.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">NVR/DVR channel values:</div>
<code class="tooltip-example">0 - First camera on NVR/DVR</code>
<code class="tooltip-example">1 - Second camera on NVR/DVR</code>
<code class="tooltip-example">2-15 - Additional cameras (for 4, 8, 16-channel NVRs)</code>
</div>
<p class="tooltip-text">Check your NVR's camera list in the device web interface to see the correct channel assignment for each camera.</p>
</div>
</span>
</label>
<input
type="number"
id="channel"
class="input"
value="0"
min="0"
max="255"
>
</div>
<details class="advanced-section"> <details class="advanced-section">
<summary class="advanced-toggle">Advanced</summary> <summary class="advanced-toggle">Advanced</summary>
<div class="advanced-content"> <div class="advanced-content">
<div class="form-group">
<label for="channel" class="label">Channel</label>
<input
type="number"
id="channel"
class="input"
value="0"
min="0"
max="255"
>
</div>
<div class="form-group"> <div class="form-group">
<label class="label">Resolution <span class="optional">(optional)</span></label> <label class="label label-with-info">
Resolution <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Resolution Filter</div>
<p class="tooltip-text">Optionally filter discovered streams by specific resolution. Leave empty to find all available resolutions. Use this to target specific stream qualities.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common resolutions:</div>
<code class="tooltip-example">1920 × 1080 - Full HD (main stream)</code>
<code class="tooltip-example">1280 × 720 - HD (sub stream)</code>
<code class="tooltip-example">640 × 480 - VGA (low quality)</code>
<code class="tooltip-example">3840 × 2160 - 4K Ultra HD</code>
</div>
<p class="tooltip-text">Tip: Leave empty for initial discovery, then use specific values to find particular stream types (main vs sub streams).</p>
</div>
</span>
</label>
<div class="input-row"> <div class="input-row">
<input <input
type="number" type="number"
@@ -169,7 +316,26 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="max-streams" class="label">Max Streams</label> <label for="max-streams" class="label label-with-info">
Max Streams
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Maximum Streams</div>
<p class="tooltip-text">The maximum number of stream URLs to test during discovery. Higher values increase scan time but may find more stream variants. Lower values speed up discovery.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Recommended values:</div>
<code class="tooltip-example">5 - Quick scan (faster)</code>
<code class="tooltip-example">10 - Balanced (default)</code>
<code class="tooltip-example">20-50 - Thorough scan (slower)</code>
</div>
<p class="tooltip-text">Purpose: Controls how many different RTSP URL patterns are tested. Most cameras have 2-5 valid streams (main, sub, mobile, etc.).</p>
</div>
</span>
</label>
<input <input
type="number" type="number"
id="max-streams" id="max-streams"
@@ -207,46 +373,11 @@
<p id="progress-text" class="progress-text">Starting scan...</p> <p id="progress-text" class="progress-text">Starting scan...</p>
</div> </div>
<div class="stats">
<div class="stat">
<span class="stat-value" id="stat-tested">0</span>
<span class="stat-label">Tested</span>
</div>
<div class="stat">
<span class="stat-value stat-primary" id="stat-found">0</span>
<span class="stat-label">Found</span>
</div>
<div class="stat">
<span class="stat-value" id="stat-remaining">0</span>
<span class="stat-label">Remaining</span>
</div>
</div>
<div id="streams-section" class="streams-section hidden"> <div id="streams-section" class="streams-section hidden">
<h3 class="section-title">Found Connections</h3> <h3 class="section-title">Found Connections</h3>
<div class="carousel-wrapper"> <div id="streams-list" class="streams-list"></div>
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="carousel">
<div id="carousel-track" class="carousel-track"></div>
</div>
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="carousel-info">
<p id="carousel-counter" class="carousel-counter">Stream 1 of 1</p>
<div id="carousel-dots" class="carousel-dots"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -265,13 +396,51 @@
<div class="stream-selection-container"> <div class="stream-selection-container">
<div class="selected-stream-info"> <div class="selected-stream-info">
<p class="stream-label">Main Stream</p> <div class="stream-label">
<span>Main Stream</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Main Stream</div>
<p class="tooltip-text">The primary high-resolution video stream from your camera. This stream is typically used for recording and high-quality viewing.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common uses:</div>
<code class="tooltip-example">Recording to disk</code>
<code class="tooltip-example">Live HD viewing</code>
<code class="tooltip-example">High-quality playback</code>
</div>
<p class="tooltip-text">Resolution is usually 1080p (1920×1080) or higher. Higher resolution means better quality but requires more bandwidth and storage.</p>
</div>
</span>
</div>
<p id="selected-main-type" class="selected-type"></p> <p id="selected-main-type" class="selected-type"></p>
<p id="selected-main-url" class="selected-url"></p> <p id="selected-main-url" class="selected-url"></p>
</div> </div>
<div id="sub-stream-info" class="selected-stream-info sub-stream hidden"> <div id="sub-stream-info" class="selected-stream-info sub-stream hidden">
<p class="stream-label">Sub Stream</p> <div class="stream-label">
<span>Sub Stream</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Sub Stream</div>
<p class="tooltip-text">A secondary lower-resolution video stream from your camera. This stream is optimized for object detection and reduces CPU usage.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Common uses:</div>
<code class="tooltip-example">Motion detection</code>
<code class="tooltip-example">Object detection (person, car)</code>
<code class="tooltip-example">Low-bandwidth monitoring</code>
</div>
<p class="tooltip-text">Resolution is usually 640×480 or 720p. Using a sub stream for detection significantly improves performance while maintaining recording quality on the main stream.</p>
</div>
</span>
</div>
<p id="selected-sub-type" class="selected-type"></p> <p id="selected-sub-type" class="selected-type"></p>
<p id="selected-sub-url" class="selected-url"></p> <p id="selected-sub-url" class="selected-url"></p>
<button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button> <button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button>
@@ -280,24 +449,41 @@
<div class="tabs"> <div class="tabs">
<div class="tabs-scroll"> <div class="tabs-scroll">
<button class="tab active" data-tab="url">URL</button> <button class="tab active" data-tab="frigate">Frigate</button>
<button class="tab" data-tab="go2rtc">Go2RTC</button> <button class="tab" data-tab="go2rtc">Go2RTC</button>
<button class="tab" data-tab="frigate">Frigate</button> <button class="tab" data-tab="url">URL</button>
</div> </div>
</div> </div>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane active" data-pane="url"> <div class="tab-pane active" data-pane="frigate">
<pre id="config-url" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="go2rtc">
<pre id="config-go2rtc" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="frigate">
<!-- Input section for existing config --> <!-- Input section for existing config -->
<div class="frigate-input-section"> <div class="frigate-input-section">
<label class="frigate-label"> <label class="frigate-label label-with-info">
Your Current Frigate Config Your Current Frigate Config <span class="optional">(optional)</span>
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Frigate Configuration</div>
<p class="tooltip-text">You can either create a new Frigate config or add this camera to your existing configuration.</p>
<p class="tooltip-text"><strong>Option 1: New Config (Recommended for beginners)</strong><br>Leave the example config below as-is, and the system will generate a complete working configuration for you.</p>
<p class="tooltip-text"><strong>Option 2: Add to Existing Config</strong><br>If you already have Frigate running, paste your current config.yml here. The system will intelligently add this camera without breaking your existing setup.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Where to find your config.yml:</div>
<code class="tooltip-example">Docker: /config/config.yml</code>
<code class="tooltip-example">Home Assistant addon: /config/frigate.yml</code>
<code class="tooltip-example">Standalone: /etc/frigate/config.yml</code>
</div>
<p class="tooltip-text">The generator will preserve all your existing cameras and settings, only adding the new camera configuration.</p>
</div>
</span>
<span class="hint">Paste your existing config.yml or leave the example below</span> <span class="hint">Paste your existing config.yml or leave the example below</span>
</label> </label>
<textarea <textarea
@@ -308,16 +494,72 @@
</div> </div>
<!-- Generate button --> <!-- Generate button -->
<button id="btn-generate-frigate" class="btn btn-primary btn-generate"> <div class="button-with-tooltip">
Generate Config <button id="btn-generate-frigate" class="btn btn-primary btn-generate">
</button> Generate Config
<span class="info-icon info-icon-button">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Generate Configuration</div>
<p class="tooltip-text">This button will process your camera streams and generate a ready-to-use Frigate configuration.</p>
<p class="tooltip-text"><strong>What happens:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Configuration includes:</div>
<code class="tooltip-example">Go2RTC streams setup</code>
<code class="tooltip-example">Camera with detect & record roles</code>
<code class="tooltip-example">Object tracking (person, car, etc.)</code>
<code class="tooltip-example">Recording settings</code>
</div>
<p class="tooltip-text">If you provided an existing config, your camera will be added to it. Otherwise, a complete new configuration will be created.</p>
<p class="tooltip-text">After generation, use Copy or Download buttons to save your config.</p>
</div>
</span>
</button>
</div>
<!-- Output section (hidden by default) --> <!-- Output section (hidden by default) -->
<div id="frigate-output-section" class="frigate-output-section hidden"> <div id="frigate-output-section" class="frigate-output-section hidden">
<label class="frigate-label">Updated Config (Camera Added)</label> <label class="frigate-label label-with-info">
Updated Config (Camera Added)
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">Generated Configuration</div>
<p class="tooltip-text">This is your complete Frigate configuration with the camera successfully added.</p>
<p class="tooltip-text"><strong>What's included:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Configuration sections:</div>
<code class="tooltip-example">go2rtc: Stream definitions</code>
<code class="tooltip-example">cameras: Camera with roles</code>
<code class="tooltip-example">objects: Person, car tracking</code>
<code class="tooltip-example">record: Recording settings</code>
</div>
<p class="tooltip-text"><strong>How to use:</strong><br>Copy or download this configuration and save it as <code>config.yml</code> in your Frigate directory. Restart Frigate to apply the changes.</p>
<p class="tooltip-text">If you added to existing config, your previous cameras and settings are preserved - only the new camera was added.</p>
</div>
</span>
</label>
<pre id="config-frigate" class="config-code"></pre> <pre id="config-frigate" class="config-code"></pre>
</div> </div>
</div> </div>
<div class="tab-pane" data-pane="go2rtc">
<pre id="config-go2rtc" class="config-code"></pre>
</div>
<div class="tab-pane" data-pane="url">
<pre id="config-url" class="config-code"></pre>
</div>
</div> </div>
<div class="actions"> <div class="actions">
@@ -337,23 +579,76 @@
</div> </div>
<div class="secondary-actions"> <div class="secondary-actions">
<button id="btn-add-sub-stream" class="btn btn-primary"> <div class="button-with-tooltip">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <button id="btn-add-sub-stream" class="btn btn-primary">
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
</svg> <path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
Add Sub Stream </svg>
</button> Add Sub Stream
<button id="btn-new-search" class="btn btn-outline"> <span class="info-icon info-icon-button">
Add Another Camera <svg viewBox="0 0 16 16" fill="none">
</button> <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Add Sub Stream</div>
<p class="tooltip-text">Add a secondary lower-resolution stream for efficient object detection and motion monitoring.</p>
<p class="tooltip-text"><strong>Why add a sub stream?</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Benefits:</div>
<code class="tooltip-example">Reduces CPU usage by 50-70%</code>
<code class="tooltip-example">Faster object detection</code>
<code class="tooltip-example">Lower bandwidth consumption</code>
<code class="tooltip-example">Main stream quality preserved</code>
</div>
<p class="tooltip-text"><strong>How it works:</strong><br>After clicking, you'll return to the stream list where you can select a lower-resolution stream (usually 640×480 or 720p). Frigate will use this for detection while recording the main stream in full quality.</p>
<p class="tooltip-text"><strong>Recommended:</strong> Most IP cameras support multiple streams. Using a sub stream is highly recommended for optimal Frigate performance.</p>
</div>
</span>
</button>
</div>
<div class="button-with-tooltip">
<button id="btn-new-search" class="btn btn-outline">
Add Another Camera
<span class="info-icon info-icon-button-outline">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">Add Another Camera</div>
<p class="tooltip-text">Start the configuration process for a new camera from the beginning.</p>
<p class="tooltip-text"><strong>⚠️ Important - Save First!</strong><br>Before clicking this button, make sure to save your current configuration using Copy or Download buttons above. This will reset the form.</p>
<p class="tooltip-text"><strong>What happens:</strong></p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">The process will:</div>
<code class="tooltip-example">1. Return to address input screen</code>
<code class="tooltip-example">2. Clear current camera settings</code>
<code class="tooltip-example">3. Start fresh discovery</code>
<code class="tooltip-example">4. Generate new config for next camera</code>
</div>
<p class="tooltip-text">You can then add the new camera to your saved Frigate config by pasting it in the config field.</p>
</div>
</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal -->
<div id="modal-overlay" class="modal-overlay hidden"></div>
<!-- Toast Notification --> <!-- Toast Notification -->
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
<script type="module" src="/js/main.js"></script> <script type="module" src="js/main.js"></script>
</body> </body>
</html> </html>
+11 -2
View File
@@ -1,15 +1,24 @@
import { MockCameraSearch } from '../mock/mock-data.js';
export class CameraSearchAPI { export class CameraSearchAPI {
constructor(baseURL = null) { constructor(baseURL = null, useMock = false) {
// Use relative URLs since API and UI are on the same port // Use relative URLs since API and UI are on the same port
if (!baseURL) { if (!baseURL) {
this.baseURL = ''; this.baseURL = '';
} else { } else {
this.baseURL = baseURL; this.baseURL = baseURL;
} }
this.useMock = useMock;
this.mockAPI = useMock ? new MockCameraSearch() : null;
} }
async search(query, limit = 10) { async search(query, limit = 10) {
const response = await fetch(`${this.baseURL}/api/v1/cameras/search`, { // Use mock API if enabled
if (this.useMock) {
return await this.mockAPI.search(query, limit);
}
const response = await fetch(`${this.baseURL}api/v1/cameras/search`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+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();
}
}
+14 -3
View File
@@ -1,5 +1,7 @@
import { MockStreamDiscovery } from '../mock/mock-data.js';
export class StreamDiscoveryAPI { export class StreamDiscoveryAPI {
constructor(baseURL = null) { constructor(baseURL = null, useMock = false) {
// Use relative URLs since API and UI are on the same port // Use relative URLs since API and UI are on the same port
if (!baseURL) { if (!baseURL) {
this.baseURL = ''; this.baseURL = '';
@@ -7,14 +9,20 @@ export class StreamDiscoveryAPI {
this.baseURL = baseURL; this.baseURL = baseURL;
} }
this.eventSource = null; this.eventSource = null;
this.useMock = useMock;
this.mockAPI = useMock ? new MockStreamDiscovery() : null;
} }
discover(request, callbacks) { discover(request, callbacks) {
this.close(); this.close();
const url = new URL(`${this.baseURL}/api/v1/streams/discover`, window.location.origin); // Use mock API if enabled
if (this.useMock) {
this.mockAPI.discover(request, callbacks);
return;
}
fetch(url, { fetch(`${this.baseURL}api/v1/streams/discover`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -93,6 +101,9 @@ export class StreamDiscoveryAPI {
} }
close() { close() {
if (this.useMock && this.mockAPI) {
this.mockAPI.close();
}
if (this.eventSource) { if (this.eventSource) {
this.eventSource.close(); this.eventSource.close();
this.eventSource = null; this.eventSource = null;
@@ -252,6 +252,15 @@ export class FrigateGenerator {
return lines; 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 * Generate camera lines for cameras section
*/ */
@@ -264,11 +273,14 @@ export class FrigateGenerator {
if (cameraInfo.subStream) { if (cameraInfo.subStream) {
// Use sub for detect, main for record // 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - detect'); 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - record'); lines.push(' - record');
@@ -280,7 +292,9 @@ export class FrigateGenerator {
lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`); lines.push(` Sub Stream: ${cameraInfo.subStreamName} # Низкое разрешение (опционально)`);
} else { } else {
// Use main for both detect and record // 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(' input_args: preset-rtsp-restream');
lines.push(' roles:'); lines.push(' roles:');
lines.push(' - detect'); lines.push(' - detect');
+186 -33
View File
@@ -1,18 +1,41 @@
import { CameraSearchAPI } from './api/camera-search.js'; import { CameraSearchAPI } from './api/camera-search.js';
import { StreamDiscoveryAPI } from './api/stream-discovery.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'; import { SearchForm } from './ui/search-form.js';
import { StreamCarousel } from './ui/stream-carousel.js'; import { StreamList } from './ui/stream-list.js';
import { ConfigPanel } from './ui/config-panel.js'; import { ConfigPanel } from './ui/config-panel.js';
import { FrigateGenerator } from './config-generators/frigate/index.js'; import { FrigateGenerator } from './config-generators/frigate/index.js';
import { showToast } from './utils/toast.js'; import { showToast } from './utils/toast.js';
import { showModal } from './ui/modal.js';
class StrixApp { class StrixApp {
constructor() { constructor() {
this.cameraAPI = new CameraSearchAPI(); // Check if mock mode is enabled via URL parameter
this.streamAPI = new StreamDiscoveryAPI(); const urlParams = new URLSearchParams(window.location.search);
const isMockMode = urlParams.get('mock') === 'true';
if (isMockMode) {
console.log('🎭 Mock mode enabled - using fake data');
this.cameraAPI = new MockCameraAPI();
this.streamAPI = new MockStreamAPI();
// Show mock mode badge
const mockBadge = document.getElementById('mock-mode-badge');
if (mockBadge) {
mockBadge.classList.remove('hidden');
}
} else {
this.cameraAPI = new CameraSearchAPI();
this.streamAPI = new StreamDiscoveryAPI();
}
this.probeAPI = new ProbeAPI();
this.probeResult = null;
this.searchForm = new SearchForm(); this.searchForm = new SearchForm();
this.carousel = new StreamCarousel(); this.streamList = new StreamList();
this.configPanel = new ConfigPanel(); this.configPanel = new ConfigPanel();
this.currentAddress = ''; this.currentAddress = '';
@@ -20,15 +43,41 @@ class StrixApp {
this.selectedMainStream = null; this.selectedMainStream = null;
this.selectedSubStream = null; this.selectedSubStream = null;
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
this.frigateConfigGenerated = false; // Track if Frigate config has been generated
this.init(); this.init();
} }
init() { init() {
this.setupEventListeners(); this.setupEventListeners();
this.prefillNetworkAddress();
this.showScreen('address'); this.showScreen('address');
} }
/**
* Pre-fill network address input with smart default based on server IP
*/
prefillNetworkAddress() {
const hostname = window.location.hostname;
const input = document.getElementById('network-address');
// Skip if localhost or empty
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') {
return;
}
// Check if hostname is an IP address (matches pattern like 192.168.1.1)
const ipPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipPattern);
if (match) {
// Extract first three octets (e.g., "192.168.1." from "192.168.1.254")
const networkPrefix = `${match[1]}.${match[2]}.${match[3]}.`;
input.value = networkPrefix;
input.placeholder = `${networkPrefix}100`;
}
}
setupEventListeners() { setupEventListeners() {
// Screen 1: Address input // Screen 1: Address input
document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress()); document.getElementById('btn-check-address').addEventListener('click', () => this.checkAddress());
@@ -38,6 +87,12 @@ class StrixApp {
// Screen 2: Configuration form // Screen 2: Configuration form
document.getElementById('btn-back-to-address').addEventListener('click', () => { 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'); this.showScreen('address');
}); });
@@ -77,24 +132,6 @@ class StrixApp {
this.showScreen('config'); this.showScreen('config');
}); });
// Carousel navigation
document.getElementById('carousel-prev').addEventListener('click', () => {
this.carousel.prev();
});
document.getElementById('carousel-next').addEventListener('click', () => {
this.carousel.next();
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
const currentScreen = document.querySelector('.screen.active').id;
if (currentScreen === 'screen-discovery') {
if (e.key === 'ArrowLeft') this.carousel.prev();
if (e.key === 'ArrowRight') this.carousel.next();
}
});
// Screen 4: Configuration output // Screen 4: Configuration output
document.getElementById('btn-back-to-streams').addEventListener('click', () => { document.getElementById('btn-back-to-streams').addEventListener('click', () => {
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
@@ -144,7 +181,88 @@ class StrixApp {
document.getElementById('address-validated').value = address; document.getElementById('address-validated').value = address;
} }
this.showScreen('config'); // 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) { isFullURL(str) {
@@ -155,10 +273,12 @@ class StrixApp {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// Extract credentials // Extract credentials (only override if provided in URL)
if (urlObj.username) { if (urlObj.username) {
document.getElementById('username').value = urlObj.username; document.getElementById('username').value = urlObj.username;
} }
// If no username in URL, keep the default "admin" value
if (urlObj.password) { if (urlObj.password) {
document.getElementById('password').value = urlObj.password; document.getElementById('password').value = urlObj.password;
} }
@@ -285,11 +405,13 @@ class StrixApp {
resetDiscoveryUI() { resetDiscoveryUI() {
document.getElementById('progress-fill').style.width = '0%'; document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-text').textContent = 'Starting scan...'; document.getElementById('progress-text').textContent = 'Starting scan...';
document.getElementById('stat-tested').textContent = '0';
document.getElementById('stat-found').textContent = '0';
document.getElementById('stat-remaining').textContent = '0';
document.getElementById('streams-section').classList.add('hidden'); document.getElementById('streams-section').classList.add('hidden');
this.currentStreams = []; 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) { handleProgress(data) {
@@ -298,9 +420,6 @@ class StrixApp {
document.getElementById('progress-fill').style.width = `${percentage}%`; document.getElementById('progress-fill').style.width = `${percentage}%`;
document.getElementById('progress-text').textContent = `Testing streams... ${Math.round(percentage)}%`; document.getElementById('progress-text').textContent = `Testing streams... ${Math.round(percentage)}%`;
document.getElementById('stat-tested').textContent = data.tested;
document.getElementById('stat-found').textContent = data.found;
document.getElementById('stat-remaining').textContent = data.remaining;
} }
handleStreamFound(data) { handleStreamFound(data) {
@@ -312,8 +431,8 @@ class StrixApp {
streamsSection.classList.remove('hidden'); streamsSection.classList.remove('hidden');
} }
// Update carousel // Update stream list (smart defaults applied automatically on first render)
this.carousel.render(this.currentStreams, (stream, index) => { this.streamList.render(this.currentStreams, (stream, index) => {
this.selectStream(stream, index); this.selectStream(stream, index);
}); });
} }
@@ -338,16 +457,22 @@ class StrixApp {
// Selecting main stream // Selecting main stream
this.selectedMainStream = stream; this.selectedMainStream = stream;
this.selectedSubStream = null; this.selectedSubStream = null;
this.frigateConfigGenerated = false; // Reset Frigate config state
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
this.showScreen('output'); this.showScreen('output');
// Hide action buttons initially since Frigate tab is active by default
document.querySelector('.actions').style.display = 'none';
} else { } else {
// Selecting sub stream // Selecting sub stream
this.selectedSubStream = stream; this.selectedSubStream = stream;
this.isSelectingSubStream = false; this.isSelectingSubStream = false;
this.frigateConfigGenerated = false; // Reset Frigate config state
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
this.showScreen('output'); this.showScreen('output');
// Hide action buttons initially since Frigate tab is active by default
document.querySelector('.actions').style.display = 'none';
} }
} }
@@ -363,14 +488,28 @@ class StrixApp {
document.getElementById('frigate-output-section').classList.add('hidden'); document.getElementById('frigate-output-section').classList.add('hidden');
document.getElementById('config-frigate').textContent = ''; 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'); showToast('Select a sub stream from available streams');
this.showScreen('discovery'); this.showScreen('discovery');
} }
removeSubStream() { removeSubStream() {
this.selectedSubStream = null; this.selectedSubStream = null;
this.frigateConfigGenerated = false; // Reset Frigate config state when sub stream is removed
this.configPanel.render(this.selectedMainStream, this.selectedSubStream); this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI(); this.updateSubStreamUI();
// Hide action buttons if on Frigate tab
const activeTab = document.querySelector('.tab.active').dataset.tab;
if (activeTab === 'frigate') {
document.querySelector('.actions').style.display = 'none';
}
showToast('Sub stream removed'); showToast('Sub stream removed');
} }
@@ -395,6 +534,10 @@ class StrixApp {
document.getElementById('config-frigate').textContent = newConfig; document.getElementById('config-frigate').textContent = newConfig;
document.getElementById('frigate-output-section').classList.remove('hidden'); document.getElementById('frigate-output-section').classList.remove('hidden');
// Mark as generated and show action buttons
this.frigateConfigGenerated = true;
document.querySelector('.actions').style.display = 'flex';
// Scroll to result // Scroll to result
document.getElementById('frigate-output-section').scrollIntoView({ document.getElementById('frigate-output-section').scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
@@ -429,6 +572,16 @@ class StrixApp {
// Update tab panes // Update tab panes
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active'); document.querySelector(`.tab-pane[data-pane="${tabName}"]`).classList.add('active');
// Show/hide action buttons based on tab and Frigate config state
const actionsContainer = document.querySelector('.actions');
if (tabName === 'frigate' && !this.frigateConfigGenerated) {
// Hide buttons on Frigate tab until config is generated
actionsContainer.style.display = 'none';
} else {
// Show buttons for other tabs or after Frigate config is generated
actionsContainer.style.display = 'flex';
}
} }
copyConfig() { copyConfig() {
@@ -482,7 +635,7 @@ class StrixApp {
document.getElementById('camera-model').value = ''; document.getElementById('camera-model').value = '';
document.getElementById('camera-model').disabled = false; document.getElementById('camera-model').disabled = false;
document.getElementById('camera-model').placeholder = 'Start typing...'; document.getElementById('camera-model').placeholder = 'Start typing...';
document.getElementById('username').value = ''; document.getElementById('username').value = 'admin'; // Reset to default value
document.getElementById('password').value = ''; document.getElementById('password').value = '';
document.getElementById('channel').value = '0'; document.getElementById('channel').value = '0';
document.getElementById('max-streams').value = '10'; document.getElementById('max-streams').value = '10';
+49
View File
@@ -0,0 +1,49 @@
// Mock implementation of CameraSearchAPI for development
export class MockCameraAPI {
constructor() {
this.mockCameras = [
{ brand: "Hikvision", model: "DS-2CD2042WD-I" },
{ brand: "Hikvision", model: "DS-2CD2142FWD-I" },
{ brand: "Hikvision", model: "DS-2CD2032-I" },
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
{ brand: "Dahua", model: "IPC-HFW4431R-Z" },
{ brand: "Dahua", model: "IPC-HDBW4433R-ZS" },
{ brand: "Dahua", model: "DH-IPC-HFW2431S-S-S2" },
{ brand: "Dahua", model: "IPC-HDW2531T-AS-S2" },
{ brand: "Axis", model: "M3046-V" },
{ brand: "Axis", model: "P3245-LVE" },
{ brand: "Axis", model: "M5525-E" },
{ brand: "Uniview", model: "IPC322SR3-DVS28-F" },
{ brand: "Uniview", model: "IPC2124SR3-DPF40" },
{ brand: "Reolink", model: "RLC-410" },
{ brand: "Reolink", model: "RLC-520A" },
{ brand: "Reolink", model: "RLC-810A" },
{ brand: "TP-Link", model: "VIGI C300HP-4" },
{ brand: "TP-Link", model: "VIGI C540V" },
{ brand: "Amcrest", model: "IP8M-2496EW" },
{ brand: "Amcrest", model: "IP4M-1041B" },
{ brand: "Foscam", model: "FI9900P" },
{ brand: "Foscam", model: "R5" },
];
}
async search(query, limit = 10) {
// Simulate network delay
await this.delay(150);
const lowerQuery = query.toLowerCase();
const filtered = this.mockCameras.filter(camera => {
const searchText = `${camera.brand} ${camera.model}`.toLowerCase();
return searchText.includes(lowerQuery);
});
return {
cameras: filtered.slice(0, limit),
total: filtered.length
};
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
+209
View File
@@ -0,0 +1,209 @@
// Mock data for development and testing
export const MOCK_CAMERAS = [
{ brand: "Hikvision", model: "DS-2CD2143G0-I" },
{ brand: "Hikvision", model: "DS-2CD2385G1-I" },
{ brand: "Hikvision", model: "DS-2CD2T85G1-I8" },
{ brand: "Dahua", model: "IPC-HFW5831E-Z5E" },
{ brand: "Dahua", model: "IPC-HDW5831R-ZE" },
{ brand: "Axis", model: "M3046-V" },
{ brand: "Axis", model: "P3245-LVE" },
{ brand: "Uniview", model: "IPC2324LB-ADZK-G" },
{ brand: "Reolink", model: "RLC-810A" },
{ brand: "TP-Link", model: "VIGI C540V" }
];
export const MOCK_STREAMS = [
{
url: "rtsp://admin:password@192.168.1.100:554/stream1",
type: "FFMPEG",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100:554/stream2",
type: "FFMPEG",
resolution: "640x360",
codec: "h264",
fps: 15,
bitrate: 512,
audio: true
},
{
url: "http://admin:password@192.168.1.100:80/onvif/device_service",
type: "ONVIF",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100/live/main",
type: "FFMPEG",
resolution: "2560x1440",
codec: "h265",
fps: 30,
bitrate: 6144,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100/live/sub",
type: "FFMPEG",
resolution: "704x576",
codec: "h264",
fps: 15,
bitrate: 768,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100:554/ch01/0",
type: "FFMPEG",
resolution: "3840x2160",
codec: "h265",
fps: 25,
bitrate: 8192,
audio: true
},
{
url: "rtsp://admin:password@192.168.1.100:554/ch01/1",
type: "FFMPEG",
resolution: "1280x720",
codec: "h264",
fps: 20,
bitrate: 2048,
audio: false
},
{
url: "http://admin:password@192.168.1.100:8080/video.mjpeg",
type: "MJPEG",
resolution: "1920x1080",
codec: "mjpeg",
fps: 10,
bitrate: 3072,
audio: false
},
{
url: "rtsp://admin:password@192.168.1.100/h264_stream",
type: "FFMPEG",
resolution: "1920x1080",
codec: "h264",
fps: 30,
bitrate: 4096,
audio: true
},
{
url: "http://admin:password@192.168.1.100:8081/stream.m3u8",
type: "HLS",
resolution: "1920x1080",
codec: "h264",
fps: 25,
bitrate: 4096,
audio: true
}
];
// Mock Camera Search API
export class MockCameraSearch {
async search(query, limit = 10) {
// Simulate network delay
await this.delay(100);
const results = MOCK_CAMERAS.filter(camera => {
const searchStr = `${camera.brand} ${camera.model}`.toLowerCase();
return searchStr.includes(query.toLowerCase());
});
return {
cameras: results.slice(0, limit),
total: results.length
};
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Mock Stream Discovery API
export class MockStreamDiscovery {
constructor() {
this.isRunning = false;
this.timeoutId = null;
}
discover(request, callbacks) {
this.isRunning = true;
let tested = 0;
const totalToTest = 516;
const foundStreams = [...MOCK_STREAMS];
// Initial progress
callbacks.onProgress({
tested: 0,
found: 0,
remaining: totalToTest
});
// Simulate progressive testing
const progressInterval = setInterval(() => {
if (!this.isRunning) {
clearInterval(progressInterval);
return;
}
tested += Math.floor(Math.random() * 30) + 20;
if (tested > totalToTest) tested = totalToTest;
callbacks.onProgress({
tested: tested,
found: foundStreams.length,
remaining: totalToTest - tested
});
if (tested >= totalToTest) {
clearInterval(progressInterval);
}
}, 200);
// Send found streams progressively
let streamIndex = 0;
const streamInterval = setInterval(() => {
if (!this.isRunning) {
clearInterval(streamInterval);
return;
}
if (streamIndex < foundStreams.length) {
callbacks.onStreamFound({
stream: foundStreams[streamIndex]
});
streamIndex++;
} else {
clearInterval(streamInterval);
}
}, 800);
// Complete after ~7.7 seconds
this.timeoutId = setTimeout(() => {
if (!this.isRunning) return;
callbacks.onComplete({
total_found: foundStreams.length,
duration: 7.7
});
this.isRunning = false;
}, 7700);
}
close() {
this.isRunning = false;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}
+311
View File
@@ -0,0 +1,311 @@
// Mock implementation of StreamDiscoveryAPI for development
export class MockStreamAPI {
constructor() {
this.mockStreams = [
// RTSP Main streams (1920x1080)
{
url: "rtsp://192.168.1.100:554/Streaming/Channels/101",
path: "/Streaming/Channels/101",
type: "FFMPEG",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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",
type: "JPEG",
resolution: "1920x1080",
codec: "JPEG",
fps: 1,
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: "1920x1080",
codec: "MJPEG",
fps: 10,
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",
type: "HLS",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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: "1920x1080",
codec: "H.264",
fps: 20,
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",
type: "BUBBLE",
resolution: "1920x1080",
codec: "H.264",
fps: 25,
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/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: 25,
bitrate: 4608000,
has_audio: true
}
];
}
discover(request, callbacks) {
const totalToScan = 450;
const streamsToFind = this.mockStreams;
let tested = 0;
let found = 0;
const startTime = Date.now();
// 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;
// Send progress event
if (callbacks.onProgress) {
callbacks.onProgress({
tested: tested,
found: found,
remaining: remaining
});
}
// Complete when done
if (tested >= totalToScan) {
clearInterval(progressInterval);
const duration = (Date.now() - startTime) / 1000;
if (callbacks.onComplete) {
callbacks.onComplete({
total_tested: totalToScan,
total_found: found,
duration: duration
});
}
}
}, 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() {
// Nothing to close in mock mode
}
}
+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;
}
+562
View File
@@ -0,0 +1,562 @@
export class StreamList {
constructor() {
this.listContainer = document.getElementById('streams-list');
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;
// 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;
const truncatedUrl = this.truncateURL(stream.url, 60);
return `
<div class="stream-item ${isExpanded ? 'expanded' : ''}" data-index="${index}">
<div class="stream-item-header" data-index="${index}">
<div class="stream-item-main">
<div class="stream-info-left">
<div class="stream-type-badge">
${icon}
<span>${stream.type}</span>
${this.getStreamTypeTooltip(stream.type)}
</div>
<div class="stream-url-preview">${truncatedUrl}</div>
</div>
<button class="stream-toggle" data-index="${index}" aria-label="Toggle details">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="chevron">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<button class="btn btn-primary btn-use-stream" data-index="${index}">Use Stream</button>
</div>
<div class="stream-item-details ${isExpanded ? 'visible' : ''}">
<div class="stream-url-full">${stream.url}</div>
${stream.resolution ? `<div class="stream-meta-item"><span class="meta-label">Resolution:</span> ${stream.resolution}</div>` : ''}
${stream.codec ? `<div class="stream-meta-item"><span class="meta-label">Codec:</span> ${stream.codec}${stream.fps ? `${stream.fps} fps` : ''}${stream.bitrate ? `${Math.round(stream.bitrate / 1000)} Kbps` : ''}</div>` : ''}
${stream.has_audio ? '<div class="stream-meta-item"><span class="meta-label">Audio:</span> Yes</div>' : ''}
</div>
</div>
`;
}
truncateURL(url, maxLength = 60) {
if (url.length <= maxLength) {
return url;
}
return url.substring(0, maxLength) + '...';
}
getStreamIcon(type) {
const icons = {
'FFMPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M14 14l-3-2-3 2V8l3 2 3-2v6z" fill="currentColor"/></svg>',
'ONVIF': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="2" fill="currentColor"/><circle cx="10" cy="10" r="5" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 3"/></svg>',
'JPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="8" r="1" fill="currentColor"/><path d="M3 13l4-4 3 3 5-5" stroke="currentColor" stroke-width="1.5"/></svg>',
'MJPEG': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="2" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="11" y="4" width="7" height="12" rx="1" stroke="currentColor" stroke-width="1.5"/><path d="M5 8l2 2-2 2M14 8l2 2-2 2" stroke="currentColor" stroke-width="1.5"/></svg>',
'HLS': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v8M6 10h8" stroke="currentColor" stroke-width="1.5"/></svg>',
'HTTP_VIDEO': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M7 6l6 4-6 4V6z" fill="currentColor"/><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/></svg>',
'BUBBLE': '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="9" r="1.5" fill="currentColor"/><circle cx="10" cy="9" r="1.5" fill="currentColor"/><circle cx="13" cy="9" r="1.5" fill="currentColor"/><path d="M6 13q2 2 4 2t4-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
};
return icons[type] || icons['FFMPEG'];
}
getStreamTypeTooltip(type) {
const tooltips = {
'FFMPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">FFMPEG Stream</div>
<p class="tooltip-text">Standard video stream decoded by FFmpeg. Most compatible and widely supported format for RTSP, HTTP, and other protocols.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Universal compatibility</code>
<code class="tooltip-example"> Supports H.264, H.265, MJPEG</code>
<code class="tooltip-example"> Works with most cameras</code>
<code class="tooltip-example"> Best for recording</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Main streams, recording, high-quality playback. Default choice for most use cases.</p>
</div>
</span>
`,
'ONVIF': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">ONVIF Stream</div>
<p class="tooltip-text">Industry standard protocol for IP cameras. Discovered via ONVIF specification, ensuring maximum compatibility with camera features.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Standardized protocol</code>
<code class="tooltip-example"> Auto-discovery support</code>
<code class="tooltip-example"> PTZ control capable</code>
<code class="tooltip-example"> Vendor-independent</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Enterprise cameras, systems requiring standardization, cameras with PTZ controls.</p>
</div>
</span>
`,
'JPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">JPEG Snapshot</div>
<p class="tooltip-text">Single static image endpoint. Can be converted to video stream by repeatedly fetching images.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Low bandwidth</code>
<code class="tooltip-example"> Simple HTTP request</code>
<code class="tooltip-example"> No streaming protocol needed</code>
<code class="tooltip-example"> Limited framerate (1-10 fps)</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Thumbnails, snapshots, very low bandwidth scenarios. Not recommended for recording.</p>
</div>
</span>
`,
'MJPEG': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">MJPEG Stream</div>
<p class="tooltip-text">Motion JPEG - sequence of JPEG images transmitted continuously. Simple but bandwidth-intensive.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Simple HTTP streaming</code>
<code class="tooltip-example"> No complex codecs</code>
<code class="tooltip-example"> Frame-by-frame</code>
<code class="tooltip-example"> High bandwidth usage</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Sub streams, low-latency monitoring, simple camera integration. Higher bandwidth than H.264.</p>
</div>
</span>
`,
'HLS': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">HLS Stream</div>
<p class="tooltip-text">HTTP Live Streaming - Apple's adaptive bitrate streaming protocol. Delivers video in small chunks over HTTP.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Adaptive bitrate</code>
<code class="tooltip-example"> Wide browser support</code>
<code class="tooltip-example"> Firewall-friendly (HTTP)</code>
<code class="tooltip-example"> Higher latency (5-30s)</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Web playback, public streaming, CDN delivery. Not ideal for real-time monitoring.</p>
</div>
</span>
`,
'HTTP_VIDEO': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">HTTP Video Stream</div>
<p class="tooltip-text">Generic HTTP-based video stream. Simple protocol that works over standard web connections.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Simple HTTP protocol</code>
<code class="tooltip-example"> No special ports</code>
<code class="tooltip-example"> Firewall-friendly</code>
<code class="tooltip-example"> Direct browser playback</code>
</div>
<p class="tooltip-text"><strong>Best for:</strong> Quick viewing, simple setups, scenarios where RTSP ports are blocked.</p>
</div>
</span>
`,
'BUBBLE': `
<span class="info-icon info-icon-stream">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip tooltip-down">
<div class="tooltip-title">BUBBLE / DVRIP Protocol</div>
<p class="tooltip-text">Proprietary protocol for Chinese DVR/NVR cameras. Also known as: ESeeCloud, dvr163, DVR-IP, NetSurveillance, Sofia protocol, XMeye SDK.</p>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Compatible brands:</div>
<code class="tooltip-example">XMEye, Floureon, ZOSI</code>
<code class="tooltip-example">Sannce, Annke, DVR163</code>
<code class="tooltip-example">ESeeCloud, NetSurveillance</code>
</div>
<div class="tooltip-examples">
<div class="tooltip-examples-title">Features:</div>
<code class="tooltip-example"> Proprietary protocol</code>
<code class="tooltip-example"> Go2RTC converts to standard</code>
<code class="tooltip-example"> Two-way audio support</code>
<code class="tooltip-example"> TCP only (port 34567)</code>
</div>
<p class="tooltip-text"><strong>Note:</strong> Automatically converted to standard RTSP format by Go2RTC. Works seamlessly with Frigate without additional configuration.</p>
</div>
</span>
`
};
return tooltips[type] || '';
}
attachEventListeners() {
// 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
if (e.target.closest('.btn-use-stream')) {
return;
}
const index = parseInt(header.dataset.index);
this.toggleExpand(index);
});
});
// Use Stream buttons
this.listContainer.querySelectorAll('.btn-use-stream').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent toggle
const index = parseInt(e.target.dataset.index);
if (this.onUseCallback) {
this.onUseCallback(this.streams[index], index);
}
});
});
}
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
this.expandedIndex = null;
} else {
// Expand new item
this.expandedIndex = index;
}
// Re-render to update state
this.render(this.streams, this.onUseCallback);
}
}