Merge develop into main for v1.0.10 release
This commit is contained in:
@@ -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,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,27 @@ 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
|
## [1.0.9] - 2025-12-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
-281
@@ -1,281 +0,0 @@
|
|||||||
# Release Process Documentation
|
|
||||||
|
|
||||||
This document describes the complete release process for Strix and its Home Assistant add-on.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Strix consists of two repositories:
|
|
||||||
- **Strix** - Main application (Go binary, Docker image)
|
|
||||||
- **hassio-strix** - Home Assistant add-on
|
|
||||||
|
|
||||||
Both repositories must be released together to maintain version consistency.
|
|
||||||
|
|
||||||
## Version Files
|
|
||||||
|
|
||||||
### Strix Repository
|
|
||||||
|
|
||||||
| File | Line | Purpose |
|
|
||||||
|------|------|---------|
|
|
||||||
| `cmd/strix/main.go` | 23 | Application version constant |
|
|
||||||
| `webui/package.json` | 3 | Frontend version |
|
|
||||||
| `CHANGELOG.md` | Top | Release notes |
|
|
||||||
|
|
||||||
### hassio-strix Repository
|
|
||||||
|
|
||||||
| File | Line | Purpose |
|
|
||||||
|------|------|---------|
|
|
||||||
| `strix/config.json` | 3 | Home Assistant add-on version |
|
|
||||||
| `strix/CHANGELOG.md` | Top | Release notes |
|
|
||||||
|
|
||||||
## Release Workflow
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
1. Ensure you're on the `develop` branch with all changes committed
|
|
||||||
2. All tests pass: `go test ./...`
|
|
||||||
3. Application builds successfully: `go build ./...`
|
|
||||||
|
|
||||||
### Step 1: Update Strix Repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/user/Strix
|
|
||||||
git checkout develop
|
|
||||||
|
|
||||||
# 1. Update version in code
|
|
||||||
# Edit cmd/strix/main.go - line 23
|
|
||||||
# Version = "1.0.X"
|
|
||||||
|
|
||||||
# 2. Update frontend version
|
|
||||||
# Edit webui/package.json - line 3
|
|
||||||
# "version": "1.0.X"
|
|
||||||
|
|
||||||
# 3. Update CHANGELOG.md
|
|
||||||
# Add new section at the top:
|
|
||||||
# ## [1.0.X] - YYYY-MM-DD
|
|
||||||
# ### Fixed/Added/Changed
|
|
||||||
# - Description of changes
|
|
||||||
|
|
||||||
# 4. Commit version bump
|
|
||||||
git add cmd/strix/main.go webui/package.json CHANGELOG.md
|
|
||||||
git commit -m "Release v1.0.X: Brief description"
|
|
||||||
|
|
||||||
# 5. Merge to main
|
|
||||||
git checkout main
|
|
||||||
git merge develop --no-ff -m "Merge develop into main for v1.0.X release"
|
|
||||||
|
|
||||||
# 6. Create tag
|
|
||||||
git tag -a v1.0.X -m "Release v1.0.X: Brief description"
|
|
||||||
|
|
||||||
# 7. Push everything
|
|
||||||
git push origin main --tags
|
|
||||||
git push origin develop
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Update hassio-strix Repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/user/hassio-strix
|
|
||||||
|
|
||||||
# 1. Update add-on version
|
|
||||||
# Edit strix/config.json - line 3
|
|
||||||
# "version": "1.0.X"
|
|
||||||
|
|
||||||
# 2. Update CHANGELOG
|
|
||||||
# Edit strix/CHANGELOG.md - add same section as in Strix
|
|
||||||
|
|
||||||
# 3. Commit and push
|
|
||||||
git add strix/config.json strix/CHANGELOG.md
|
|
||||||
git commit -m "Release v1.0.X: Brief description"
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# 4. Create tag
|
|
||||||
git tag -a v1.0.X -m "Release v1.0.X: Brief description"
|
|
||||||
git push origin --tags
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify GitHub Actions
|
|
||||||
|
|
||||||
After pushing tags, GitHub Actions will automatically:
|
|
||||||
|
|
||||||
**Strix (eduard256/Strix):**
|
|
||||||
- ✅ `release.yml` - GoReleaser creates GitHub Release with binaries
|
|
||||||
- ✅ `docker.yml` - Builds and pushes Docker images to Docker Hub
|
|
||||||
- Tags: `1.0.X`, `1.0`, `1`, `latest`
|
|
||||||
- Platforms: `linux/amd64`, `linux/arm64`
|
|
||||||
|
|
||||||
**hassio-strix (eduard256/hassio-strix):**
|
|
||||||
- ✅ `builder.yml` - Builds and pushes HA add-on to GHCR
|
|
||||||
- Image: `ghcr.io/eduard256/hassio-strix-{arch}`
|
|
||||||
- Architectures: `amd64`, `aarch64`
|
|
||||||
|
|
||||||
Check status:
|
|
||||||
```bash
|
|
||||||
# Check Strix actions
|
|
||||||
gh run list --repo eduard256/Strix --limit 5
|
|
||||||
|
|
||||||
# Check hassio-strix actions
|
|
||||||
gh run list --repo eduard256/hassio-strix --limit 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Release
|
|
||||||
|
|
||||||
1. **GitHub Release**: https://github.com/eduard256/Strix/releases/tag/v1.0.X
|
|
||||||
- Should have binaries for all platforms
|
|
||||||
|
|
||||||
2. **Docker Hub**: https://hub.docker.com/r/eduard256/strix/tags
|
|
||||||
- Should show new version tag
|
|
||||||
|
|
||||||
3. **GHCR**: https://github.com/eduard256/hassio-strix/pkgs/container/hassio-strix-amd64
|
|
||||||
- Should show new version
|
|
||||||
|
|
||||||
4. **Home Assistant Add-on Store**:
|
|
||||||
- Users will see update notification automatically
|
|
||||||
|
|
||||||
## Quick Release Script
|
|
||||||
|
|
||||||
For automated release, use this script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# release.sh - Automated release script
|
|
||||||
|
|
||||||
VERSION=$1
|
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
echo "Usage: ./release.sh 1.0.X"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🚀 Releasing v$VERSION"
|
|
||||||
|
|
||||||
# Strix repo
|
|
||||||
cd /home/user/Strix
|
|
||||||
git checkout develop
|
|
||||||
|
|
||||||
# Update versions
|
|
||||||
sed -i "s/Version = \".*\"/Version = \"$VERSION\"/" cmd/strix/main.go
|
|
||||||
sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" webui/package.json
|
|
||||||
|
|
||||||
# Update CHANGELOG (manual step reminder)
|
|
||||||
echo "⚠️ Please update CHANGELOG.md manually"
|
|
||||||
read -p "Press enter when done..."
|
|
||||||
|
|
||||||
# Commit and merge
|
|
||||||
git add cmd/strix/main.go webui/package.json 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 -a "v$VERSION" -m "Release v$VERSION"
|
|
||||||
git push origin main --tags
|
|
||||||
git push origin develop
|
|
||||||
|
|
||||||
# hassio-strix repo
|
|
||||||
cd /home/user/hassio-strix
|
|
||||||
|
|
||||||
# Update version
|
|
||||||
sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" strix/config.json
|
|
||||||
|
|
||||||
# Update CHANGELOG (manual step reminder)
|
|
||||||
echo "⚠️ Please update strix/CHANGELOG.md manually"
|
|
||||||
read -p "Press enter when done..."
|
|
||||||
|
|
||||||
# Commit and tag
|
|
||||||
git add strix/config.json strix/CHANGELOG.md
|
|
||||||
git commit -m "Release v$VERSION"
|
|
||||||
git push origin main
|
|
||||||
git tag -a "v$VERSION" -m "Release v$VERSION"
|
|
||||||
git push origin --tags
|
|
||||||
|
|
||||||
echo "✅ Release v$VERSION complete!"
|
|
||||||
echo "Check GitHub Actions:"
|
|
||||||
echo " - https://github.com/eduard256/Strix/actions"
|
|
||||||
echo " - https://github.com/eduard256/hassio-strix/actions"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Docker Build Fails
|
|
||||||
|
|
||||||
**Symptom**: "Docker Hub Description" step shows `Forbidden` error
|
|
||||||
|
|
||||||
**Cause**: Docker Hub Personal Access Token expired or lacks permissions
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Go to Docker Hub → Account Settings → Security → Personal Access Tokens
|
|
||||||
2. Create new token with `Read, Write, Delete` scopes
|
|
||||||
3. Update GitHub secret: https://github.com/eduard256/Strix/settings/secrets/actions
|
|
||||||
4. Update `DOCKER_TOKEN` secret
|
|
||||||
|
|
||||||
**Note**: This only affects README updates on Docker Hub. Docker images still build successfully.
|
|
||||||
|
|
||||||
### Home Assistant Add-on Build Fails
|
|
||||||
|
|
||||||
**Symptom**: Builder workflow fails
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Check `strix/Dockerfile` for correct Go version
|
|
||||||
2. Verify `strix/build.yaml` has correct base images
|
|
||||||
3. Check logs: `gh run view <run-id> --repo eduard256/hassio-strix --log`
|
|
||||||
|
|
||||||
### Version Mismatch
|
|
||||||
|
|
||||||
**Symptom**: Strix shows different version than add-on
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Verify both `cmd/strix/main.go` and `strix/config.json` have same version
|
|
||||||
2. Ensure both repositories have matching tags: `git tag -l`
|
|
||||||
3. If needed, delete wrong tag: `git tag -d v1.0.X && git push origin :refs/tags/v1.0.X`
|
|
||||||
|
|
||||||
## Release Checklist
|
|
||||||
|
|
||||||
Before releasing:
|
|
||||||
- [ ] All changes merged to `develop`
|
|
||||||
- [ ] Tests pass: `go test ./...`
|
|
||||||
- [ ] Build succeeds: `go build ./...`
|
|
||||||
- [ ] CHANGELOG.md updated in both repos
|
|
||||||
- [ ] Version numbers match in all files
|
|
||||||
- [ ] Commit messages don't mention AI/Claude Code
|
|
||||||
|
|
||||||
After releasing:
|
|
||||||
- [ ] GitHub Release created with binaries
|
|
||||||
- [ ] Docker images pushed to Docker Hub
|
|
||||||
- [ ] HA add-on images pushed to GHCR
|
|
||||||
- [ ] Home Assistant users can see update
|
|
||||||
|
|
||||||
## Version Numbering
|
|
||||||
|
|
||||||
Strix follows [Semantic Versioning](https://semver.org/):
|
|
||||||
|
|
||||||
- **MAJOR.MINOR.PATCH** (e.g., 1.0.9)
|
|
||||||
- **MAJOR**: Breaking changes
|
|
||||||
- **MINOR**: New features (backward compatible)
|
|
||||||
- **PATCH**: Bug fixes
|
|
||||||
|
|
||||||
## GitHub Actions Overview
|
|
||||||
|
|
||||||
### Strix Repository
|
|
||||||
|
|
||||||
**Triggers:**
|
|
||||||
- `release.yml`: On push tag `v*`
|
|
||||||
- `docker.yml`: On push tag `v*` or push to `main`
|
|
||||||
- `ci.yml`: On push to any branch
|
|
||||||
|
|
||||||
**Secrets Required:**
|
|
||||||
- `GITHUB_TOKEN` (automatic)
|
|
||||||
- `DOCKER_USERNAME`
|
|
||||||
- `DOCKER_TOKEN`
|
|
||||||
|
|
||||||
### hassio-strix Repository
|
|
||||||
|
|
||||||
**Triggers:**
|
|
||||||
- `builder.yml`: On push tag `v*` or manual workflow dispatch
|
|
||||||
|
|
||||||
**Secrets Required:**
|
|
||||||
- `GITHUB_TOKEN` (automatic)
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Always release both repositories together
|
|
||||||
- Keep version numbers synchronized
|
|
||||||
- Test in Home Assistant Ingress mode before releasing
|
|
||||||
- Docker/direct mode should work without changes (backward compatibility)
|
|
||||||
+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.9"
|
// 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()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -20,8 +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
|
||||||
|
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,14 +85,17 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ func Load() *Config {
|
|||||||
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"`
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "1.0.9",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -1093,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 {
|
||||||
|
|||||||
@@ -643,6 +643,9 @@
|
|||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-1
@@ -1,5 +1,6 @@
|
|||||||
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 { MockCameraAPI } from './mock/mock-camera-api.js';
|
||||||
import { MockStreamAPI } from './mock/mock-stream-api.js';
|
import { MockStreamAPI } from './mock/mock-stream-api.js';
|
||||||
import { SearchForm } from './ui/search-form.js';
|
import { SearchForm } from './ui/search-form.js';
|
||||||
@@ -7,6 +8,7 @@ import { StreamList } from './ui/stream-list.js';
|
|||||||
import { ConfigPanel } from './ui/config-panel.js';
|
import { 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() {
|
||||||
@@ -29,6 +31,9 @@ class StrixApp {
|
|||||||
this.streamAPI = new StreamDiscoveryAPI();
|
this.streamAPI = new StreamDiscoveryAPI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.probeAPI = new ProbeAPI();
|
||||||
|
this.probeResult = null;
|
||||||
|
|
||||||
this.searchForm = new SearchForm();
|
this.searchForm = new SearchForm();
|
||||||
this.streamList = new StreamList();
|
this.streamList = new StreamList();
|
||||||
this.configPanel = new ConfigPanel();
|
this.configPanel = new ConfigPanel();
|
||||||
@@ -82,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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +181,88 @@ class StrixApp {
|
|||||||
document.getElementById('address-validated').value = address;
|
document.getElementById('address-validated').value = address;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showScreen('config');
|
// Extract IP for probe (from full URL or raw input)
|
||||||
|
const probeIP = this.extractIPForProbe(address);
|
||||||
|
|
||||||
|
// Probe the device before proceeding
|
||||||
|
const btn = document.getElementById('btn-check-address');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Checking...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.probeResult = await this.probeAPI.probe(probeIP);
|
||||||
|
|
||||||
|
if (this.probeResult.reachable) {
|
||||||
|
// Auto-fill vendor into Camera Model if found
|
||||||
|
if (this.probeResult.probes.arp && this.probeResult.probes.arp.vendor) {
|
||||||
|
const modelInput = document.getElementById('camera-model');
|
||||||
|
if (!modelInput.disabled && !modelInput.value) {
|
||||||
|
modelInput.value = this.probeResult.probes.arp.vendor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showScreen('config');
|
||||||
|
} else {
|
||||||
|
// Device unreachable -- show modal
|
||||||
|
const result = await showModal({
|
||||||
|
title: 'Device Unreachable',
|
||||||
|
message: `The device at ${probeIP} is not responding. It may be offline, on a different network, or the IP address may be incorrect.`,
|
||||||
|
buttons: [
|
||||||
|
{ id: 'change', label: 'Change IP', style: 'primary' },
|
||||||
|
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'continue') {
|
||||||
|
this.showScreen('config');
|
||||||
|
} else {
|
||||||
|
// 'change' or null (overlay click) -- stay on address screen
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Network/server error -- show modal
|
||||||
|
const result = await showModal({
|
||||||
|
title: 'Connection Error',
|
||||||
|
message: `Could not check the device: ${error.message}`,
|
||||||
|
buttons: [
|
||||||
|
{ id: 'change', label: 'Change IP', style: 'primary' },
|
||||||
|
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'continue') {
|
||||||
|
this.showScreen('config');
|
||||||
|
} else {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract IP address from input for probe API call.
|
||||||
|
* Handles plain IPs and full URLs like rtsp://user:pass@192.168.1.50/stream
|
||||||
|
*/
|
||||||
|
extractIPForProbe(address) {
|
||||||
|
if (this.isFullURL(address)) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(address);
|
||||||
|
return urlObj.hostname;
|
||||||
|
} catch (e) {
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove port if present (e.g., "192.168.1.50:554")
|
||||||
|
const colonIndex = address.lastIndexOf(':');
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
return address.substring(0, colonIndex);
|
||||||
|
}
|
||||||
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFullURL(str) {
|
isFullURL(str) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user