Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bd2de78dc | |||
| 89a7c87462 | |||
| e40dccbb90 | |||
| fe93aa329c | |||
| ddf2b4a373 | |||
| 833da5cf48 | |||
| 3fec89be7f | |||
| 4d6c2fd878 | |||
| eb8cc546c8 | |||
| 1fc345c569 | |||
| 0c0d743594 | |||
| 787919d20b | |||
| e9dc04178e | |||
| 915c1dec1b | |||
| e6828d8a22 | |||
| eedce14731 | |||
| 9975aa71de | |||
| 38e4af230f | |||
| 031e494787 | |||
| de389588ce | |||
| 4c03ad8d3c | |||
| d569a76700 | |||
| a405d6198f | |||
| 4143c267cd | |||
| 19e58db70f | |||
| 11e6ba9902 | |||
| a6e9cc2c5e | |||
| 12770ed5b9 | |||
| 90c4416709 | |||
| d602c8dfca | |||
| 596cf1ccdc | |||
| 779ae33bac | |||
| 71d6f2aac8 | |||
| 56c06dfa98 | |||
| 8bf92e6598 | |||
| 522d274dd4 | |||
| 8036d3e9be | |||
| 5b2f80f057 | |||
| e2b9802fd8 | |||
| 65a198d119 | |||
| 722c629c01 | |||
| c81d9a1e63 | |||
| 06de1c198b | |||
| ded9b507d6 | |||
| 90063c3f3a | |||
| 1bab993a13 | |||
| 1163af6fac | |||
| 95a0fc2096 |
@@ -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>
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
|
|
||||||
@@ -5,6 +5,75 @@ 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.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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 серверы
|
|
||||||
@@ -1,234 +1,550 @@
|
|||||||
# 🦉 Strix - Smart IP Camera Stream Discovery System
|
# Strix
|
||||||
|
[](https://github.com/eduard256/strix/stargazers)
|
||||||

|
|
||||||
|
|
||||||
[](https://go.dev/)
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/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
|

|
||||||
|
|
||||||
### 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.*
|
||||||
|
|||||||
+6
-6
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+200
-83
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+23
-1
@@ -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,
|
||||||
}
|
}
|
||||||
@@ -120,13 +139,16 @@ 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
|
||||||
s.router.Get("/health", handlers.NewHealthHandler("1.0.0", s.logger).ServeHTTP)
|
s.router.Get("/health", handlers.NewHealthHandler(s.config.Version, s.logger).ServeHTTP)
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
if currentTested != lastTested {
|
|
||||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||||
Tested: int(currentTested),
|
Tested: int(atomic.LoadInt32(&tested)),
|
||||||
Found: int(atomic.LoadInt32(&found)),
|
Found: int(atomic.LoadInt32(&found)),
|
||||||
Remaining: len(streams) - int(currentTested),
|
Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
|
||||||
})
|
})
|
||||||
lastTested = currentTested
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -176,8 +176,12 @@ func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
|
|||||||
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),
|
||||||
|
"{CHANNEL}": strconv.Itoa(ctx.Channel), // BUBBLE protocol uses {channel}
|
||||||
|
"{channel}": strconv.Itoa(ctx.Channel),
|
||||||
|
"{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
|
||||||
|
"{channel+1}": strconv.Itoa(ctx.Channel + 1),
|
||||||
"[WIDTH]": strconv.Itoa(ctx.Width),
|
"[WIDTH]": strconv.Itoa(ctx.Width),
|
||||||
"[width]": strconv.Itoa(ctx.Width),
|
"[width]": strconv.Itoa(ctx.Width),
|
||||||
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ProbeResponse represents the result of probing an IP address.
|
||||||
|
// The Type field determines which UI flow the frontend should use:
|
||||||
|
// - "unreachable" -- device did not respond to ping
|
||||||
|
// - "standard" -- normal IP camera (RTSP/HTTP/ONVIF)
|
||||||
|
// - "homekit" -- Apple HomeKit camera (needs PIN pairing)
|
||||||
|
type ProbeResponse struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
LatencyMs float64 `json:"latency_ms,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Probes ProbeResults `json:"probes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeResults contains results from all parallel probers.
|
||||||
|
// Nil fields mean the prober did not find anything or timed out.
|
||||||
|
type ProbeResults struct {
|
||||||
|
DNS *DNSProbeResult `json:"dns"`
|
||||||
|
ARP *ARPProbeResult `json:"arp"`
|
||||||
|
MDNS *MDNSProbeResult `json:"mdns"`
|
||||||
|
HTTP *HTTPProbeResult `json:"http"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPProbeResult contains HTTP server identification from port 80.
|
||||||
|
type HTTPProbeResult struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSProbeResult contains reverse DNS lookup result.
|
||||||
|
type DNSProbeResult struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARPProbeResult contains ARP table lookup + OUI vendor identification.
|
||||||
|
type ARPProbeResult struct {
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MDNSProbeResult contains mDNS service discovery result (HomeKit).
|
||||||
|
type MDNSProbeResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Category string `json:"category"` // "camera", "doorbell"
|
||||||
|
Paired bool `json:"paired"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Feature string `json:"feature"`
|
||||||
|
}
|
||||||
+63
-1
@@ -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
|
||||||
@@ -255,6 +266,7 @@ func generateClientID() string {
|
|||||||
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{
|
||||||
@@ -289,6 +304,7 @@ 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})
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
name: Strix Home Assistant Add-ons
|
|
||||||
url: https://github.com/eduard256/Strix
|
|
||||||
maintainer: eduard256
|
|
||||||
+1
-1
@@ -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
@@ -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;
|
||||||
|
|||||||
Executable
+14
@@ -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
|
||||||
+358
-63
@@ -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,11 +239,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="advanced-section">
|
|
||||||
<summary class="advanced-toggle">Advanced</summary>
|
|
||||||
<div class="advanced-content">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="channel" class="label">Channel</label>
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="channel"
|
id="channel"
|
||||||
@@ -149,8 +272,32 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="advanced-section">
|
||||||
|
<summary class="advanced-toggle">Advanced</summary>
|
||||||
|
<div class="advanced-content">
|
||||||
|
|
||||||
<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 -->
|
||||||
|
<div class="button-with-tooltip">
|
||||||
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
<button id="btn-generate-frigate" class="btn btn-primary btn-generate">
|
||||||
Generate Config
|
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>
|
</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">
|
||||||
|
<div class="button-with-tooltip">
|
||||||
<button id="btn-add-sub-stream" class="btn btn-primary">
|
<button id="btn-add-sub-stream" class="btn btn-primary">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Sub Stream
|
Add Sub Stream
|
||||||
|
<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">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>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-with-tooltip">
|
||||||
<button id="btn-new-search" class="btn btn-outline">
|
<button id="btn-new-search" class="btn btn-outline">
|
||||||
Add Another Camera
|
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>
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
+183
-30
@@ -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() {
|
||||||
|
// Check if mock mode is enabled via URL parameter
|
||||||
|
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.cameraAPI = new CameraSearchAPI();
|
||||||
this.streamAPI = new StreamDiscoveryAPI();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
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';
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user