Compare commits

...
Author SHA1 Message Date
QiuSW 8f7d91310b feat: 重建 Bell Event 与合成入口 (#131) 2026-08-29 09:00:40 +08:00
ila 96bd4ad2c8 feat: 串联 Brain 独立纵切与内部事件 (#16)
合入 dev,#16 保持待验收。
2026-08-28 23:25:39 +08:00
QiuSW adbd1c6aba feat: 串联 Brain 独立纵切与内部事件 (#16) 2026-08-28 23:25:23 +08:00
ila 6ffdbcce84 feat: 实现 Brain 区域与方向越线判定 (#15)
合入 dev,#15 保持待验收。
2026-08-28 23:08:01 +08:00
QiuSW f6f561f2e2 feat: 实现 Brain 区域与方向越线判定 (#15) 2026-08-28 23:07:46 +08:00
ila ee9cfb0433 feat: 实现 Brain 匿名检测与单路跟踪 (#14)
合入 dev,#14 保持待验收。
2026-08-28 22:39:29 +08:00
QiuSW 407ffa17b2 feat: 实现 Brain 匿名检测与单路跟踪 (#14) 2026-08-28 22:39:12 +08:00
ila ba4ec28763 feat: 建立 Brain 可替换视频解码流水线 (#13)
合入 dev,#13 保持待验收。
2026-08-28 22:27:27 +08:00
QiuSW b9b067213f feat: 建立 Brain 可替换视频解码流水线 (#13) 2026-08-28 22:27:04 +08:00
ila 52b368068e feat: 实现 Brain 合成与本地视频输入 (#11)
合入 dev,#11 保持待验收。
2026-08-28 22:20:04 +08:00
QiuSW 7964d61cab feat: 实现 Brain 合成与本地视频输入 (#11) 2026-08-28 22:19:22 +08:00
ila 4a605f6482 Merge pull request #123 from feat/sense-79-ops-alerts
feat: 实现 Sense 运维告警隔离 (#79)
2026-08-28 18:25:33 +08:00
QiuSW d61e6d5ee1 feat: 实现 Sense 运维告警隔离 (#79) 2026-08-28 18:25:07 +08:00
ila 7274bd42f5 Merge PR #122: Sense 可靠投递状态机
Implements #78. Issue remains open for user acceptance.
2026-08-28 17:23:01 +08:00
QiuSW 02a5af5e3b feat: 建立 Sense 可靠投递状态机 (#78) 2026-08-28 17:22:44 +08:00
ila e06904272a Merge PR #121: [SEN] MediaMTX 分片分配与故障范围可观察
实现工单 #77;保持工单待人工验收。
2026-08-28 16:10:21 +08:00
QiuSW d681fd1345 feat: 实现 MediaMTX 分片分配与故障范围可观察 (#77) 2026-08-28 16:09:52 +08:00
ila bdd78523b5 Merge PR #120: Sense 边缘节点资产与离线状态管理
Implements #76; remains pending user acceptance.
2026-08-28 14:29:55 +08:00
QiuSW 2db49fc955 feat: 实现边缘节点资产与离线状态管理 (#76) 2026-08-28 14:25:32 +08:00
ila a9fd8e8a47 Merge PR #119: Sense 容量与配额安全闸
合入 dev,工单 #75 保持待验收。
2026-08-28 11:55:51 +08:00
QiuSW 12f5419f21 feat: 实现容量与配额安全闸 (#75) 2026-08-28 11:55:11 +08:00
ila debf662138 Merge PR #118: Sense 运维中心 (#74)
将 #74 实现合入 dev;工单保持待验收。
2026-08-28 11:05:11 +08:00
QiuSW 184f101a86 feat: 实现 Sense 运维中心 (#74) 2026-08-28 11:02:34 +08:00
ila 4157d90e9f Merge PR #117: Sense 本地事件候选查看
关联 #73;合入 dev,工单保持待验收。
2026-08-28 10:26:58 +08:00
QiuSW 028519a710 feat: 实现 Sense 本地事件只读查看 (#73) 2026-08-28 10:26:13 +08:00
ila 44b60b5b46 Merge PR #116: Sense 批量开通与失败项重试 (#72)
实现已合入 dev,工单 #72 保持待验收。
2026-08-28 09:54:30 +08:00
QiuSW ae19e59703 feat: 实现 Sense 批量开通与失败重试 (#72) 2026-08-28 09:53:00 +08:00
ila 2e709aa4ff merge: 记录三项目并行工单验收 (#113)
#10、#62、#71 的用户验收状态已写入 Wiki 并同步核心镜像;未验证边界保持不变。
2026-08-27 23:30:15 +08:00
QiuSW 870cb4acec docs: 记录三项目并行工单验收 (#113) 2026-08-27 23:29:52 +08:00
ila 9c874734af merge: 同步三项目并行实施事实 (#113)
Wiki 已回读,18 页镜像一致,DevHarness strict 与 48 项测试通过。#10、#62、#71、#113 均保持待验收。
2026-08-27 19:14:17 +08:00
QiuSW 966633d1cc docs: 同步三项目并行实施事实 (#113) 2026-08-27 19:14:01 +08:00
ila dcbebdf01b merge: 完成 Sense 独立端到端回归 (#71)
协调复核通过:范围、隔离清理、静态兼容、Go 全量测试与 DevHarness strict 均通过。工单保持待验收。
2026-08-27 19:00:16 +08:00
QiuSW f1c765f8cc test: 补充 Sense 独立端到端回归 (#71) 2026-08-27 18:56:59 +08:00
ila 14fda5395b merge: 完成 Bell GoAdmin 产品骨架 (#62)
协调复核通过:来源、许可证、安全默认值、Go 测试、前端单测与 DevHarness strict 均通过。工单保持待验收。
2026-08-27 18:29:50 +08:00
ila e60d10b103 Merge pull request #110: 初始化 Brain Python CUDA 项目骨架
关联工单 #10;等待用户验收。
2026-08-27 18:17:20 +08:00
QiuSW 60036587a6 feat: 初始化 Brain Python CUDA 项目骨架 (#10) 2026-08-27 18:15:09 +08:00
218 changed files with 13788 additions and 39 deletions
+190
View File
@@ -0,0 +1,190 @@
[CmdletBinding()]
param(
[string]$PostgresBin = 'D:\pgsql17\bin'
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$server = $null
$pgStarted = $false
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-131-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $testRoot 'postgres'
$pgLog = Join-Path $testRoot 'postgres.log'
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
$serverOut = Join-Path $testRoot 'bell.out.log'
$serverErr = Join-Path $testRoot 'bell.err.log'
$serverExe = Join-Path $testRoot 'bell-server.exe'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\server')).Path
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
$client = [Net.Sockets.TcpClient]::new()
try {
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
} catch {
$connected = $false
} finally {
$client.Dispose()
}
if ($connected -eq $Open) { return }
Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function Wait-Health([string]$BaseUrl) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
try {
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return }
} catch {}
Start-Sleep -Milliseconds 300
}
throw 'Bell health endpoint did not become ready'
}
function Start-Bell([string]$BaseUrl, [string]$Config = 'config/settings.demo.yml') {
$script:server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', $Config) -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
Wait-Health $BaseUrl
}
function Stop-Bell {
if ($null -ne $script:server -and -not $script:server.HasExited) {
Stop-Process -Id $script:server.Id -Force
$script:server.WaitForExit(5000) | Out-Null
}
$script:server = $null
}
New-Item -ItemType Directory -Path $testRoot | Out-Null
$pgPort = Get-FreeTcpPort
$bellPort = Get-FreeTcpPort
$baseUrl = "http://127.0.0.1:$bellPort"
$database = 'bell_131'
$adminPassword = [guid]::NewGuid().ToString('N')
try {
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
$path = Join-Path $PostgresBin $required
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' }
$pgArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput $pgCtlOut -RedirectStandardError $pgCtlErr -WindowStyle Hidden | Out-Null
Wait-Tcp -Port $pgPort -Open $true
$pgStarted = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres $database
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_131_admin'
$env:BELL_BOOTSTRAP_PASSWORD = $adminPassword
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = $bellPort.ToString()
$env:BELL_SYNTHETIC_EVENTS_ENABLED = 'true'
Push-Location $serverRoot
try {
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw 'Bell migration failed' }
go build -o $serverExe .
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
} finally {
Pop-Location
}
Start-Bell $baseUrl
$unauthorized = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 5 -NoProxy
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated request returned code $($unauthorized.code)" }
$loginBody = @{ username = 'bell_131_admin'; password = $adminPassword; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$login = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $loginBody -TimeoutSec 5 -NoProxy
if ([int]$login.code -ne 200 -or [string]::IsNullOrWhiteSpace($login.token)) { throw 'Bell login failed' }
$headers = @{ Authorization = "Bearer $($login.token)" }
$occurredAt = '2026-08-29T00:00:00Z'
$eventBody = @{
sourceEventId = 'acceptance-001'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
location = '东门'; severity = 'high'; evidenceRef = 'evidence/acceptance-001'
attributes = @{ rule = 'area-01'; target = 'anonymous' }
} | ConvertTo-Json -Depth 6 -Compress
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
if ([int]$created.code -ne 200 -or $created.data.duplicate -ne $false) { throw 'first synthetic event was not accepted as new' }
$eventId = [string]$created.data.event.id
$receiptId = [string]$created.data.receipt.id
if ([string]::IsNullOrWhiteSpace($eventId) -or [string]::IsNullOrWhiteSpace($receiptId)) { throw 'event or receipt id missing' }
$replay = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
if ([int]$replay.code -ne 200 -or $replay.data.duplicate -ne $true -or $replay.data.event.id -ne $eventId) { throw 'idempotent replay failed' }
$concurrentBody = @{
sourceEventId = 'acceptance-concurrent'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
location = '西门'; severity = 'medium'; evidenceRef = 'evidence/acceptance-concurrent'
attributes = @{ rule = 'area-02'; target = 'anonymous' }
} | ConvertTo-Json -Depth 6 -Compress
$authHeader = [string]$headers.Authorization
$concurrent = 1..12 | ForEach-Object -Parallel {
$requestHeaders = @{ Authorization = $using:authHeader }
Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/synthetic-events" -Headers $requestHeaders -ContentType 'application/json; charset=utf-8' -Body $using:concurrentBody -TimeoutSec 20 -NoProxy
} -ThrottleLimit 12
$concurrentIds = @($concurrent | ForEach-Object { [string]$_.data.event.id } | Sort-Object -Unique)
$newCount = @($concurrent | Where-Object { $_.data.duplicate -eq $false }).Count
if ($concurrent.Count -ne 12 -or $concurrentIds.Count -ne 1 -or $newCount -ne 1) { throw 'concurrent idempotency did not converge to one Event' }
$conflictObject = $eventBody | ConvertFrom-Json
$conflictObject.severity = 'critical'
$conflict = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ($conflictObject | ConvertTo-Json -Depth 6 -Compress) -TimeoutSec 10 -NoProxy
if ([int]$conflict.code -ne 409) { throw "idempotency conflict returned code $($conflict.code)" }
$malformed = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body '{' -TimeoutSec 10 -NoProxy
if ([int]$malformed.code -ne 400) { throw "malformed request returned code $($malformed.code)" }
$oversized = @{ sourceEventId='too-large'; eventType='test'; occurredAt=$occurredAt; location='lab'; severity='low'; attributes=@{ blob=('x' * 70000) } } | ConvertTo-Json -Depth 5 -Compress
$tooLarge = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body $oversized -TimeoutSec 10 -NoProxy
if ([int]$tooLarge.code -ne 400) { throw "oversized request returned code $($tooLarge.code)" }
$psql = Join-Path $PostgresBin 'psql.exe'
$counts = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from bell_events; select count(*) from bell_event_receipts; select count(*) from bell_event_ingest_audits where outcome='accepted'; select count(*) from bell_event_ingest_audits where outcome='replay'; select count(*) from bell_event_ingest_audits where outcome='conflict';"
if (($counts -join ',') -ne '2,2,2,12,1') { throw "unexpected Bell fact counts: $($counts -join ',')" }
$payloadAuditCount = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from sys_opera_log where oper_url='/api/v1/bell/synthetic-events' and (oper_param like '%sourceEventId%' or json_result like '%acceptance-001%');"
if ([int]$payloadAuditCount -ne 0) { throw 'generic GoAdmin audit retained synthetic event payload' }
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "update bell_events set severity='low' where id='$eventId'" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw 'immutable Event update unexpectedly succeeded' }
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "delete from bell_event_receipts where id='$receiptId'" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw 'immutable Receipt delete unexpectedly succeeded' }
Stop-Bell
Start-Bell $baseUrl
$afterRestart = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/events/$eventId" -Headers $headers -TimeoutSec 10 -NoProxy
if ([int]$afterRestart.code -ne 200 -or $afterRestart.data.id -ne $eventId) { throw 'Event was not readable after Bell restart' }
Stop-Bell
Start-Bell $baseUrl 'config/settings.yml'
$productionSynthetic = Invoke-WebRequest -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 10 -NoProxy -SkipHttpErrorCheck
if ([int]$productionSynthetic.StatusCode -ne 404) { throw "production synthetic route returned HTTP $($productionSynthetic.StatusCode)" }
Write-Output "BELL_131_SMOKE events=2 receipts=2 concurrent=12 audit=redacted immutable=true restart=true production_synthetic=404"
} finally {
Stop-Bell
if ($pgStarted) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
}
foreach ($name in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT','BELL_SYNTHETIC_EVENTS_ENABLED')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Write-Verbose "Bell #131 temporary artifacts: $testRoot"
}
+9
View File
@@ -0,0 +1,9 @@
package event
import "errors"
var (
ErrInvalid = errors.New("事件字段无效")
ErrConflict = errors.New("幂等键已用于不同事件载荷")
ErrNotFound = errors.New("事件不存在")
)
+24
View File
@@ -0,0 +1,24 @@
package event
import (
"encoding/json"
"time"
)
// Event is an immutable Bell business fact. It intentionally does not embed
// GoAdmin's mutable/soft-delete model fields.
type Event struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_event_key"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_event_key"`
EventType string `json:"eventType" gorm:"size:128;not null;index"`
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
Location string `json:"location" gorm:"size:256;not null;index"`
Severity string `json:"severity" gorm:"size:16;not null;index"`
EvidenceRef *string `json:"evidenceRef,omitempty" gorm:"size:512"`
NormalizedPayload json.RawMessage `json:"normalizedPayload" gorm:"type:jsonb;not null"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
ReceivedAt time.Time `json:"receivedAt" gorm:"type:timestamptz;not null;index"`
}
func (Event) TableName() string { return "bell_events" }
+105
View File
@@ -0,0 +1,105 @@
package event
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
)
const (
maxProducerID = 128
maxSourceEventID = 256
maxEventType = 128
maxLocation = 256
maxEvidenceRef = 512
maxAttributes = 48 * 1024
)
type Command struct {
ProducerID string `json:"producerId"`
SourceEventID string `json:"sourceEventId"`
EventType string `json:"eventType"`
OccurredAt time.Time `json:"occurredAt"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidenceRef,omitempty"`
Attributes map[string]any `json:"attributes"`
}
type Normalized struct {
Command Command
Payload []byte
Digest string
}
func Normalize(command Command) (Normalized, error) {
command.ProducerID = strings.TrimSpace(command.ProducerID)
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
command.EventType = strings.TrimSpace(command.EventType)
command.Location = strings.TrimSpace(command.Location)
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
command.OccurredAt = command.OccurredAt.UTC()
if !validText(command.ProducerID, maxProducerID) ||
!validText(command.SourceEventID, maxSourceEventID) ||
!validText(command.EventType, maxEventType) ||
!validText(command.Location, maxLocation) || command.OccurredAt.IsZero() {
return Normalized{}, ErrInvalid
}
switch command.Severity {
case "low", "medium", "high", "critical":
default:
return Normalized{}, ErrInvalid
}
if command.EvidenceRef != nil {
value := strings.TrimSpace(*command.EvidenceRef)
lower := strings.ToLower(value)
if !validText(value, maxEvidenceRef) || strings.Contains(value, "@") ||
strings.Contains(value, "\\") || strings.HasPrefix(lower, "file:") {
return Normalized{}, fmt.Errorf("%w: evidenceRef 必须是安全的逻辑引用", ErrInvalid)
}
command.EvidenceRef = &value
}
if command.Attributes == nil {
command.Attributes = map[string]any{}
}
attributes, err := canonicalJSON(command.Attributes)
if err != nil || len(attributes) > maxAttributes {
return Normalized{}, fmt.Errorf("%w: attributes 无效或过大", ErrInvalid)
}
// Round trip the attributes so map ordering and nested values have one
// deterministic representation before the complete command is hashed.
if err = json.Unmarshal(attributes, &command.Attributes); err != nil {
return Normalized{}, fmt.Errorf("%w: attributes 无效", ErrInvalid)
}
payload, err := json.Marshal(command)
if err != nil {
return Normalized{}, fmt.Errorf("normalize event: %w", err)
}
digest := sha256.Sum256(payload)
return Normalized{Command: command, Payload: payload, Digest: hex.EncodeToString(digest[:])}, nil
}
func canonicalJSON(value any) ([]byte, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var normalized any
if err = decoder.Decode(&normalized); err != nil {
return nil, err
}
return json.Marshal(normalized)
}
func validText(value string, max int) bool {
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
!strings.ContainsAny(value, "\x00\r\n")
}
+110
View File
@@ -0,0 +1,110 @@
package event
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/receipt"
)
type Result struct {
Event Event `json:"event"`
Receipt receipt.Receipt `json:"receipt"`
Duplicate bool `json:"duplicate"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) Ingest(ctx context.Context, command Command, actorID int) (Result, error) {
if s.DB == nil {
return Result{}, errors.New("event database is unavailable")
}
normalized, err := Normalize(command)
if err != nil {
return Result{}, err
}
var result Result
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockKey := fmt.Sprintf("%d:%s:%s", len(normalized.Command.ProducerID), normalized.Command.ProducerID, normalized.Command.SourceEventID)
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", lockKey).Error; err != nil {
return fmt.Errorf("lock event idempotency key: %w", err)
}
var existing receipt.Receipt
err := tx.Where("producer_id = ? AND source_event_id = ?", normalized.Command.ProducerID, normalized.Command.SourceEventID).
First(&existing).Error
if err == nil {
if existing.PayloadSHA256 != normalized.Digest {
return ErrConflict
}
if err = tx.First(&result.Event, "id = ?", existing.EventID).Error; err != nil {
return fmt.Errorf("load replay event: %w", err)
}
result.Receipt = existing
result.Duplicate = true
return tx.Create(&receipt.IngestAudit{
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeReplay, ActorID: actorID, CreatedAt: time.Now().UTC(),
}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("load event receipt: %w", err)
}
now := time.Now().UTC()
result.Event = Event{
ID: uuid.NewString(), ProducerID: normalized.Command.ProducerID,
SourceEventID: normalized.Command.SourceEventID, EventType: normalized.Command.EventType,
OccurredAt: normalized.Command.OccurredAt, Location: normalized.Command.Location,
Severity: normalized.Command.Severity, EvidenceRef: normalized.Command.EvidenceRef,
NormalizedPayload: normalized.Payload, PayloadSHA256: normalized.Digest, ReceivedAt: now,
}
result.Receipt = receipt.Receipt{
ID: uuid.NewString(), EventID: result.Event.ID, ProducerID: result.Event.ProducerID,
SourceEventID: result.Event.SourceEventID, PayloadSHA256: normalized.Digest, AcceptedAt: now,
}
if err = tx.Create(&result.Event).Error; err != nil {
return fmt.Errorf("create event: %w", err)
}
if err = tx.Create(&result.Receipt).Error; err != nil {
return fmt.Errorf("create event receipt: %w", err)
}
return tx.Create(&receipt.IngestAudit{
ProducerID: result.Event.ProducerID, SourceEventID: result.Event.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeAccepted, ActorID: actorID, CreatedAt: now,
}).Error
})
if errors.Is(err, ErrConflict) {
// The conflict audit must commit independently from the rejected ingest.
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeConflict, ActorID: actorID, CreatedAt: time.Now().UTC(),
}).Error
if auditErr != nil {
return Result{}, fmt.Errorf("record idempotency conflict audit: %w", auditErr)
}
return Result{}, ErrConflict
}
return result, err
}
func (s Service) Get(ctx context.Context, id string) (Event, error) {
if _, err := uuid.Parse(id); err != nil {
return Event{}, ErrNotFound
}
var item Event
if err := s.DB.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Event{}, ErrNotFound
}
return Event{}, err
}
return item, nil
}
+23
View File
@@ -0,0 +1,23 @@
package receipt
import "time"
const (
OutcomeAccepted = "accepted"
OutcomeReplay = "replay"
OutcomeConflict = "conflict"
)
// IngestAudit contains only identifiers, a digest and an outcome. Event payloads,
// credentials and tokens must never be copied into this append-only audit table.
type IngestAudit struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
ProducerID string `json:"producerId" gorm:"size:128;not null;index"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;index"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
Outcome string `json:"outcome" gorm:"size:16;not null"`
ActorID int `json:"actorId" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (IngestAudit) TableName() string { return "bell_event_ingest_audits" }
+16
View File
@@ -0,0 +1,16 @@
package receipt
import "time"
// Receipt permanently binds an idempotency key to the accepted Event payload.
// It deliberately has no UpdatedAt or soft-delete fields because it is immutable.
type Receipt struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
EventID string `json:"eventId" gorm:"type:uuid;not null;uniqueIndex"`
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_receipt_key"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_receipt_key"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
AcceptedAt time.Time `json:"acceptedAt" gorm:"type:timestamptz;not null"`
}
func (Receipt) TableName() string { return "bell_event_receipts" }
+38
View File
@@ -0,0 +1,38 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/event"
)
type eventHandler struct{ api.Api }
func registerEventRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := eventHandler{}
v1.GET("/events/:id", authMiddleware.MiddlewareFunc(), handler.Get)
}
func (h eventHandler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
item, err := event.NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, event.ErrNotFound) {
h.Error(http.StatusNotFound, event.ErrNotFound, event.ErrNotFound.Error())
return
}
h.Logger.Errorf("load Bell event failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
return
}
h.OK(item, "查询成功")
}
+38
View File
@@ -0,0 +1,38 @@
package router
import (
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/synthetic"
"go-admin/common/middleware"
)
type registrar func(*gin.RouterGroup, *jwt.GinJWTMiddleware)
var registrars = []registrar{registerEventRouter}
func InitRouter() {
engine, ok := sdk.Runtime.GetEngine().(*gin.Engine)
if !ok || engine == nil {
log.Error("Bell business router requires Gin engine")
return
}
authMiddleware, err := middleware.AuthInit()
if err != nil {
log.Errorf("Bell business JWT init error: %v", err)
return
}
v1 := engine.Group("/api/v1/bell")
for _, register := range registrars {
register(v1, authMiddleware)
}
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
registerSyntheticRouter(v1, authMiddleware)
}
}
+13
View File
@@ -0,0 +1,13 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/synthetic"
)
func registerSyntheticRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := synthetic.Handler{}
v1.POST("/synthetic-events", authMiddleware.MiddlewareFunc(), handler.Create)
}
+19
View File
@@ -0,0 +1,19 @@
package synthetic
import (
"os"
"strings"
)
const EnabledEnv = "BELL_SYNTHETIC_EVENTS_ENABLED"
// Enabled requires both a non-production runtime and an explicit opt-in.
func Enabled(mode string, getenv func(string) string) bool {
if strings.EqualFold(strings.TrimSpace(mode), "prod") || strings.EqualFold(strings.TrimSpace(mode), "production") {
return false
}
value := strings.ToLower(strings.TrimSpace(getenv(EnabledEnv)))
return value == "true" || value == "1"
}
func EnabledFromEnvironment(mode string) bool { return Enabled(mode, os.Getenv) }
+58
View File
@@ -0,0 +1,58 @@
package synthetic
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"go-admin/app/bell/event"
)
const maxRequestBytes = 64 * 1024
type Handler struct{ api.Api }
func IsAdministrator(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
func (h Handler) Create(c *gin.Context) {
if !IsAdministrator(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("无权使用合成事件入口"), "无权使用合成事件入口")
return
}
if err := restoreRequestBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
return
}
var request CreateRequest
h.MakeContext(c).MakeOrm().Bind(&request, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
return
}
result, err := event.NewService(h.Orm).Ingest(c.Request.Context(), request.Command(), user.GetUserId(c))
if err != nil {
switch {
case errors.Is(err, event.ErrInvalid):
h.Error(http.StatusBadRequest, event.ErrInvalid, event.ErrInvalid.Error())
case errors.Is(err, event.ErrConflict):
h.Error(http.StatusConflict, event.ErrConflict, event.ErrConflict.Error())
default:
h.Logger.Errorf("synthetic event ingest failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("合成事件写入失败"), "合成事件写入失败")
}
return
}
h.OK(result, "合成事件已接收")
// The client already received the full response. Keep only a fixed marker
// for GoAdmin's generic operation audit, which runs after the handler.
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
}
@@ -0,0 +1,53 @@
package synthetic
import (
"bytes"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
)
const (
requestBodyKey = "bell.synthetic.request-body"
requestBodyErrorKey = "bell.synthetic.request-body-error"
)
var redactedRequestBody = []byte(`{"redacted":true}`)
// RedactRequestBody runs before GoAdmin's operation logger. It keeps the real
// body only in the request context for the handler and exposes a fixed marker
// to the generic audit middleware so event payloads never enter sys_opera_log.
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || c.Request.URL.Path != "/api/v1/bell/synthetic-events" {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxRequestBytes+1))
if err != nil {
c.Set(requestBodyErrorKey, err)
} else if len(body) > maxRequestBytes {
c.Set(requestBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(requestBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(redactedRequestBody))
c.Next()
}
}
func restoreRequestBody(c *gin.Context) error {
if value, ok := c.Get(requestBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(requestBodyKey)
if !ok {
return errors.New("synthetic request body was not captured")
}
body := value.([]byte)
c.Request.Body = io.NopCloser(bytes.NewReader(body))
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package synthetic
import (
"time"
"go-admin/app/bell/event"
)
const ProducerID = "bell.synthetic"
type CreateRequest struct {
SourceEventID string `json:"sourceEventId" binding:"required"`
EventType string `json:"eventType" binding:"required"`
OccurredAt time.Time `json:"occurredAt" binding:"required"`
Location string `json:"location" binding:"required"`
Severity string `json:"severity" binding:"required"`
EvidenceRef *string `json:"evidenceRef,omitempty"`
Attributes map[string]any `json:"attributes"`
}
func (r CreateRequest) Command() event.Command {
return event.Command{
ProducerID: ProducerID, SourceEventID: r.SourceEventID, EventType: r.EventType,
OccurredAt: r.OccurredAt, Location: r.Location, Severity: r.Severity,
EvidenceRef: r.EvidenceRef, Attributes: r.Attributes,
}
}
+5 -1
View File
@@ -20,6 +20,8 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
bellrouter "go-admin/app/bell/router"
"go-admin/app/bell/synthetic"
"go-admin/common/bellconfig"
"go-admin/common/database"
"go-admin/common/global"
@@ -54,6 +56,7 @@ func init() {
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
AppRouters = append(AppRouters, router.InitRouter)
AppRouters = append(AppRouters, bellrouter.InitRouter)
}
func setup() error {
@@ -178,7 +181,8 @@ func initRouter() {
//r.Use(middleware.Metrics())
r.Use(common.Sentinel()).
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger)
Use(api.SetRequestLogger).
Use(synthetic.RedactRequestBody())
common.InitMiddleware(r)
@@ -0,0 +1,39 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventReceipt)
}
func migrateBellEventReceipt(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(event.Event), new(receipt.Receipt), new(receipt.IngestAudit)); err != nil {
return err
}
statements := []string{
`ALTER TABLE bell_event_receipts ADD CONSTRAINT bell_event_receipts_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_events ADD CONSTRAINT bell_events_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
`CREATE OR REPLACE FUNCTION bell_reject_immutable_fact() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Bell immutable fact cannot be changed' USING ERRCODE = '55000'; END $$`,
`CREATE TRIGGER bell_events_immutable BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_event_receipts_immutable BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_event_ingest_audits_immutable BEFORE UPDATE OR DELETE ON bell_event_ingest_audits FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
for _, statement := range statements {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,61 @@
package bell_event_test
import (
"errors"
"testing"
"time"
"go-admin/app/bell/event"
)
func validCommand() event.Command {
return event.Command{
ProducerID: " bell.synthetic ", SourceEventID: " test-001 ", EventType: " danger_area_entered ",
OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)),
Location: " 东门 ", Severity: " HIGH ", Attributes: map[string]any{"z": 2, "a": "first"},
}
}
func TestNormalizeIsDeterministicAndTrimsBusinessFields(t *testing.T) {
first, err := event.Normalize(validCommand())
if err != nil {
t.Fatal(err)
}
secondCommand := validCommand()
secondCommand.Attributes = map[string]any{"a": "first", "z": 2}
second, err := event.Normalize(secondCommand)
if err != nil {
t.Fatal(err)
}
if first.Digest != second.Digest || string(first.Payload) != string(second.Payload) {
t.Fatalf("normalized payload is not deterministic: %s != %s", first.Payload, second.Payload)
}
if first.Command.ProducerID != "bell.synthetic" || first.Command.Severity != "high" {
t.Fatalf("fields were not normalized: %#v", first.Command)
}
if first.Command.OccurredAt.Location() != time.UTC {
t.Fatalf("occurredAt was not converted to UTC: %v", first.Command.OccurredAt)
}
}
func TestNormalizeRejectsUnsafeOrInvalidInput(t *testing.T) {
tests := []struct {
name string
mutate func(*event.Command)
}{
{name: "missing source id", mutate: func(c *event.Command) { c.SourceEventID = " " }},
{name: "unknown severity", mutate: func(c *event.Command) { c.Severity = "urgent" }},
{name: "newline in location", mutate: func(c *event.Command) { c.Location = "东门\nsecret" }},
{name: "unsafe evidence path", mutate: func(c *event.Command) { value := `C:\\secret.jpg`; c.EvidenceRef = &value }},
{name: "oversized attributes", mutate: func(c *event.Command) { c.Attributes = map[string]any{"blob": string(make([]byte, 49*1024))} }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
command := validCommand()
test.mutate(&command)
if _, err := event.Normalize(command); !errors.Is(err, event.ErrInvalid) {
t.Fatalf("expected ErrInvalid, got %v", err)
}
})
}
}
@@ -0,0 +1,42 @@
package bell_event_test
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/synthetic"
)
func TestSyntheticEnabledRequiresExplicitNonProductionOptIn(t *testing.T) {
getenv := func(name string) string {
if name == synthetic.EnabledEnv {
return "true"
}
return ""
}
if synthetic.Enabled("prod", getenv) || synthetic.Enabled("production", getenv) {
t.Fatal("synthetic route must stay disabled in production even when the flag is set")
}
if !synthetic.Enabled("dev", getenv) {
t.Fatal("explicit development opt-in should enable the synthetic route")
}
if synthetic.Enabled("dev", func(string) string { return "" }) {
t.Fatal("synthetic route must default to disabled")
}
}
func TestSyntheticAdministratorCheckUsesJWTClaims(t *testing.T) {
gin.SetMode(gin.TestMode)
admin, _ := gin.CreateTestContext(nil)
admin.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "admin"})
if !synthetic.IsAdministrator(admin) {
t.Fatal("admin role should be authorized")
}
operator, _ := gin.CreateTestContext(nil)
operator.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "operator"})
if synthetic.IsAdministrator(operator) {
t.Fatal("non-admin role must be rejected")
}
}
+3
View File
@@ -0,0 +1,3 @@
# Safe local defaults. Do not add credentials, customer data, or production paths.
BRAIN_DEVICE=auto
BRAIN_LOG_LEVEL=INFO
+1
View File
@@ -0,0 +1 @@
3.11.15
+90
View File
@@ -0,0 +1,90 @@
# YoVision Brain
Brain 是无界面的独立推理交付单元。本骨架只提供可安装 Python 包、命令入口和运行时探测;尚不包含视频、模型、规则、事件或部署能力,也不依赖 Sense、Bell 在线。
## 冻结基线
| 项目 | 版本 / 选择 |
|---|---|
| Python | CPython `3.11.15`(`.python-version`;本机由 uv 隔离管理) |
| 环境与包管理 | Python `venv` + pip `26.2.1`;可用 uv `0.11.6` 取得固定 Python |
| 构建后端 | setuptools `80.9.0` |
| 测试 | pytest `8.4.2` |
| 数组运行时 | NumPy `2.3.3` |
| 推理运行时 | PyTorch `2.12.1` |
| CPU wheel | PyTorch 官方 `https://download.pytorch.org/whl/cpu` |
| NVIDIA wheel | PyTorch 官方 CUDA 12.6 `https://download.pytorch.org/whl/cu126` |
选择 Python 3.11 是因为 PyTorch 的 Windows 支持范围包含 Python 3.9–3.12,并且本机已有隔离的 CPython 3.11.15。选择 `torch 2.12.1 + cu126` 是因为 PyTorch 官方为 Linux/Windows 同时发布该固定组合;NVIDIA 的 CUDA 12.x 兼容表要求 Windows 驱动至少为 528.33,本机驱动 566.24 满足运行 CUDA 12.6 wheel 的驱动前提。
本机安装的 CUDA Toolkit 11.2 不参与 PyTorch wheel 构建,也不因本项目而修改。驱动满足最低版本只是兼容前提,不等于 GPU 已验证;必须以 `--smoke cuda` 的真实结果为准。
官方依据:
- [PyTorch - Start Locally](https://docs.pytorch.org/get-started/locally/)
- [PyTorch - Previous Versions](https://pytorch.org/get-started/previous-versions/)
- [NVIDIA CUDA 12.6 Release Notes](https://docs.nvidia.com/cuda/archive/12.6.0/cuda-toolkit-release-notes/index.html)
- [Python 3.11.15](https://www.python.org/downloads/release/python-31115/)
## 创建隔离环境
从仓库根目录执行。无需激活虚拟环境,也不需要更改 PowerShell 执行策略:
```powershell
uv python install 3.11.15
uv venv --python 3.11.15 Brain/.venv
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
```
若不使用 uv,也可以让已安装的 Python 3.11.15 创建环境:
```powershell
py -V:3.11 -m venv Brain/.venv
Brain\.venv\Scripts\python.exe -m pip install pip==26.2.1 --index-url https://pypi.org/simple
Brain\.venv\Scripts\python.exe -m pip install -e "Brain[dev]" --index-url https://pypi.org/simple
```
## 安装运行时
CPU 环境使用 PyTorch 官方 CPU 索引:
```powershell
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cpu
```
目标 NVIDIA 环境使用官方 CUDA 12.6 wheel。该 wheel 自带所需 CUDA 用户态运行库,不要求把系统 Toolkit 改成 12.6:
```powershell
Brain\.venv\Scripts\python.exe -m pip install numpy==2.3.3 --index-url https://pypi.org/simple
Brain\.venv\Scripts\python.exe -m pip install torch==2.12.1 --index-url https://download.pytorch.org/whl/cu126
```
不要在同一环境混装 CPU 与 CUDA wheel;切换后端时重建 `.venv`。
## 运行和验证
入口的帮助与版本查询不导入 PyTorch,因此未安装运行时时也可用:
```powershell
Brain\.venv\Scripts\python.exe -m yovision_brain --help
Brain\.venv\Scripts\python.exe -m yovision_brain --version
```
安装相应运行时后执行:
```powershell
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/test_package.py -q
Brain\.venv\Scripts\python.exe -m yovision_brain --runtime-info
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cpu
Brain\.venv\Scripts\python.exe -m yovision_brain --smoke cuda
```
`--runtime-info` 只输出 Python、PyTorch 和设备能力,不读取或显示环境变量值。`--smoke cuda` 在 CUDA wheel、驱动或设备不可用时以非零状态退出,不会回退 CPU 后伪称成功。
## 配置与安全边界
`.env.example` 只有无秘密默认值。Brain 不接收用户会话,不持有账户、RBAC、Alert 或通知状态。不得把 token、摄像头凭据、客户数据、内部文件路径或未经授权的人脸信息写入配置、日志或事件。
第三方许可证与来源见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
+17
View File
@@ -0,0 +1,17 @@
# Third-party notices
本文件记录 Brain 骨架直接固定或明确依赖的第三方软件。具体安装包内许可证文本仍是最终依据。
| 组件 | 固定版本 | 许可证 | 来源 |
|---|---:|---|---|
| CPython | 3.11.15 | Python Software Foundation License | https://www.python.org/downloads/release/python-31115/ |
| pip | 26.2.1 | MIT | https://github.com/pypa/pip/tree/26.2.1 |
| uv(可选 Python 获取工具,不随 Brain 分发) | 0.11.6 | Apache-2.0 OR MIT | https://github.com/astral-sh/uv/tree/0.11.6 |
| python-build-standalone(uv 管理的 Python 分发来源,不随 Brain 分发) | 2026 系列 | MPL-2.0;分发包内另含 CPython 与组件许可证 | https://github.com/astral-sh/python-build-standalone |
| setuptools | 80.9.0 | MIT | https://github.com/pypa/setuptools/tree/v80.9.0 |
| pytest | 8.4.2 | MIT | https://github.com/pytest-dev/pytest/tree/8.4.2 |
| NumPy | 2.3.3 | BSD-3-Clause | https://github.com/numpy/numpy/tree/v2.3.3 |
| PyTorch | 2.12.1 | BSD-3-Clause | https://github.com/pytorch/pytorch/tree/v2.12.1 |
| NVIDIA CUDA runtime(随官方 PyTorch CUDA wheel 分发) | 12.6 系列 | NVIDIA CUDA Toolkit End User License Agreement | https://docs.nvidia.com/cuda/eula/index.html |
PyTorch 及后续模型可能带来额外第三方依赖与模型许可。本骨架未选择或分发任何模型;引入模型前必须另行核对商用、再分发、数据和输出限制,不能把框架许可证视为模型许可证。
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools==80.9.0"]
build-backend = "setuptools.build_meta"
[project]
name = "yovision-brain"
version = "0.1.0"
description = "Headless inference delivery unit for YoVision"
readme = "README.md"
requires-python = "==3.11.*"
dependencies = []
[project.optional-dependencies]
# The wheel backend is selected by the official PyTorch index documented in
# README.md. Keeping one pinned requirement here prevents CPU/CUDA drift.
runtime = ["numpy==2.3.3", "torch==2.12.1"]
dev = ["pytest==8.4.2"]
[project.scripts]
yovision-brain = "yovision_brain.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers"
+5
View File
@@ -0,0 +1,5 @@
"""YoVision Brain package."""
__version__ = "0.1.0"
__all__ = ["__version__"]
+112
View File
@@ -0,0 +1,112 @@
"""Command-line entry point for safe Brain runtime diagnostics."""
from __future__ import annotations
import argparse
import json
import platform
from collections.abc import Sequence
from typing import Any
from yovision_brain import __version__
def _load_torch() -> Any:
try:
import torch
except ImportError as exc:
raise RuntimeError(
"PyTorch runtime is not installed; install the pinned CPU or CUDA wheel "
"from Brain/README.md"
) from exc
return torch
def runtime_info() -> dict[str, object]:
"""Return non-sensitive interpreter and compute-runtime facts."""
info: dict[str, object] = {
"python": platform.python_version(),
"torch_installed": False,
"torch_version": None,
"cuda_build": None,
"cuda_available": False,
"cuda_device_count": 0,
}
try:
torch = _load_torch()
except RuntimeError:
return info
cuda_available = bool(torch.cuda.is_available())
info.update(
{
"torch_installed": True,
"torch_version": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": cuda_available,
"cuda_device_count": torch.cuda.device_count() if cuda_available else 0,
}
)
return info
def smoke(device: str) -> dict[str, object]:
"""Run a deterministic tensor operation on exactly the requested device."""
torch = _load_torch()
if device == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but PyTorch reports no available CUDA device")
tensor = torch.tensor([[1.0, 2.0], [3.0, 4.0]], device=device)
result = tensor @ tensor
expected = torch.tensor([[7.0, 10.0], [15.0, 22.0]], device=device)
if not torch.equal(result, expected):
raise RuntimeError("tensor smoke result did not match the expected value")
return {
"status": "ok",
"device": device,
"torch_version": torch.__version__,
"cuda_build": torch.version.cuda,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="yovision-brain",
description="YoVision Brain runtime diagnostics",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
action = parser.add_mutually_exclusive_group()
action.add_argument(
"--runtime-info",
action="store_true",
help="print non-sensitive Python/PyTorch/CUDA capability information",
)
action.add_argument(
"--smoke",
choices=("cpu", "cuda"),
help="run a tensor smoke test on exactly the selected device",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.runtime_info:
payload = runtime_info()
elif args.smoke:
payload = smoke(args.smoke)
else:
build_parser().print_help()
return 0
except RuntimeError as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False))
return 2
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
"""Brain independent vertical-slice application."""
from .runner import RunSummary, run_pipeline
__all__ = ["RunSummary", "run_pipeline"]
+63
View File
@@ -0,0 +1,63 @@
"""CLI for the isolated Brain local-event vertical slice."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from yovision_brain.config import ConfigError
from yovision_brain.decode import DecoderError
from yovision_brain.events import JsonLinesSink
from yovision_brain.input import InputError
from yovision_brain.rules import RuleConfigError
from .runner import run_pipeline
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the isolated Brain internal-event pipeline")
parser.add_argument("--config", required=True, help="explicit Brain-internal JSON configuration")
parser.add_argument("--output", default="-", help="JSON Lines output file, or - for stdout")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
config_path = Path(args.config)
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
print(json.dumps({"status": "error", "message": "Brain internal config cannot be read"}), file=sys.stderr)
return 2
if not isinstance(raw, dict):
print(json.dumps({"status": "error", "message": "Brain internal config must be an object"}), file=sys.stderr)
return 2
stream = sys.stdout
owned_stream = None
try:
if args.output != "-":
try:
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
except OSError:
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
return 2
stream = owned_stream
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 3
except KeyboardInterrupt:
print(json.dumps({"status": "cancelled"}), file=sys.stderr)
return 130
finally:
if owned_stream is not None:
owned_stream.close()
print(json.dumps({"status": summary.status, "frames": summary.frames, "detections": summary.detections, "events": summary.events}, sort_keys=True), file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Compose input, decode, anonymous vision, rules and local event output."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from yovision_brain.config import BrainInputConfig, parse_input_config
from yovision_brain.decode import decode_packets
from yovision_brain.events import EventSink, candidate_from_decision
from yovision_brain.input import CancellationToken, build_input_source
from yovision_brain.rules import (
AreaDefinition,
DirectionalLineDefinition,
NormalizedPoint,
RuleEngine,
RuleSet,
)
from yovision_brain.vision import LumaBlobDetector, SingleStreamTracker, TorchLumaBlobDetector
@dataclass(frozen=True, slots=True)
class RunSummary:
status: str
frames: int
detections: int
events: int
def _rules(config: BrainInputConfig, version: str) -> RuleSet:
return RuleSet(
version=version,
profile_id=config.profile.profile_id,
width=config.profile.width,
height=config.profile.height,
areas=tuple(
AreaDefinition(area.rule_id, tuple(NormalizedPoint(point.x, point.y) for point in area.points))
for area in config.areas
),
directional_lines=tuple(
DirectionalLineDefinition(
line.rule_id,
NormalizedPoint(line.start.x, line.start.y),
NormalizedPoint(line.end.x, line.end.y),
line.trigger_direction,
)
for line in config.directional_lines
),
)
def run_pipeline(
raw: Mapping[str, Any],
sink: EventSink,
*,
base_dir: Path | None = None,
cancellation: CancellationToken | None = None,
) -> RunSummary:
input_raw = raw.get("input")
if not isinstance(input_raw, Mapping):
raise ValueError("input must be an object")
config = parse_input_config(input_raw, base_dir=base_dir)
version = raw.get("rules_version")
if not isinstance(version, str) or not version:
raise ValueError("rules_version must be a non-empty string")
detector_raw = raw.get("detector", {})
if not isinstance(detector_raw, Mapping):
raise ValueError("detector must be an object")
backend = detector_raw.get("backend", "python")
threshold = detector_raw.get("threshold", 200)
minimum_area = detector_raw.get("minimum_area", 1)
if not isinstance(threshold, int) or not isinstance(minimum_area, int):
raise ValueError("detector threshold and minimum_area must be integers")
if backend == "python":
detector = LumaBlobDetector(threshold=threshold, minimum_area=minimum_area)
elif backend == "torch_cpu":
detector = TorchLumaBlobDetector(threshold=threshold, minimum_area=minimum_area, device="cpu")
else:
raise ValueError("detector.backend must be python or torch_cpu")
source = build_input_source(config)
tracker = SingleStreamTracker()
engine = RuleEngine(_rules(config, version))
frame_count = detection_count = event_count = 0
for frame in decode_packets(source.packets(cancellation), cancellation):
frame_count += 1
detections = detector.detect(frame)
detection_count += len(detections)
tracks = tracker.update(detections, frame_sequence=frame.sequence, timestamp_ns=frame.timestamp_ns)
decisions = engine.evaluate(
tracks,
profile_id=frame.profile_id,
width=frame.width,
height=frame.height,
)
tracks_by_id = {track.track_id: track for track in tracks}
for decision in decisions:
if not decision.triggered:
continue
sink.write(candidate_from_decision(
decision,
tracks_by_id[decision.track_id],
logical_input_id=config.logical_device_id,
detector=detector.metadata,
))
event_count += 1
tracker.finish()
status = "cancelled" if cancellation is not None and cancellation.cancelled else "completed"
return RunSummary(status, frame_count, detection_count, event_count)
@@ -0,0 +1,27 @@
"""Brain-internal configuration models.
These types are deliberately not a cross-project contract. Adapters for a
future versioned Sense/Brain contract belong in a coordination task.
"""
from .models import (
AreaRule,
BrainInputConfig,
ConfigError,
DirectionalLineRule,
Point,
SourceConfig,
VideoProfile,
parse_input_config,
)
__all__ = [
"AreaRule",
"BrainInputConfig",
"ConfigError",
"DirectionalLineRule",
"Point",
"SourceConfig",
"VideoProfile",
"parse_input_config",
]
+206
View File
@@ -0,0 +1,206 @@
"""Validated project-internal input configuration."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
INTERNAL_SCHEMA = "brain.internal.input/v1"
_SECRET_KEYS = {"credential", "password", "secret", "token", "username"}
class ConfigError(ValueError):
"""Raised when Brain's project-internal test/runtime config is invalid."""
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
@dataclass(frozen=True, slots=True)
class AreaRule:
rule_id: str
points: tuple[Point, ...]
@dataclass(frozen=True, slots=True)
class DirectionalLineRule:
rule_id: str
start: Point
end: Point
trigger_direction: str
@dataclass(frozen=True, slots=True)
class VideoProfile:
profile_id: str
width: int
height: int
fps: float
@dataclass(frozen=True, slots=True)
class SourceConfig:
kind: str
seed: int | None = None
frame_count: int | None = None
path: Path | None = None
chunk_size: int = 64 * 1024
@dataclass(frozen=True, slots=True)
class BrainInputConfig:
schema: str
logical_device_id: str
profile: VideoProfile
source: SourceConfig
areas: tuple[AreaRule, ...] = ()
directional_lines: tuple[DirectionalLineRule, ...] = ()
def _mapping(value: object, field: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
raise ConfigError(f"{field} must be an object")
return value
def _text(value: object, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"{field} must be a non-empty string")
return value.strip()
def _integer(value: object, field: str, *, minimum: int = 1) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ConfigError(f"{field} must be an integer >= {minimum}")
return value
def _number(value: object, field: str, *, minimum: float = 0.0) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ConfigError(f"{field} must be a number")
result = float(value)
if result <= minimum:
raise ConfigError(f"{field} must be greater than {minimum}")
return result
def _reject_secrets(value: object, field: str = "config") -> None:
if isinstance(value, Mapping):
for key, child in value.items():
normalized = str(key).strip().lower()
if normalized in _SECRET_KEYS or any(
marker in normalized for marker in ("password", "secret", "token")
):
raise ConfigError(f"{field} must not contain credential field {key!r}")
_reject_secrets(child, f"{field}.{key}")
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
for index, child in enumerate(value):
_reject_secrets(child, f"{field}[{index}]")
def _point(value: object, field: str) -> Point:
if (
not isinstance(value, Sequence)
or isinstance(value, (str, bytes, bytearray))
or len(value) != 2
):
raise ConfigError(f"{field} must be [x, y]")
x, y = value
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
raise ConfigError(f"{field} coordinates must be numbers")
point = Point(float(x), float(y))
if not 0.0 <= point.x <= 1.0 or not 0.0 <= point.y <= 1.0:
raise ConfigError(f"{field} coordinates must be normalized to 0..1")
return point
def parse_input_config(raw: Mapping[str, Any], *, base_dir: Path | None = None) -> BrainInputConfig:
"""Parse the explicitly versioned Brain-internal input configuration."""
_reject_secrets(raw)
schema = _text(raw.get("schema"), "schema")
if schema != INTERNAL_SCHEMA:
raise ConfigError(f"schema must be {INTERNAL_SCHEMA!r}")
profile_raw = _mapping(raw.get("profile"), "profile")
profile = VideoProfile(
profile_id=_text(profile_raw.get("id"), "profile.id"),
width=_integer(profile_raw.get("width"), "profile.width"),
height=_integer(profile_raw.get("height"), "profile.height"),
fps=_number(profile_raw.get("fps"), "profile.fps"),
)
source_raw = _mapping(raw.get("source"), "source")
kind = _text(source_raw.get("kind"), "source.kind")
if kind == "synthetic":
seed = source_raw.get("seed", 0)
if isinstance(seed, bool) or not isinstance(seed, int):
raise ConfigError("source.seed must be an integer")
source = SourceConfig(
kind=kind,
seed=seed,
frame_count=_integer(source_raw.get("frame_count"), "source.frame_count"),
)
elif kind == "local_file":
configured_path = Path(_text(source_raw.get("path"), "source.path"))
if not configured_path.is_absolute() and base_dir is not None:
configured_path = base_dir / configured_path
source = SourceConfig(
kind=kind,
path=configured_path,
chunk_size=_integer(source_raw.get("chunk_size", 64 * 1024), "source.chunk_size"),
)
else:
raise ConfigError("source.kind must be 'synthetic' or 'local_file'")
areas_raw = raw.get("areas", [])
if not isinstance(areas_raw, list):
raise ConfigError("areas must be an array")
areas: list[AreaRule] = []
for index, item in enumerate(areas_raw):
area = _mapping(item, f"areas[{index}]")
points_raw = area.get("points")
if not isinstance(points_raw, list) or len(points_raw) < 3:
raise ConfigError(f"areas[{index}].points must contain at least three points")
areas.append(
AreaRule(
rule_id=_text(area.get("id"), f"areas[{index}].id"),
points=tuple(_point(point, f"areas[{index}].points[{point_index}]") for point_index, point in enumerate(points_raw)),
)
)
lines_raw = raw.get("directional_lines", [])
if not isinstance(lines_raw, list):
raise ConfigError("directional_lines must be an array")
lines: list[DirectionalLineRule] = []
for index, item in enumerate(lines_raw):
line = _mapping(item, f"directional_lines[{index}]")
direction = _text(line.get("trigger_direction"), f"directional_lines[{index}].trigger_direction")
if direction not in {"left_to_right", "right_to_left"}:
raise ConfigError(
f"directional_lines[{index}].trigger_direction must be left_to_right or right_to_left"
)
lines.append(
DirectionalLineRule(
rule_id=_text(line.get("id"), f"directional_lines[{index}].id"),
start=_point(line.get("start"), f"directional_lines[{index}].start"),
end=_point(line.get("end"), f"directional_lines[{index}].end"),
trigger_direction=direction,
)
)
identifiers = [area.rule_id for area in areas] + [line.rule_id for line in lines]
if len(set(identifiers)) != len(identifiers):
raise ConfigError("rule ids must be unique")
return BrainInputConfig(
schema=schema,
logical_device_id=_text(raw.get("logical_device_id"), "logical_device_id"),
profile=profile,
source=source,
areas=tuple(areas),
directional_lines=tuple(lines),
)
@@ -0,0 +1,16 @@
"""Replaceable Brain-internal video decode pipeline."""
from .models import DecodedFrame, DecoderBackend, DecoderError
from .pipeline import DecoderPipeline, decode_packets
from .raw_rgb import RawRGBDecoder
from .y4m import Y4MDecoder
__all__ = [
"DecodedFrame",
"DecoderBackend",
"DecoderError",
"DecoderPipeline",
"RawRGBDecoder",
"Y4MDecoder",
"decode_packets",
]
+35
View File
@@ -0,0 +1,35 @@
"""Decode-layer ports and frame model."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Iterator, Protocol
from yovision_brain.input import CancellationToken, InputPacket
class DecoderError(RuntimeError):
"""A safe and actionable decode failure."""
@dataclass(frozen=True, slots=True)
class DecodedFrame:
sequence: int
timestamp_ns: int
logical_device_id: str
profile_id: str
width: int
height: int
pixel_format: str
payload: bytes
dimensions_changed: bool = False
class DecoderBackend(Protocol):
media_formats: frozenset[str]
def decode(
self,
packets: Iterable[InputPacket],
cancellation: CancellationToken | None = None,
) -> Iterator[DecodedFrame]: ...
@@ -0,0 +1,47 @@
"""Decoder selection independent of concrete codec libraries."""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from itertools import chain
from yovision_brain.input import CancellationToken, InputPacket
from .models import DecodedFrame, DecoderBackend, DecoderError
from .raw_rgb import RawRGBDecoder
from .y4m import Y4MDecoder
class DecoderPipeline:
def __init__(self, backends: Iterable[DecoderBackend] | None = None) -> None:
selected = tuple(backends) if backends is not None else (RawRGBDecoder(), Y4MDecoder())
self._backends: dict[str, DecoderBackend] = {}
for backend in selected:
for media_format in backend.media_formats:
if media_format in self._backends:
raise ValueError(f"duplicate decoder for media format {media_format!r}")
self._backends[media_format] = backend
def decode(
self,
packets: Iterable[InputPacket],
cancellation: CancellationToken | None = None,
) -> Iterator[DecodedFrame]:
iterator = iter(packets)
if cancellation is not None and cancellation.cancelled:
return
try:
first = next(iterator)
except StopIteration:
return
backend = self._backends.get(first.media_format)
if backend is None:
raise DecoderError(f"no decoder registered for media format {first.media_format!r}")
yield from backend.decode(chain((first,), iterator), cancellation)
def decode_packets(
packets: Iterable[InputPacket],
cancellation: CancellationToken | None = None,
) -> Iterator[DecodedFrame]:
return DecoderPipeline().decode(packets, cancellation)
@@ -0,0 +1,45 @@
"""Pass-through decoder for deterministic RGB24 synthetic frames."""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from yovision_brain.input import CancellationToken, InputPacket
from .models import DecodedFrame, DecoderError
class RawRGBDecoder:
media_formats = frozenset({"rgb24"})
def decode(
self,
packets: Iterable[InputPacket],
cancellation: CancellationToken | None = None,
) -> Iterator[DecodedFrame]:
previous_dimensions: tuple[int, int] | None = None
for packet in packets:
if cancellation is not None and cancellation.cancelled:
return
if packet.media_format != "rgb24":
raise DecoderError(f"raw RGB decoder does not support {packet.media_format!r}")
expected = packet.width * packet.height * 3
if len(packet.payload) != expected:
raise DecoderError(
f"RGB24 frame {packet.sequence} has {len(packet.payload)} bytes; expected {expected}"
)
if packet.timestamp_ns is None:
raise DecoderError(f"RGB24 frame {packet.sequence} has no source timestamp")
dimensions = (packet.width, packet.height)
yield DecodedFrame(
sequence=packet.sequence,
timestamp_ns=packet.timestamp_ns,
logical_device_id=packet.logical_device_id,
profile_id=packet.profile_id,
width=packet.width,
height=packet.height,
pixel_format="rgb24",
payload=packet.payload,
dimensions_changed=previous_dimensions is not None and dimensions != previous_dimensions,
)
previous_dimensions = dimensions
+167
View File
@@ -0,0 +1,167 @@
"""Minimal streaming YUV4MPEG2 decoder for anonymous local fixtures.
The backend intentionally supports only uncompressed C444 streams. Production
codecs and RTSP belong behind the same decoder port in later tasks.
"""
from __future__ import annotations
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from yovision_brain.input import CancellationToken, InputPacket
from .models import DecodedFrame, DecoderError
_MAX_HEADER_BYTES = 4096
_MAX_FRAME_BYTES = 256 * 1024 * 1024
class _Cancelled(Exception):
pass
class _PacketReader:
def __init__(
self,
packets: Iterable[InputPacket],
cancellation: CancellationToken | None,
) -> None:
self._packets = iter(packets)
self._cancellation = cancellation
self._buffer = bytearray()
self._ended = False
self.first_packet: InputPacket | None = None
def _fill(self) -> bool:
if self._cancellation is not None and self._cancellation.cancelled:
raise _Cancelled
if self._ended:
return False
try:
packet = next(self._packets)
except StopIteration:
self._ended = True
return False
if packet.media_format != "container-bytes":
raise DecoderError(f"Y4M decoder does not support {packet.media_format!r}")
if self.first_packet is None:
self.first_packet = packet
else:
first = self.first_packet
if (packet.logical_device_id, packet.profile_id) != (
first.logical_device_id,
first.profile_id,
):
raise DecoderError("input identity changed inside one local video stream")
self._buffer.extend(packet.payload)
return True
def line(self, *, allow_clean_eof: bool = False) -> bytes | None:
while True:
newline = self._buffer.find(b"\n")
if newline >= 0:
result = bytes(self._buffer[:newline])
del self._buffer[: newline + 1]
return result
if len(self._buffer) > _MAX_HEADER_BYTES:
raise DecoderError("Y4M header exceeds the safe size limit")
if not self._fill():
if not self._buffer and allow_clean_eof:
return None
raise DecoderError("truncated Y4M header")
def exact(self, size: int) -> bytes:
while len(self._buffer) < size:
if not self._fill():
raise DecoderError("truncated Y4M frame payload")
result = bytes(self._buffer[:size])
del self._buffer[:size]
return result
@dataclass(frozen=True, slots=True)
class _Header:
width: int
height: int
fps_numerator: int
fps_denominator: int
def _positive_int(value: bytes, field: str) -> int:
try:
result = int(value)
except ValueError as exc:
raise DecoderError(f"invalid Y4M {field}") from exc
if result <= 0:
raise DecoderError(f"invalid Y4M {field}")
return result
def _parse_header(line: bytes) -> _Header:
parts = line.split()
if not parts or parts[0] != b"YUV4MPEG2":
raise DecoderError("unsupported local video format; expected YUV4MPEG2")
fields = {part[:1]: part[1:] for part in parts[1:] if len(part) > 1}
if fields.get(b"C", b"444") not in {b"444", b"444jpeg"}:
raise DecoderError("unsupported Y4M chroma; only C444 is supported")
width = _positive_int(fields.get(b"W", b""), "width")
height = _positive_int(fields.get(b"H", b""), "height")
fps_parts = fields.get(b"F", b"").split(b":", 1)
if len(fps_parts) != 2:
raise DecoderError("invalid Y4M frame rate")
header = _Header(
width=width,
height=height,
fps_numerator=_positive_int(fps_parts[0], "frame rate numerator"),
fps_denominator=_positive_int(fps_parts[1], "frame rate denominator"),
)
if header.width * header.height * 3 > _MAX_FRAME_BYTES:
raise DecoderError("Y4M frame exceeds the safe size limit")
return header
class Y4MDecoder:
media_formats = frozenset({"container-bytes"})
def decode(
self,
packets: Iterable[InputPacket],
cancellation: CancellationToken | None = None,
) -> Iterator[DecodedFrame]:
reader = _PacketReader(packets, cancellation)
try:
header_line = reader.line()
assert header_line is not None
header = _parse_header(header_line)
first = reader.first_packet
if first is None:
raise DecoderError("local video input is empty")
if (first.width, first.height) != (header.width, header.height):
raise DecoderError(
"Y4M dimensions do not match the configured input profile "
f"({header.width}x{header.height} != {first.width}x{first.height})"
)
interval_ns = round(1_000_000_000 * header.fps_denominator / header.fps_numerator)
frame_size = header.width * header.height * 3
sequence = 0
while True:
frame_header = reader.line(allow_clean_eof=True)
if frame_header is None:
return
if frame_header != b"FRAME":
raise DecoderError(f"invalid Y4M frame header at frame {sequence}")
payload = reader.exact(frame_size)
yield DecodedFrame(
sequence=sequence,
timestamp_ns=sequence * interval_ns,
logical_device_id=first.logical_device_id,
profile_id=first.profile_id,
width=header.width,
height=header.height,
pixel_format="yuv444p",
payload=payload,
)
sequence += 1
except _Cancelled:
return
@@ -0,0 +1,13 @@
"""Brain-internal event candidates and local sinks."""
from .mapper import candidate_from_decision
from .models import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
from .sink import EventSink, JsonLinesSink
__all__ = [
"EventSink",
"INTERNAL_EVENT_SCHEMA",
"InternalEventCandidate",
"JsonLinesSink",
"candidate_from_decision",
]
+53
View File
@@ -0,0 +1,53 @@
"""Stable mapping from an internal rule hit to an internal event candidate."""
from __future__ import annotations
import hashlib
import json
from yovision_brain.rules import RuleDecision
from yovision_brain.vision import DetectorMetadata, TrackedObject
from .models import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
def candidate_from_decision(
decision: RuleDecision,
track: TrackedObject,
*,
logical_input_id: str,
detector: DetectorMetadata,
) -> InternalEventCandidate:
if not decision.triggered or decision.track_id != track.track_id:
raise ValueError("only a triggered decision for the same anonymous track can become an event")
event_type = "danger_area_entered" if decision.rule_type == "danger_area" else "directional_line_crossed"
observation: dict[str, object] = {
"category": track.category,
"confidence": track.confidence,
"box": {
"left": track.box.left,
"top": track.box.top,
"right": track.box.right,
"bottom": track.box.bottom,
},
"anchor": {"x": decision.anchor.x, "y": decision.anchor.y},
}
fact = {
"schema": INTERNAL_EVENT_SCHEMA,
"logical_input_id": logical_input_id,
"event_type": event_type,
"occurred_at_ns": decision.timestamp_ns,
"rule_id": decision.rule_id,
"rule_version": decision.config_version,
"model_name": detector.name,
"model_version": detector.version,
"profile_id": decision.profile_id,
"frame_width": decision.width,
"frame_height": decision.height,
"track_id": track.track_id,
"observation": observation,
"reason": decision.reason,
}
canonical = json.dumps(fact, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
event_id = "brain-local-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return InternalEventCandidate(event_id=event_id, **fact)
+29
View File
@@ -0,0 +1,29 @@
"""Explicitly internal event candidate model; not a shared contract."""
from __future__ import annotations
from dataclasses import asdict, dataclass
INTERNAL_EVENT_SCHEMA = "brain.internal.event-candidate/v1"
@dataclass(frozen=True, slots=True)
class InternalEventCandidate:
schema: str
event_id: str
logical_input_id: str
event_type: str
occurred_at_ns: int
rule_id: str
rule_version: str
model_name: str
model_version: str
profile_id: str
frame_width: int
frame_height: int
track_id: str
observation: dict[str, object]
reason: str
def to_dict(self) -> dict[str, object]:
return asdict(self)
+21
View File
@@ -0,0 +1,21 @@
"""Replaceable local event sinks."""
from __future__ import annotations
import json
from typing import Protocol, TextIO
from .models import InternalEventCandidate
class EventSink(Protocol):
def write(self, candidate: InternalEventCandidate) -> None: ...
class JsonLinesSink:
def __init__(self, stream: TextIO) -> None:
self._stream = stream
def write(self, candidate: InternalEventCandidate) -> None:
self._stream.write(json.dumps(candidate.to_dict(), ensure_ascii=False, sort_keys=True) + "\n")
self._stream.flush()
@@ -0,0 +1,16 @@
"""Brain-internal video input adapters."""
from .factory import build_input_source
from .local_file import LocalFileInput
from .models import CancellationToken, InputError, InputPacket, InputSource
from .synthetic import SyntheticInput
__all__ = [
"CancellationToken",
"InputError",
"InputPacket",
"InputSource",
"LocalFileInput",
"SyntheticInput",
"build_input_source",
]
+17
View File
@@ -0,0 +1,17 @@
"""Construct the configured Brain-internal input adapter."""
from __future__ import annotations
from yovision_brain.config import BrainInputConfig
from .local_file import LocalFileInput
from .models import InputSource
from .synthetic import SyntheticInput
def build_input_source(config: BrainInputConfig) -> InputSource:
if config.source.kind == "synthetic":
return SyntheticInput(config)
if config.source.kind == "local_file":
return LocalFileInput(config)
raise ValueError(f"unsupported Brain input source kind: {config.source.kind}")
@@ -0,0 +1,54 @@
"""Explicit local-file input adapter with safe error reporting."""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
from yovision_brain.config import BrainInputConfig
from .models import CancellationToken, InputError, InputPacket
class LocalFileInput:
def __init__(self, config: BrainInputConfig) -> None:
if config.source.kind != "local_file" or config.source.path is None:
raise ValueError("LocalFileInput requires a local_file source config")
self._config = config
self._path = config.source.path
@property
def source_label(self) -> str:
"""Return a safe label rather than exposing the internal absolute path."""
return self._path.name
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]:
profile = self._config.profile
source = self._config.source
try:
stream = self._path.open("rb")
except FileNotFoundError as exc:
raise InputError(f"local video source not found: {self.source_label}") from exc
except OSError as exc:
raise InputError(f"local video source cannot be opened: {self.source_label}: {exc.strerror}") from exc
with stream:
sequence = 0
while cancellation is None or not cancellation.cancelled:
try:
payload = stream.read(source.chunk_size)
except OSError as exc:
raise InputError(f"local video source read failed: {self.source_label}: {exc.strerror}") from exc
if not payload:
return
yield InputPacket(
sequence=sequence,
timestamp_ns=None,
logical_device_id=self._config.logical_device_id,
profile_id=profile.profile_id,
width=profile.width,
height=profile.height,
media_format="container-bytes",
payload=payload,
)
sequence += 1
+43
View File
@@ -0,0 +1,43 @@
"""Common project-internal input types."""
from __future__ import annotations
from dataclasses import dataclass
from threading import Event
from typing import Iterator, Protocol
class InputError(RuntimeError):
"""A safe, actionable input adapter error."""
class CancellationToken:
"""Thread-safe cooperative cancellation without platform dependencies."""
def __init__(self) -> None:
self._event = Event()
def cancel(self) -> None:
self._event.set()
@property
def cancelled(self) -> bool:
return self._event.is_set()
@dataclass(frozen=True, slots=True)
class InputPacket:
sequence: int
timestamp_ns: int | None
logical_device_id: str
profile_id: str
width: int
height: int
media_format: str
payload: bytes
class InputSource(Protocol):
"""Replaceable source boundary consumed by the future decode layer."""
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]: ...
@@ -0,0 +1,38 @@
"""Deterministic synthetic RGB input for isolated tests and smoke runs."""
from __future__ import annotations
import random
from collections.abc import Iterator
from yovision_brain.config import BrainInputConfig
from .models import CancellationToken, InputPacket
class SyntheticInput:
def __init__(self, config: BrainInputConfig) -> None:
if config.source.kind != "synthetic":
raise ValueError("SyntheticInput requires a synthetic source config")
self._config = config
def packets(self, cancellation: CancellationToken | None = None) -> Iterator[InputPacket]:
source = self._config.source
assert source.seed is not None and source.frame_count is not None
randomizer = random.Random(source.seed)
profile = self._config.profile
frame_size = profile.width * profile.height * 3
interval_ns = round(1_000_000_000 / profile.fps)
for sequence in range(source.frame_count):
if cancellation is not None and cancellation.cancelled:
return
yield InputPacket(
sequence=sequence,
timestamp_ns=sequence * interval_ns,
logical_device_id=self._config.logical_device_id,
profile_id=profile.profile_id,
width=profile.width,
height=profile.height,
media_format="rgb24",
payload=randomizer.randbytes(frame_size),
)
@@ -0,0 +1,21 @@
"""Brain-internal anonymous area and directional-line rules."""
from .engine import RuleEngine
from .models import (
AreaDefinition,
DirectionalLineDefinition,
NormalizedPoint,
RuleConfigError,
RuleDecision,
RuleSet,
)
__all__ = [
"AreaDefinition",
"DirectionalLineDefinition",
"NormalizedPoint",
"RuleConfigError",
"RuleDecision",
"RuleEngine",
"RuleSet",
]
+112
View File
@@ -0,0 +1,112 @@
"""Stateful, explainable area and directional-line evaluation."""
from __future__ import annotations
from yovision_brain.vision import TrackedObject
from .models import NormalizedPoint, RuleConfigError, RuleDecision, RuleSet
_EPSILON = 1e-9
def _anchor(track: TrackedObject, width: int, height: int) -> NormalizedPoint:
x = (track.box.left + track.box.right) / (2.0 * width)
y = track.box.bottom / height
try:
return NormalizedPoint(x, y)
except RuleConfigError as exc:
raise RuleConfigError(f"track {track.track_id!r} anchor is outside the configured frame") from exc
def _on_segment(point: NormalizedPoint, first: NormalizedPoint, second: NormalizedPoint) -> bool:
cross = (second.x - first.x) * (point.y - first.y) - (second.y - first.y) * (point.x - first.x)
return abs(cross) <= _EPSILON and min(first.x, second.x) - _EPSILON <= point.x <= max(first.x, second.x) + _EPSILON and min(first.y, second.y) - _EPSILON <= point.y <= max(first.y, second.y) + _EPSILON
def _inside(point: NormalizedPoint, polygon: tuple[NormalizedPoint, ...]) -> bool:
inside = False
previous = polygon[-1]
for current in polygon:
if _on_segment(point, previous, current):
return True
if (current.y > point.y) != (previous.y > point.y):
crossing_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
if point.x < crossing_x:
inside = not inside
previous = current
return inside
def _side(point: NormalizedPoint, start: NormalizedPoint, end: NormalizedPoint) -> float:
return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)
class RuleEngine:
"""Evaluates one versioned rule set against one stream session."""
def __init__(self, rules: RuleSet) -> None:
self._rules = rules
self._area_inside: dict[tuple[str, str], bool] = {}
self._line_side: dict[tuple[str, str], int] = {}
def evaluate(
self,
tracks: tuple[TrackedObject, ...],
*,
profile_id: str,
width: int,
height: int,
) -> tuple[RuleDecision, ...]:
if (profile_id, width, height) != (self._rules.profile_id, self._rules.width, self._rules.height):
raise RuleConfigError("track Profile/resolution does not match the versioned rule configuration")
decisions: list[RuleDecision] = []
for track in tracks:
anchor = _anchor(track, width, height)
common = dict(
track_id=track.track_id,
config_version=self._rules.version,
profile_id=profile_id,
width=width,
height=height,
anchor=anchor,
timestamp_ns=track.timestamp_ns,
)
for area in self._rules.areas:
key = (track.track_id, area.rule_id)
current = _inside(anchor, area.points)
previous = self._area_inside.get(key, False)
state = "entered" if current and not previous else "inside" if current else "outside"
self._area_inside[key] = current
decisions.append(RuleDecision(
rule_id=area.rule_id,
rule_type="danger_area",
state=state,
triggered=state == "entered",
reason=f"bottom-center anchor is {state} the configured polygon",
**common,
))
for line in self._rules.directional_lines:
key = (track.track_id, line.rule_id)
value = _side(anchor, line.start, line.end)
if abs(value) <= line.deadband:
decisions.append(RuleDecision(
rule_id=line.rule_id, rule_type="directional_line", state="on_line",
triggered=False, reason="anchor is inside the line deadband; previous significant side is retained",
**common,
))
continue
current_side = 1 if value > 0 else -1
previous_side = self._line_side.get(key)
self._line_side[key] = current_side
wanted = (previous_side, current_side) == ((1, -1) if line.trigger_direction == "left_to_right" else (-1, 1))
crossed = previous_side is not None and previous_side != current_side
state = "triggered" if wanted else "reverse_crossing" if crossed else "same_side"
decisions.append(RuleDecision(
rule_id=line.rule_id,
rule_type="directional_line",
state=state,
triggered=wanted,
reason=f"directed side transition {previous_side!r}->{current_side}; expected {line.trigger_direction}",
**common,
))
return tuple(decisions)
+84
View File
@@ -0,0 +1,84 @@
"""Versioned Brain-internal rule configuration and decisions."""
from __future__ import annotations
from dataclasses import dataclass
class RuleConfigError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class NormalizedPoint:
x: float
y: float
def __post_init__(self) -> None:
if not 0.0 <= self.x <= 1.0 or not 0.0 <= self.y <= 1.0:
raise RuleConfigError("rule coordinates must be normalized to 0..1")
@dataclass(frozen=True, slots=True)
class AreaDefinition:
rule_id: str
points: tuple[NormalizedPoint, ...]
@dataclass(frozen=True, slots=True)
class DirectionalLineDefinition:
rule_id: str
start: NormalizedPoint
end: NormalizedPoint
trigger_direction: str
deadband: float = 0.005
@dataclass(frozen=True, slots=True)
class RuleSet:
version: str
profile_id: str
width: int
height: int
areas: tuple[AreaDefinition, ...] = ()
directional_lines: tuple[DirectionalLineDefinition, ...] = ()
def __post_init__(self) -> None:
if not self.version or not self.profile_id or self.width <= 0 or self.height <= 0:
raise RuleConfigError("rule version, profile and dimensions are required")
identifiers = [rule.rule_id for rule in self.areas] + [rule.rule_id for rule in self.directional_lines]
if any(not identifier for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
raise RuleConfigError("rule ids must be non-empty and unique")
for area in self.areas:
if len(area.points) < 3 or abs(_polygon_area(area.points)) < 1e-9:
raise RuleConfigError(f"area {area.rule_id!r} must be a non-degenerate polygon")
for line in self.directional_lines:
if line.start == line.end:
raise RuleConfigError(f"line {line.rule_id!r} must have distinct endpoints")
if line.trigger_direction not in {"left_to_right", "right_to_left"}:
raise RuleConfigError(f"line {line.rule_id!r} has invalid trigger direction")
if not 0.0 <= line.deadband < 0.5:
raise RuleConfigError(f"line {line.rule_id!r} has invalid deadband")
def _polygon_area(points: tuple[NormalizedPoint, ...]) -> float:
return sum(
first.x * second.y - second.x * first.y
for first, second in zip(points, points[1:] + points[:1])
) / 2.0
@dataclass(frozen=True, slots=True)
class RuleDecision:
rule_id: str
rule_type: str
track_id: str
state: str
triggered: bool
reason: str
config_version: str
profile_id: str
width: int
height: int
anchor: NormalizedPoint
timestamp_ns: int
@@ -0,0 +1,16 @@
"""Anonymous detection and single-stream tracking."""
from .detector import LumaBlobDetector, TorchLumaBlobDetector
from .models import BoundingBox, Detection, Detector, DetectorMetadata, TrackedObject
from .tracker import SingleStreamTracker
__all__ = [
"BoundingBox",
"Detection",
"Detector",
"DetectorMetadata",
"LumaBlobDetector",
"SingleStreamTracker",
"TorchLumaBlobDetector",
"TrackedObject",
]
+120
View File
@@ -0,0 +1,120 @@
"""Deterministic anonymous blob detectors with no biometric semantics."""
from __future__ import annotations
from collections.abc import Sequence
from yovision_brain.decode import DecodedFrame, DecoderError
from .models import BoundingBox, Detection, DetectorMetadata
_METADATA = DetectorMetadata(
name="yovision-luma-blob",
version="1.0.0",
source="YoVision Brain first-party deterministic algorithm",
license="No external model license; no learned weights are distributed",
weights="none",
)
def _components(mask: Sequence[Sequence[bool]], minimum_area: int) -> tuple[BoundingBox, ...]:
height = len(mask)
width = len(mask[0]) if height else 0
visited: set[tuple[int, int]] = set()
boxes: list[BoundingBox] = []
for y in range(height):
for x in range(width):
if not mask[y][x] or (x, y) in visited:
continue
pending = [(x, y)]
visited.add((x, y))
points: list[tuple[int, int]] = []
while pending:
current_x, current_y = pending.pop()
points.append((current_x, current_y))
for neighbor in (
(current_x - 1, current_y),
(current_x + 1, current_y),
(current_x, current_y - 1),
(current_x, current_y + 1),
):
nx, ny = neighbor
if 0 <= nx < width and 0 <= ny < height and mask[ny][nx] and neighbor not in visited:
visited.add(neighbor)
pending.append(neighbor)
if len(points) >= minimum_area:
xs, ys = zip(*points)
boxes.append(BoundingBox(min(xs), min(ys), max(xs) + 1, max(ys) + 1))
return tuple(sorted(boxes, key=lambda box: (box.top, box.left, box.bottom, box.right)))
def _validate_frame(frame: DecodedFrame) -> None:
if frame.pixel_format not in {"rgb24", "yuv444p"}:
raise DecoderError(f"anonymous detector does not support pixel format {frame.pixel_format!r}")
expected = frame.width * frame.height * 3
if len(frame.payload) != expected:
raise DecoderError(f"vision frame has {len(frame.payload)} bytes; expected {expected}")
class LumaBlobDetector:
"""Small CPU reference detector used for deterministic integration tests."""
metadata = _METADATA
def __init__(self, *, threshold: int = 200, minimum_area: int = 1) -> None:
if not 0 <= threshold <= 255 or minimum_area < 1:
raise ValueError("invalid luma detector threshold or minimum area")
self._threshold = threshold
self._minimum_area = minimum_area
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]:
_validate_frame(frame)
if frame.pixel_format == "rgb24":
pixels = [
max(frame.payload[index : index + 3])
for index in range(0, len(frame.payload), 3)
]
else:
pixels = list(frame.payload[: frame.width * frame.height])
mask = [
[pixels[y * frame.width + x] >= self._threshold for x in range(frame.width)]
for y in range(frame.height)
]
return tuple(
Detection(box=box, category="anonymous_target", confidence=1.0)
for box in _components(mask, self._minimum_area)
)
class TorchLumaBlobDetector:
"""PyTorch CPU/GPU smoke backend; it contains no external model weights."""
metadata = DetectorMetadata(
name="yovision-torch-luma-blob",
version="1.0.0",
source="YoVision Brain first-party PyTorch tensor implementation",
license="PyTorch BSD-3-Clause; no external model weights",
weights="none",
)
def __init__(self, *, threshold: int = 200, minimum_area: int = 1, device: str = "cpu") -> None:
self._threshold = threshold
self._minimum_area = minimum_area
self._device = device
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]:
_validate_frame(frame)
try:
import torch
except ImportError as exc:
raise RuntimeError("PyTorch runtime is required for TorchLumaBlobDetector") from exc
values = torch.tensor(list(frame.payload), dtype=torch.uint8, device=self._device)
if frame.pixel_format == "rgb24":
luma = values.reshape(frame.height, frame.width, 3).amax(dim=2)
else:
luma = values[: frame.width * frame.height].reshape(frame.height, frame.width)
mask = (luma >= self._threshold).cpu().tolist()
return tuple(
Detection(box=box, category="anonymous_target", confidence=1.0)
for box in _components(mask, self._minimum_area)
)
+52
View File
@@ -0,0 +1,52 @@
"""Privacy-preserving vision ports and observations."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from yovision_brain.decode import DecodedFrame
@dataclass(frozen=True, slots=True)
class DetectorMetadata:
name: str
version: str
source: str
license: str
weights: str
@dataclass(frozen=True, slots=True)
class BoundingBox:
left: int
top: int
right: int
bottom: int
@property
def area(self) -> int:
return max(0, self.right - self.left) * max(0, self.bottom - self.top)
@dataclass(frozen=True, slots=True)
class Detection:
box: BoundingBox
category: str
confidence: float
@dataclass(frozen=True, slots=True)
class TrackedObject:
track_id: str
box: BoundingBox
category: str
confidence: float
frame_sequence: int
timestamp_ns: int
class Detector(Protocol):
metadata: DetectorMetadata
def detect(self, frame: DecodedFrame) -> tuple[Detection, ...]: ...
@@ -0,0 +1,83 @@
"""Session-local single-stream IoU tracker."""
from __future__ import annotations
from dataclasses import dataclass
from .models import BoundingBox, Detection, TrackedObject
def _iou(first: BoundingBox, second: BoundingBox) -> float:
intersection = BoundingBox(
max(first.left, second.left),
max(first.top, second.top),
min(first.right, second.right),
min(first.bottom, second.bottom),
).area
union = first.area + second.area - intersection
return intersection / union if union else 0.0
@dataclass(slots=True)
class _Track:
track_id: str
detection: Detection
missed: int = 0
class SingleStreamTracker:
"""Tracks anonymous boxes only within one process session and one stream."""
def __init__(self, *, iou_threshold: float = 0.2, max_missed: int = 2) -> None:
if not 0.0 <= iou_threshold <= 1.0 or max_missed < 0:
raise ValueError("invalid tracker threshold or missed-frame limit")
self._iou_threshold = iou_threshold
self._max_missed = max_missed
self._tracks: dict[str, _Track] = {}
self._next_id = 1
def update(
self,
detections: tuple[Detection, ...],
*,
frame_sequence: int,
timestamp_ns: int,
) -> tuple[TrackedObject, ...]:
unmatched_tracks = set(self._tracks)
results: list[TrackedObject] = []
for detection in detections:
candidates = [
(track_id, _iou(self._tracks[track_id].detection.box, detection.box))
for track_id in unmatched_tracks
if self._tracks[track_id].detection.category == detection.category
]
track_id, score = max(candidates, key=lambda item: item[1], default=("", -1.0))
if score < self._iou_threshold:
track_id = f"track-{self._next_id:06d}"
self._next_id += 1
self._tracks[track_id] = _Track(track_id, detection)
else:
unmatched_tracks.remove(track_id)
self._tracks[track_id].detection = detection
self._tracks[track_id].missed = 0
results.append(
TrackedObject(
track_id=track_id,
box=detection.box,
category=detection.category,
confidence=detection.confidence,
frame_sequence=frame_sequence,
timestamp_ns=timestamp_ns,
)
)
for track_id in unmatched_tracks:
track = self._tracks[track_id]
track.missed += 1
if track.missed > self._max_missed:
del self._tracks[track_id]
return tuple(results)
def finish(self) -> tuple[str, ...]:
ended = tuple(sorted(self._tracks))
self._tracks.clear()
return ended
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import io
import json
import subprocess
import sys
from pathlib import Path
from yovision_brain.app import run_pipeline
from yovision_brain.events import JsonLinesSink
from yovision_brain.input import CancellationToken
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
def test_pipeline_generates_stable_internal_event() -> None:
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
first_stream, second_stream = io.StringIO(), io.StringIO()
first = run_pipeline(raw, JsonLinesSink(first_stream), base_dir=FIXTURE.parent)
second = run_pipeline(raw, JsonLinesSink(second_stream), base_dir=FIXTURE.parent)
assert first.events == second.events == 1
assert first_stream.getvalue() == second_stream.getvalue()
event = json.loads(first_stream.getvalue())
assert event["event_type"] == "danger_area_entered"
assert event["schema"] == "brain.internal.event-candidate/v1"
def test_no_hit_has_explicit_zero_event_summary() -> None:
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
raw["detector"]["threshold"] = 255
raw["detector"]["minimum_area"] = 1000
stream = io.StringIO()
summary = run_pipeline(raw, JsonLinesSink(stream))
assert (summary.status, summary.events, stream.getvalue()) == ("completed", 0, "")
def test_cli_runs_without_sense_or_bell() -> None:
result = subprocess.run(
[sys.executable, "-m", "yovision_brain.app", "--config", str(FIXTURE), "--output", "-"],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout)["schema"] == "brain.internal.event-candidate/v1"
assert json.loads(result.stderr)["events"] == 1
def test_pre_cancelled_run_is_explicit() -> None:
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
token = CancellationToken()
token.cancel()
summary = run_pipeline(raw, JsonLinesSink(io.StringIO()), cancellation=token)
assert (summary.status, summary.frames, summary.events) == ("cancelled", 0, 0)
def test_cli_config_failure_is_nonzero_and_does_not_echo_path(tmp_path: Path) -> None:
missing = tmp_path / "private-machine-path.json"
result = subprocess.run(
[sys.executable, "-m", "yovision_brain.app", "--config", str(missing)],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 2
assert json.loads(result.stderr)["status"] == "error"
assert str(tmp_path) not in result.stderr
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from pathlib import Path
import pytest
from yovision_brain.config import ConfigError, parse_input_config
def valid_config() -> dict[str, object]:
return {
"schema": "brain.internal.input/v1",
"logical_device_id": "synthetic-camera-01",
"profile": {"id": "main", "width": 4, "height": 3, "fps": 5},
"source": {"kind": "synthetic", "seed": 17, "frame_count": 3},
"areas": [{"id": "danger-yard", "points": [[0.1, 0.1], [0.9, 0.1], [0.5, 0.8]]}],
"directional_lines": [
{
"id": "gate-line",
"start": [0.2, 0.5],
"end": [0.8, 0.5],
"trigger_direction": "left_to_right",
}
],
}
def test_parse_versioned_internal_config() -> None:
parsed = parse_input_config(valid_config())
assert parsed.schema == "brain.internal.input/v1"
assert parsed.logical_device_id == "synthetic-camera-01"
assert parsed.profile.width == 4
assert parsed.areas[0].rule_id == "danger-yard"
assert parsed.directional_lines[0].trigger_direction == "left_to_right"
def test_relative_local_path_is_bound_to_explicit_base(tmp_path: Path) -> None:
raw = valid_config()
raw["source"] = {"kind": "local_file", "path": "fixture.bin", "chunk_size": 8}
parsed = parse_input_config(raw, base_dir=tmp_path)
assert parsed.source.path == tmp_path / "fixture.bin"
@pytest.mark.parametrize(
("change", "message"),
[
({"schema": "shared.source/v1"}, "schema must be"),
({"logical_device_id": ""}, "logical_device_id"),
({"source": {"kind": "synthetic", "seed": 1, "frame_count": 0}}, "frame_count"),
({"password": "must-not-be-accepted"}, "credential field"),
],
)
def test_invalid_or_secret_config_is_rejected(change: dict[str, object], message: str) -> None:
raw = valid_config()
raw.update(change)
with pytest.raises(ConfigError, match=message):
parse_input_config(raw)
def test_coordinates_and_rule_ids_are_validated() -> None:
raw = valid_config()
raw["areas"] = [{"id": "same", "points": [[0, 0], [2, 0], [0, 1]]}]
with pytest.raises(ConfigError, match="normalized"):
parse_input_config(raw)
raw = valid_config()
raw["areas"] = [{"id": "same", "points": [[0, 0], [1, 0], [0, 1]]}]
raw["directional_lines"] = [
{"id": "same", "start": [0, 0], "end": [1, 1], "trigger_direction": "left_to_right"}
]
with pytest.raises(ConfigError, match="unique"):
parse_input_config(raw)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from pathlib import Path
import pytest
from yovision_brain.config import parse_input_config
from yovision_brain.decode import DecoderError, DecoderPipeline, decode_packets
from yovision_brain.input import CancellationToken, InputPacket, LocalFileInput, SyntheticInput
def synthetic_packets():
config = parse_input_config(
{
"schema": "brain.internal.input/v1",
"logical_device_id": "synthetic-01",
"profile": {"id": "main", "width": 2, "height": 1, "fps": 5},
"source": {"kind": "synthetic", "seed": 3, "frame_count": 2},
}
)
return SyntheticInput(config).packets()
def local_packets(path: Path, *, width: int = 2, height: int = 1, chunk_size: int = 5):
config = parse_input_config(
{
"schema": "brain.internal.input/v1",
"logical_device_id": "local-01",
"profile": {"id": "archive", "width": width, "height": height, "fps": 25},
"source": {"kind": "local_file", "path": str(path), "chunk_size": chunk_size},
}
)
return LocalFileInput(config).packets()
def test_rgb24_pipeline_preserves_order_timestamps_and_metadata() -> None:
frames = list(decode_packets(synthetic_packets()))
assert [frame.sequence for frame in frames] == [0, 1]
assert [frame.timestamp_ns for frame in frames] == [0, 200_000_000]
assert all(frame.logical_device_id == "synthetic-01" for frame in frames)
assert all(frame.profile_id == "main" for frame in frames)
assert all((frame.width, frame.height, frame.pixel_format) == (2, 1, "rgb24") for frame in frames)
def test_rgb24_dimension_change_is_explicit() -> None:
packets = [
InputPacket(0, 0, "camera", "main", 1, 1, "rgb24", b"abc"),
InputPacket(1, 1, "camera", "main", 2, 1, "rgb24", b"abcdef"),
]
frames = list(decode_packets(packets))
assert [frame.dimensions_changed for frame in frames] == [False, True]
def test_invalid_rgb_payload_and_unsupported_format_are_clear() -> None:
bad = [InputPacket(0, 0, "camera", "main", 2, 2, "rgb24", b"short")]
with pytest.raises(DecoderError, match="expected 12"):
list(decode_packets(bad))
unknown = [InputPacket(0, 0, "camera", "main", 1, 1, "opaque", b"data")]
with pytest.raises(DecoderError, match="no decoder registered"):
list(DecoderPipeline().decode(unknown))
def test_y4m_local_video_decodes_across_input_chunks(tmp_path: Path) -> None:
video = tmp_path / "anonymous.y4m"
first, second = b"abcdef", b"ghijkl"
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\nFRAME\n" + first + b"FRAME\n" + second)
frames = list(decode_packets(local_packets(video)))
assert [frame.payload for frame in frames] == [first, second]
assert [frame.timestamp_ns for frame in frames] == [0, 40_000_000]
assert all(frame.pixel_format == "yuv444p" for frame in frames)
assert all((frame.width, frame.height) == (2, 1) for frame in frames)
assert all(frame.profile_id == "archive" for frame in frames)
def test_y4m_clean_eof_and_cancellation_are_normal(tmp_path: Path) -> None:
video = tmp_path / "empty.y4m"
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\n")
assert list(decode_packets(local_packets(video))) == []
token = CancellationToken()
token.cancel()
assert list(decode_packets(local_packets(video), token)) == []
@pytest.mark.parametrize(
("payload", "message"),
[
(b"not-video\n", "expected YUV4MPEG2"),
(b"YUV4MPEG2 W2 H1 F25:1 C420\n", "only C444"),
(b"YUV4MPEG2 W2 H1 F25:1 C444\nFRAME\nabc", "truncated Y4M frame"),
],
)
def test_y4m_damage_and_unsupported_content_are_clear(tmp_path: Path, payload: bytes, message: str) -> None:
video = tmp_path / "broken.y4m"
video.write_bytes(payload)
with pytest.raises(DecoderError, match=message):
list(decode_packets(local_packets(video)))
def test_y4m_profile_dimension_mismatch_is_rejected(tmp_path: Path) -> None:
video = tmp_path / "mismatch.y4m"
video.write_bytes(b"YUV4MPEG2 W2 H1 F25:1 C444\n")
with pytest.raises(DecoderError, match="do not match"):
list(decode_packets(local_packets(video, width=3)))
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import io
import json
from yovision_brain.events import JsonLinesSink, candidate_from_decision
from yovision_brain.rules import NormalizedPoint, RuleDecision
from yovision_brain.vision import BoundingBox, DetectorMetadata, TrackedObject
def test_internal_event_id_is_stable_and_payload_is_safe() -> None:
track = TrackedObject("track-000001", BoundingBox(1, 2, 3, 4), "anonymous_target", 1.0, 7, 123)
decision = RuleDecision("yard", "danger_area", track.track_id, "entered", True, "entered polygon", "rules-v1", "main", 10, 10, NormalizedPoint(0.2, 0.4), 123)
metadata = DetectorMetadata("detector", "1", "first-party", "no external weights", "none")
first = candidate_from_decision(decision, track, logical_input_id="camera-01", detector=metadata)
second = candidate_from_decision(decision, track, logical_input_id="camera-01", detector=metadata)
assert first == second
assert first.event_id.startswith("brain-local-")
payload = json.dumps(first.to_dict())
for forbidden in ("password", "rtsp://", "evidence", "face"):
assert forbidden not in payload.lower()
def test_json_lines_sink_writes_one_canonical_line() -> None:
track = TrackedObject("track-000001", BoundingBox(0, 0, 1, 1), "anonymous_target", 1.0, 0, 0)
decision = RuleDecision("yard", "danger_area", track.track_id, "entered", True, "entered", "v1", "main", 2, 2, NormalizedPoint(0.25, 0.5), 0)
candidate = candidate_from_decision(decision, track, logical_input_id="synthetic", detector=DetectorMetadata("d", "1", "first", "none", "none"))
stream = io.StringIO()
JsonLinesSink(stream).write(candidate)
assert json.loads(stream.getvalue())["schema"] == "brain.internal.event-candidate/v1"
+5
View File
@@ -0,0 +1,5 @@
# Brain decode fixtures
Decode tests generate tiny anonymous YUV4MPEG2 streams at runtime. Do not add
customer recordings, camera credentials, machine-specific codec paths, or
large model/media artifacts to this directory.
+5
View File
@@ -0,0 +1,5 @@
# Brain internal-event fixtures
These fixtures are synthetic and explicitly internal. They are not the future
Brain-to-Bell event contract and must not contain evidence references, customer
media, identities, credentials, or machine-specific paths.
+29
View File
@@ -0,0 +1,29 @@
{
"rules_version": "fixture-rules-v1",
"detector": {
"backend": "python",
"threshold": 0,
"minimum_area": 1
},
"input": {
"schema": "brain.internal.input/v1",
"logical_device_id": "synthetic-camera-01",
"profile": {
"id": "main",
"width": 4,
"height": 3,
"fps": 5
},
"source": {
"kind": "synthetic",
"seed": 17,
"frame_count": 3
},
"areas": [
{
"id": "full-frame-area",
"points": [[0, 0], [1, 0], [1, 1], [0, 1]]
}
]
}
}
+7
View File
@@ -0,0 +1,7 @@
# Brain input fixtures
This directory may contain only synthetic or anonymous fixtures. Do not add
customer video, camera credentials, personal data, or machine-specific paths.
The current tests generate their tiny local-file payload at runtime so the
repository does not carry a file that could be mistaken for customer media.
+5
View File
@@ -0,0 +1,5 @@
# Brain rule fixtures
Rule tests use normalized synthetic geometry and anonymous track IDs only. Do
not add customer site layouts, camera paths, identities, credentials, or a
copy of a future cross-project contract.
+5
View File
@@ -0,0 +1,5 @@
# Brain vision fixtures
Vision tests create anonymous geometric RGB frames in memory. Never add faces,
customer recordings, biometric templates, camera credentials, or unreviewed
model weights to this directory.
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from pathlib import Path
import pytest
from yovision_brain.config import parse_input_config
from yovision_brain.input import CancellationToken, InputError, LocalFileInput, SyntheticInput, build_input_source
def synthetic_config(seed: int = 9, frame_count: int = 3):
return parse_input_config(
{
"schema": "brain.internal.input/v1",
"logical_device_id": "camera-lab-01",
"profile": {"id": "main", "width": 3, "height": 2, "fps": 4},
"source": {"kind": "synthetic", "seed": seed, "frame_count": frame_count},
}
)
def local_config(path: Path, *, chunk_size: int = 4):
return parse_input_config(
{
"schema": "brain.internal.input/v1",
"logical_device_id": "local-video-01",
"profile": {"id": "archive", "width": 1920, "height": 1080, "fps": 25},
"source": {"kind": "local_file", "path": str(path), "chunk_size": chunk_size},
}
)
def test_synthetic_input_is_deterministic_and_carries_metadata() -> None:
first = list(SyntheticInput(synthetic_config()).packets())
second = list(build_input_source(synthetic_config()).packets())
different = list(SyntheticInput(synthetic_config(seed=10)).packets())
assert first == second
assert [packet.sequence for packet in first] == [0, 1, 2]
assert [packet.timestamp_ns for packet in first] == [0, 250_000_000, 500_000_000]
assert first[0].logical_device_id == "camera-lab-01"
assert first[0].profile_id == "main"
assert first[0].media_format == "rgb24"
assert len(first[0].payload) == 3 * 2 * 3
assert first[0].payload != different[0].payload
def test_synthetic_input_honors_cancellation() -> None:
token = CancellationToken()
packets = SyntheticInput(synthetic_config(frame_count=10)).packets(token)
assert next(packets).sequence == 0
token.cancel()
assert list(packets) == []
def test_local_file_input_reads_chunks_and_finishes_at_eof(tmp_path: Path) -> None:
video = tmp_path / "anonymous-fixture.bin"
video.write_bytes(b"abcdefghij")
source = LocalFileInput(local_config(video))
packets = list(source.packets())
assert source.source_label == "anonymous-fixture.bin"
assert [packet.payload for packet in packets] == [b"abcd", b"efgh", b"ij"]
assert [packet.sequence for packet in packets] == [0, 1, 2]
assert all(packet.timestamp_ns is None for packet in packets)
assert all(packet.media_format == "container-bytes" for packet in packets)
def test_local_file_input_honors_cancellation(tmp_path: Path) -> None:
video = tmp_path / "anonymous-fixture.bin"
video.write_bytes(b"abcdefghij")
token = CancellationToken()
packets = LocalFileInput(local_config(video)).packets(token)
assert next(packets).payload == b"abcd"
token.cancel()
assert list(packets) == []
def test_missing_file_error_is_actionable_without_absolute_path(tmp_path: Path) -> None:
missing = tmp_path / "missing-video.mp4"
with pytest.raises(InputError, match="local video source not found: missing-video.mp4") as caught:
list(LocalFileInput(local_config(missing)).packets())
assert str(tmp_path) not in str(caught.value)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import pytest
from yovision_brain.rules import (
AreaDefinition,
DirectionalLineDefinition,
NormalizedPoint,
RuleConfigError,
RuleEngine,
RuleSet,
)
from yovision_brain.vision import BoundingBox, TrackedObject
def point(x: float, y: float) -> NormalizedPoint:
return NormalizedPoint(x, y)
def rules() -> RuleSet:
return RuleSet(
version="rules-v7",
profile_id="main",
width=100,
height=100,
areas=(AreaDefinition("yard", (point(0.2, 0.2), point(0.8, 0.2), point(0.8, 0.8), point(0.2, 0.8))),),
directional_lines=(DirectionalLineDefinition("gate", point(0.5, 0.1), point(0.5, 0.9), "left_to_right", 0.01),),
)
def track(track_id: str, anchor_x: int, anchor_y: int, sequence: int = 0) -> TrackedObject:
return TrackedObject(track_id, BoundingBox(anchor_x - 1, anchor_y - 2, anchor_x + 1, anchor_y), "anonymous_target", 1.0, sequence, sequence)
def decisions(engine: RuleEngine, item: TrackedObject):
return engine.evaluate((item,), profile_id="main", width=100, height=100)
def test_area_outside_entered_inside_and_boundary() -> None:
engine = RuleEngine(rules())
assert decisions(engine, track("one", 10, 50))[0].state == "outside"
entered = decisions(engine, track("one", 20, 50, 1))[0]
assert (entered.state, entered.triggered) == ("entered", True)
inside = decisions(engine, track("one", 50, 50, 2))[0]
assert (inside.state, inside.triggered) == ("inside", False)
assert inside.config_version == "rules-v7"
def test_direction_and_reverse_crossing_are_distinct() -> None:
engine = RuleEngine(rules())
decisions(engine, track("one", 40, 50))
forward = decisions(engine, track("one", 60, 50, 1))[1]
assert (forward.state, forward.triggered) == ("triggered", True)
reverse_engine = RuleEngine(rules())
decisions(reverse_engine, track("two", 60, 50))
reverse = decisions(reverse_engine, track("two", 40, 50, 1))[1]
assert (reverse.state, reverse.triggered) == ("reverse_crossing", False)
def test_line_deadband_prevents_jitter_trigger() -> None:
engine = RuleEngine(rules())
decisions(engine, track("one", 40, 50))
on_line = decisions(engine, track("one", 50, 50, 1))[1]
assert (on_line.state, on_line.triggered) == ("on_line", False)
triggered = decisions(engine, track("one", 60, 50, 2))[1]
assert triggered.triggered is True
def test_profile_resolution_mismatch_is_rejected() -> None:
with pytest.raises(RuleConfigError, match="Profile/resolution"):
RuleEngine(rules()).evaluate((track("one", 20, 20),), profile_id="sub", width=100, height=100)
def test_invalid_polygon_line_and_duplicate_ids_are_rejected() -> None:
with pytest.raises(RuleConfigError, match="non-degenerate"):
RuleSet("v", "main", 10, 10, areas=(AreaDefinition("bad", (point(0, 0), point(0.5, 0.5), point(1, 1))),))
with pytest.raises(RuleConfigError, match="distinct endpoints"):
RuleSet("v", "main", 10, 10, directional_lines=(DirectionalLineDefinition("bad", point(0, 0), point(0, 0), "left_to_right"),))
with pytest.raises(RuleConfigError, match="unique"):
RuleSet("v", "main", 10, 10, areas=(AreaDefinition("same", (point(0, 0), point(1, 0), point(0, 1))),), directional_lines=(DirectionalLineDefinition("same", point(0, 0), point(1, 1), "left_to_right"),))
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import json
import subprocess
import sys
import pytest
from yovision_brain import __version__
from yovision_brain.__main__ import main, runtime_info, smoke
def test_package_version() -> None:
assert __version__ == "0.1.0"
def test_module_help_runs_without_other_products() -> None:
result = subprocess.run(
[sys.executable, "-m", "yovision_brain", "--help"],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0
assert "YoVision Brain runtime diagnostics" in result.stdout
def test_runtime_info_is_non_sensitive(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["--runtime-info"]) == 0
payload = json.loads(capsys.readouterr().out)
assert set(payload) == {
"cuda_available",
"cuda_build",
"cuda_device_count",
"python",
"torch_installed",
"torch_version",
}
assert payload == runtime_info()
def test_cpu_tensor_smoke() -> None:
pytest.importorskip("torch")
result = smoke("cpu")
assert result["status"] == "ok"
assert result["device"] == "cpu"
@@ -0,0 +1,69 @@
from __future__ import annotations
import pytest
from yovision_brain.decode import DecodedFrame
from yovision_brain.vision import (
BoundingBox,
Detection,
LumaBlobDetector,
SingleStreamTracker,
TorchLumaBlobDetector,
)
def frame(payload: bytes, *, sequence: int = 0, width: int = 4, height: int = 3) -> DecodedFrame:
return DecodedFrame(sequence, sequence * 40_000_000, "camera", "main", width, height, "rgb24", payload)
def rgb(values: list[int]) -> bytes:
return b"".join(bytes((value, value, value)) for value in values)
def detection(left: int, top: int, right: int, bottom: int) -> Detection:
return Detection(BoundingBox(left, top, right, bottom), "anonymous_target", 0.9)
def test_detector_emits_only_anonymous_observations() -> None:
payload = rgb([0, 255, 255, 0, 0, 255, 255, 0, 0, 0, 0, 0])
result = LumaBlobDetector(minimum_area=2).detect(frame(payload))
assert result == (Detection(BoundingBox(1, 0, 3, 2), "anonymous_target", 1.0),)
assert LumaBlobDetector.metadata.weights == "none"
assert "external model license" in LumaBlobDetector.metadata.license
def test_empty_frame_has_no_detection() -> None:
assert LumaBlobDetector().detect(frame(rgb([0] * 12))) == ()
def test_tracker_keeps_session_id_across_motion_and_short_occlusion() -> None:
tracker = SingleStreamTracker(iou_threshold=0.1, max_missed=2)
first = tracker.update((detection(0, 0, 3, 3),), frame_sequence=0, timestamp_ns=0)
assert first[0].track_id == "track-000001"
assert tracker.update((), frame_sequence=1, timestamp_ns=1) == ()
resumed = tracker.update((detection(1, 0, 4, 3),), frame_sequence=2, timestamp_ns=2)
assert resumed[0].track_id == "track-000001"
assert tracker.finish() == ("track-000001",)
def test_disappeared_track_ends_and_new_target_gets_new_id() -> None:
tracker = SingleStreamTracker(max_missed=1)
first = tracker.update((detection(0, 0, 2, 2),), frame_sequence=0, timestamp_ns=0)
tracker.update((), frame_sequence=1, timestamp_ns=1)
tracker.update((), frame_sequence=2, timestamp_ns=2)
second = tracker.update((detection(0, 0, 2, 2),), frame_sequence=3, timestamp_ns=3)
assert first[0].track_id == "track-000001"
assert second[0].track_id == "track-000002"
def test_track_ids_are_session_local() -> None:
one = SingleStreamTracker().update((detection(0, 0, 1, 1),), frame_sequence=0, timestamp_ns=0)
two = SingleStreamTracker().update((detection(0, 0, 1, 1),), frame_sequence=0, timestamp_ns=0)
assert one[0].track_id == two[0].track_id == "track-000001"
def test_torch_backend_cpu_smoke_uses_no_external_weights() -> None:
pytest.importorskip("torch")
result = TorchLumaBlobDetector().detect(frame(rgb([0, 255] + [0] * 10)))
assert result[0].category == "anonymous_target"
assert TorchLumaBlobDetector.metadata.weights == "none"
+46
View File
@@ -0,0 +1,46 @@
# Sense 独立纵切验收
本验收只启动 Sense、临时 PostgreSQL 17、测试用 ONVIF/RTSP 夹具和独立 MediaMTX;不启动 Brain 或 Bell。测试凭据在每次运行时随机生成,只通过子进程环境和系统临时目录传递,测试结束后清理。
## 自动化入口
从仓库根目录执行:
```powershell
& Sense\tests\compatibility\run-static-regression.ps1
& Sense\tests\e2e\run-isolated-e2e.ps1
```
隔离 E2E 默认使用 `D:\pgsql17\bin`、已审核的 `C:\Users\ila20\Desktop\mediamtx\mediamtx.exe` 和本机 Chrome。路径不同时使用参数显式指定。运行数据、数据库、构建副本、浏览器截图和日志全部位于系统临时目录;只有排错时才使用 `-KeepTemporary`,其中可能含运行时秘密,必须按敏感数据保护并及时清理。
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
## 回归矩阵
| 范围 | 自动化证据 | 判定 |
|---|---|---|
| GoAdmin 来源与复用 | 检查冻结 commit、MIT 文件、Cobra、迁移、Gin Router、JWT、动态 Router/Store/Axios/Layout/权限入口 | 必须通过 |
| 空白 PostgreSQL 17 | 临时集群执行完整迁移,核对迁移版本和 `capabilities` JSONB | 必须通过 |
| 登录与 RBAC | 一次性安全初始化、免验证码登录、未认证访问拒绝、管理员权限链 | 必须通过 |
| 中文与请求白名单 | 中文设备/安装位置/区域往返;未知凭据字段返回 400 | 必须通过 |
| 凭据边界 | ONVIF/RTSP 分用途写入;API、媒体路径、日志和数据库均不出现明文 | 必须通过 |
| ONVIF 与 RTSP | 测试 ONVIF 对每个 SOAP 操作强制并校验 Digest;RTSP OPTIONS 验证合成源 | 必须通过 |
| MediaMTX | 独立端口、回环 Control API、按需路径、合成视频消费者和安全播放地址 | 必须通过 |
| 实时监看 | 创建短期播放会话并验证同源 wrapper 与浏览器可达地址 | 必须通过 |
| 区域配置 | 多边形保存;Profile 分辨率变化后自动标记需要重校准 | 必须通过 |
| GoAdmin UI 外壳 | Chrome 验证侧栏、顶部导航、标签页和五个 Sense 页面;无无关入口 | 必须通过 |
| Windows 交付 | 临时副本按固定 Go/Node/pnpm 构建,生产启动、停止、冷启动路径恢复 | 必须通过 |
| 独立性 | 测试不连接 Brain/Bell,不写 `contracts/` 或根级部署 | 必须通过 |
## 历史迁移裁决
- 旧 `explore` 只提供需求与故障清单,不作为通过证据,也不复制其自研认证、RBAC、HTTP 外壳或业务实现。
- 中文绑定、严格请求字段、Digest、凭据分离、Media 回环地址、按需拉流、冷启动恢复、区域重校准和 Windows 包必须由当前 `dev` 新基线重新运行。
- 测试失败时保留失败证据并建立独立缺陷工单;本验收任务不修改产品实现以掩盖失败。
## 未验证边界
- 客户现场真实 ONVIF 摄像机型号、固件差异、网络丢包和时钟偏差。
- 客户全新 Windows 主机、生产数据库账号权限、防火墙和服务管理器。
- 16 路及更高并发、长时间稳定性、硬件解码与实际带宽;16 路只是默认交付配额,不是代码硬上限。
- Brain/Bell 契约、跨项目端到端事件和证据链,必须由后续协调工单验证。
@@ -0,0 +1,9 @@
[
{
"id": "secondary",
"name": "备用媒体分片",
"mode": "external",
"controlAPI": "http://127.0.0.1:19997",
"capacity": 24
}
]
+2
View File
@@ -13,6 +13,8 @@ SENSE_MEDIAMTX_MODE=disabled
SENSE_MEDIAMTX_BINARY=
SENSE_MEDIAMTX_CONFIG=
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
SENSE_MEDIAMTX_CAPACITY=
SENSE_MEDIAMTX_SHARDS_FILE=
SENSE_WEB_ROOT=web
SENSE_AUTO_MIGRATE=true
SENSE_POSTGRES_BIN=
+4
View File
@@ -13,6 +13,10 @@ SENSE_MEDIAMTX_MODE=managed
SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe
SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
# Leave capacity empty to use the current database quota. Optional extra
# shards are read from a repository-external JSON file and must use loopback APIs.
SENSE_MEDIAMTX_CAPACITY=
SENSE_MEDIAMTX_SHARDS_FILE=
SENSE_WEB_ROOT=web
SENSE_AUTO_MIGRATE=true
SENSE_POSTGRES_BIN=
+1
View File
@@ -81,6 +81,7 @@ try {
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.yml')
Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx-shards.example.json') -Destination (Join-Path $staging 'config\mediamtx-shards.example.json')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
Copy-Item -LiteralPath (Join-Path $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
+1 -1
View File
@@ -9,7 +9,7 @@ $required = @(
'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat',
'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env',
'config\sense.env.example', 'config\sense.demo.env',
'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql',
'config\mediamtx.yml', 'config\mediamtx-shards.example.json', 'config\db.sql', 'config\pg.sql',
'web\index.html', 'scripts\runtime\sense-common.ps1'
)
foreach ($relative in $required) {
+7 -1
View File
@@ -7,7 +7,8 @@ $script:SenseAllowedEnvironment = @(
'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP',
'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE',
'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG',
'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
'SENSE_MEDIAMTX_API', 'SENSE_MEDIAMTX_CAPACITY',
'SENSE_MEDIAMTX_SHARDS_FILE', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE',
'SENSE_POSTGRES_BIN'
)
@@ -185,6 +186,7 @@ function Initialize-SenseRuntime {
}
$mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY')
$mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG')
$mediaShardsFile = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_SHARDS_FILE')
if ($mediaMode -eq 'managed') {
if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' }
if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' }
@@ -192,6 +194,9 @@ function Initialize-SenseRuntime {
if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) {
throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)."
}
if (-not [string]::IsNullOrWhiteSpace($mediaShardsFile) -and -not (Test-Path -LiteralPath $mediaShardsFile -PathType Leaf)) {
throw 'SENSE_MEDIAMTX_SHARDS_FILE does not exist.'
}
$webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web')
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' }
@@ -205,6 +210,7 @@ function Initialize-SenseRuntime {
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process')
}
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process')
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_SHARDS_FILE', $mediaShardsFile, 'Process')
$runtimeDir = Join-Path $PackageRoot 'data\runtime'
$logDir = Join-Path $PackageRoot 'logs'
@@ -22,6 +22,7 @@ func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.POST("", api.Insert)
r.PUT("/:id", api.Update)
r.PUT("/:id/disable", api.Disable)
r.PUT("/:id/enable", api.Enable)
r.PUT("/:id/credentials", api.UpdateCredentials)
}
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/edge_node"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseEdgeNodeRouter) }
func registerSenseEdgeNodeRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &edge_node.API{}
r := v1.Group("/edge-nodes").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseLocalEventRouter) }
func registerSenseLocalEventRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &local_event.API{}
r := v1.Group("/local-events").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
}
@@ -0,0 +1,29 @@
package router
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestLocalEventRouterIsReadOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseLocalEventRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
routes := make(map[string]struct{})
for _, route := range engine.Routes() {
routes[route.Method+" "+route.Path] = struct{}{}
}
for _, expected := range []string{"GET /api/v1/local-events", "GET /api/v1/local-events/:id"} {
if _, ok := routes[expected]; !ok {
t.Fatalf("missing local event route: %s", expected)
}
}
for route := range routes {
if route != "GET /api/v1/local-events" && route != "GET /api/v1/local-events/:id" {
t.Fatalf("unexpected mutable local event route: %s", route)
}
}
}
@@ -0,0 +1,20 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media_shard"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseMediaShardRouter) }
func registerSenseMediaShardRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &media_shard.API{}
r := v1.Group("/media-shards").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
r.GET("/:id/migration-preflight", api.Preflight)
}
@@ -0,0 +1,20 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/operations"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseOperationsRouter) }
func registerSenseOperationsRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &operations.API{}
r := v1.Group("/operations").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
r.POST("/:id/retry", api.Retry)
}
@@ -0,0 +1,31 @@
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestSenseOperationsRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseOperationsRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
wanted := map[string]bool{
http.MethodGet + " /api/v1/operations": false,
http.MethodGet + " /api/v1/operations/:id": false,
http.MethodPost + " /api/v1/operations/:id/retry": false,
}
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
if _, ok := wanted[key]; ok {
wanted[key] = true
}
}
for route, found := range wanted {
if !found {
t.Fatalf("route not registered: %s", route)
}
}
}
@@ -0,0 +1,22 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/ops_alert"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseOpsAlertRouter) }
func registerSenseOpsAlertRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &ops_alert.API{}
r := v1.Group("/ops-alerts").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
r.POST("/evaluate", api.Evaluate)
r.POST("/:id/acknowledge", api.Acknowledge)
r.POST("/:id/recover", api.Recover)
}
@@ -0,0 +1,32 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"net/http"
"testing"
)
func TestSenseOpsAlertRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseOpsAlertRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
wanted := map[string]bool{
http.MethodGet + " /api/v1/ops-alerts": false,
http.MethodGet + " /api/v1/ops-alerts/:id": false,
http.MethodPost + " /api/v1/ops-alerts/evaluate": false,
http.MethodPost + " /api/v1/ops-alerts/:id/acknowledge": false,
http.MethodPost + " /api/v1/ops-alerts/:id/recover": false,
}
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
if _, ok := wanted[key]; ok {
wanted[key] = true
}
}
for route, found := range wanted {
if !found {
t.Fatalf("route not registered: %s", route)
}
}
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseOutboxRouter) }
func registerSenseOutboxRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &outbox.API{}
r := v1.Group("/outbox").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.GET("/:id", api.Get)
r.POST("/:id/requeue", api.Requeue)
}
@@ -0,0 +1,26 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"net/http"
"testing"
)
func TestSenseOutboxRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
registerSenseOutboxRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
wanted := map[string]bool{http.MethodGet + " /api/v1/outbox": false, http.MethodGet + " /api/v1/outbox/:id": false, http.MethodPost + " /api/v1/outbox/:id/requeue": false}
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
if _, ok := wanted[key]; ok {
wanted[key] = true
}
}
for route, found := range wanted {
if !found {
t.Fatalf("route not registered: %s", route)
}
}
}
@@ -0,0 +1,24 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/provisioning"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseProvisioningRouter) }
func registerSenseProvisioningRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &provisioning.API{}
r := v1.Group("/provisioning/batches").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.List)
r.POST("", api.Create)
r.GET("/:id", api.Get)
r.POST("/:id/execute", api.Execute)
r.POST("/:id/retry-failed", api.RetryFailed)
r.POST("/:id/items/:itemId/retry", api.RetryItem)
r.GET("/:id/export", api.Export)
}
@@ -0,0 +1,19 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseQuotaRouter) }
func registerSenseQuotaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &quota.API{}
r := v1.Group("/quota").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("", api.Get)
r.PUT("", api.Update)
}
@@ -14,6 +14,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
type Device struct{ api.Api }
@@ -106,6 +107,25 @@ func (e Device) Disable(c *gin.Context) {
e.OK(response, "设备已停用")
}
func (e Device) Enable(c *gin.Context) {
service := deviceService.Device{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
return
}
req := dto.EnableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
if err := bindStrictJSON(c, &req); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
var response dto.DeviceResponse
if err := service.Enable(&req, &response); err != nil {
e.writeServiceError(err)
return
}
e.OK(response, "设备已启用,请重新完成视频接入验证")
}
func (e Device) UpdateCredentials(c *gin.Context) {
service := deviceService.Device{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
@@ -133,6 +153,10 @@ func (e Device) writeServiceError(err error) {
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, deviceService.ErrVersionConflict):
e.Error(http.StatusConflict, err, err.Error())
case errors.Is(err, quota.ErrExceeded):
e.Error(http.StatusConflict, err, "当前配额已满,无法新增或启用设备")
case errors.Is(err, quota.ErrUnavailable):
e.Error(http.StatusServiceUnavailable, err, "配额配置不可读取,已拒绝新增或启用设备")
case errors.Is(err, credential.ErrKeyUnavailable):
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
default:
@@ -15,6 +15,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
var (
@@ -108,7 +109,12 @@ func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error
}
model.CreateBy = req.CreateBy
model.UpdateBy = req.CreateBy
if err = e.Orm.Create(&model).Error; err != nil {
if err = quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
return tx.Create(&model).Error
}); err != nil {
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) {
return err
}
return fmt.Errorf("create device: %w", err)
}
return e.Get(model.ID, response)
@@ -155,6 +161,35 @@ func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) erro
return e.Get(req.ID, response)
}
func (e *Device) Enable(req *dto.EnableReq, response *dto.DeviceResponse) error {
if req.Version < 1 {
return ErrInvalidDevice
}
err := quota.WithAvailableSlot(e.Orm, func(tx *gorm.DB) error {
result := tx.Model(&models.Device{}).
Where("id = ? AND version = ? AND status = ?", req.ID, req.Version, models.StatusDisabled).
Updates(map[string]any{
"status": models.StatusPending,
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return e.notFoundOrConflictWith(tx, req.ID)
}
return nil
})
if err != nil {
if errors.Is(err, quota.ErrUnavailable) || errors.Is(err, quota.ErrExceeded) ||
errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
return err
}
return fmt.Errorf("enable device: %w", err)
}
return e.Get(req.ID, response)
}
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
return ErrInvalidDevice
@@ -13,6 +13,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
@@ -21,7 +22,10 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}, &quota.Setting{}, &quota.Change{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&quota.Setting{ID: quota.SettingID, Limit: 32, Source: "test", Version: 1}).Error; err != nil {
t.Fatal(err)
}
key := make([]byte, 32)
@@ -36,6 +40,38 @@ func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
return service, db
}
func TestCreateAndEnableUseQuotaSafetyGate(t *testing.T) {
service, db := testDeviceService(t)
if err := db.Model(&quota.Setting{}).Where("id = ?", quota.SettingID).Update("limit", 1).Error; err != nil {
t.Fatal(err)
}
var first dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第一路", Modality: "video", Capabilities: []string{"video"}}, &first); err != nil {
t.Fatal(err)
}
var rejected dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrExceeded {
t.Fatalf("second create error=%v", err)
}
var disabled dto.DeviceResponse
if err := service.Disable(&dto.DisableReq{ID: first.ID, Version: first.Version}, &disabled); err != nil {
t.Fatal(err)
}
var second dto.DeviceResponse
if err := service.Insert(&dto.CreateReq{Name: "第二路", Modality: "video", Capabilities: []string{"video"}}, &second); err != nil {
t.Fatal(err)
}
if err := service.Enable(&dto.EnableReq{ID: disabled.ID, Version: disabled.Version}, &rejected); err != quota.ErrExceeded {
t.Fatalf("enable over quota error=%v", err)
}
if err := db.Delete(&quota.Setting{}, quota.SettingID).Error; err != nil {
t.Fatal(err)
}
if err := service.Insert(&dto.CreateReq{Name: "第三路", Modality: "video", Capabilities: []string{"video"}}, &rejected); err != quota.ErrUnavailable {
t.Fatalf("create with unreadable quota error=%v", err)
}
}
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
service, _ := testDeviceService(t)
var created dto.DeviceResponse
@@ -36,6 +36,12 @@ type DisableReq struct {
UpdateBy int `json:"-"`
}
type EnableReq struct {
ID string `json:"-"`
Version int64 `json:"version"`
UpdateBy int `json:"-"`
}
type CredentialUpdateReq struct {
ID string `json:"-"`
ONVIFUsername string `json:"onvifUsername"`
+81
View File
@@ -0,0 +1,81 @@
package edge_node
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"git.ilapage.cn/ila/yovision/Sense/server/common"
)
const auditSuccess, auditFailure = "1", "2"
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return NewService(base.Orm), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := PageRequest{}
if err = e.MakeContext(c).Bind(&request).Errors; err != nil {
e.audit(c, service, "List", auditFailure, "边缘节点查询条件格式不正确")
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
response, err := service.List(request)
if err != nil {
e.audit(c, service, "List", auditFailure, "边缘节点列表查询失败")
e.writeError(err)
return
}
e.audit(c, service, "List", auditSuccess, "读取边缘节点列表")
e.OK(response, "查询成功")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.Get(c.Param("id"))
if err != nil {
e.audit(c, service, "Get", auditFailure, "边缘节点详情查询失败")
e.writeError(err)
return
}
e.audit(c, service, "Get", auditSuccess, "读取边缘节点详情 "+item.ID)
e.OK(item, "查询成功")
}
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
if err := WriteAudit(service.Orm, Audit{Action: action, Method: c.Request.Method, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c), ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now()}); err != nil {
api.GetRequestLogger(c).Errorf("edge node audit failed: %s", err.Error())
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidQuery):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrNotFound):
e.Error(http.StatusNotFound, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "边缘节点查询失败")
}
}
+21
View File
@@ -0,0 +1,21 @@
package edge_node
import (
"time"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
)
type Audit struct {
Action, Method, Status, Username, ClientIP, Route, Remark string
UserID int
At time.Time
}
func WriteAudit(db *gorm.DB, input Audit) error {
model := adminModels.SysOperaLog{Title: "边缘节点", BusinessType: "other", Method: "edge_node.API." + input.Action, RequestMethod: input.Method, OperatorType: "1", OperName: input.Username, OperUrl: input.Route, OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark, CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC()}
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
return db.Create(&model).Error
}
+65
View File
@@ -0,0 +1,65 @@
package edge_node
import (
"time"
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
const HeartbeatTimeout = 90 * time.Second
type PageRequest struct {
commonDto.Pagination `search:"-"`
}
type Summary struct {
Total int `json:"total"`
Online int `json:"online"`
Offline int `json:"offline"`
Recovering int `json:"recovering"`
LoadUsed int `json:"loadUsed"`
LoadCapacity int `json:"loadCapacity"`
BackfillQueueDepth int `json:"backfillQueueDepth"`
HeartbeatTimeout int64 `json:"heartbeatTimeoutSeconds"`
}
type Response struct {
ID string `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
Status string `json:"status"`
RuntimeVersion string `json:"runtimeVersion"`
ModelVersion string `json:"modelVersion"`
StartedAt time.Time `json:"startedAt"`
UptimeSeconds int64 `json:"uptimeSeconds"`
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
LastCollectedAt time.Time `json:"lastCollectedAt"`
FreshnessSeconds int64 `json:"freshnessSeconds"`
Stale bool `json:"stale"`
LoadUsed int `json:"loadUsed"`
LoadCapacity int `json:"loadCapacity"`
ControlTunnelStatus string `json:"controlTunnelStatus"`
ControlTunnelDetail string `json:"controlTunnelDetail"`
ControlLatencyMs int `json:"controlLatencyMs"`
VideoPlaneStatus string `json:"videoPlaneStatus"`
VideoPlaneDetail string `json:"videoPlaneDetail"`
ActiveStreamCount int `json:"activeStreamCount"`
BackfillStatus string `json:"backfillStatus"`
BackfillQueueDepth int `json:"backfillQueueDepth"`
RecoveryPhase string `json:"recoveryPhase"`
ProjectionVersion int64 `json:"projectionVersion"`
Events []Event `json:"events,omitempty"`
}
type PageResponse struct {
Summary Summary `json:"summary"`
List []Response `json:"list"`
Count int64 `json:"count"`
}
// HeartbeatSample is an internal Sense adapter input. It is intentionally not
// exposed as an HTTP contract by this task.
type HeartbeatSample struct {
Node
CollectedAt time.Time
}
@@ -0,0 +1,20 @@
package edge_node
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SeedSyntheticFixture is explicit test/development data. Production startup
// and migrations never call it.
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
now = now.UTC().Truncate(time.Second)
items := []Node{
{ID: "SEN-EDGE-01", Name: "主楼边缘节点", Location: "主楼机房", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-7 * 24 * time.Hour), LastHeartbeatAt: now.Add(-18 * time.Second), LastCollectedAt: now.Add(-18 * time.Second), LoadUsed: 6, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道正常", ControlLatencyMs: 24, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "6 路视频正常", ActiveStreamCount: 6, BackfillStatus: BackfillIdle, ProjectionVersion: 1},
{ID: "SEN-EDGE-02", Name: "仓库边缘节点", Location: "仓库弱电间", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-3 * 24 * time.Hour), LastHeartbeatAt: now.Add(-4 * time.Minute), LastCollectedAt: now.Add(-4 * time.Minute), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "最后已知:控制隧道正常", ControlLatencyMs: 31, VideoPlaneStatus: ChannelReady, VideoPlaneDetail: "最后已知:4 路视频正常", ActiveStreamCount: 4, BackfillStatus: BackfillPending, BackfillQueueDepth: 12, ProjectionVersion: 1},
{ID: "SEN-EDGE-03", Name: "南门边缘节点", Location: "南门岗亭", RuntimeVersion: "sense-edge 0.1.0", ModelVersion: "detector-v3", StartedAt: now.Add(-10 * time.Hour), LastHeartbeatAt: now.Add(-12 * time.Second), LastCollectedAt: now.Add(-12 * time.Second), LoadUsed: 2, LoadCapacity: 8, ControlTunnelStatus: ChannelReady, ControlTunnelDetail: "控制隧道已恢复", ControlLatencyMs: 46, VideoPlaneStatus: ChannelUnavailable, VideoPlaneDetail: "视频数据面恢复中", ActiveStreamCount: 0, BackfillStatus: BackfillPending, BackfillQueueDepth: 5, RecoveryPhase: "video_reconnecting", ProjectionVersion: 2},
}
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
}
@@ -0,0 +1,61 @@
package edge_node
import (
"time"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
const (
StatusOnline = "online"
StatusOffline = "offline"
StatusRecovering = "recovering"
ChannelReady = "ready"
ChannelUnavailable = "unavailable"
BackfillIdle = "idle"
BackfillPending = "pending"
)
// Node is the Sense-owned last-known projection of one edge runtime. It is
// deliberately not a shared identity or a cross-product protocol model.
type Node struct {
ID string `gorm:"size:64;primaryKey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Location string `gorm:"size:256;not null;default:''" json:"location"`
RuntimeVersion string `gorm:"size:64;not null;default:''" json:"runtimeVersion"`
ModelVersion string `gorm:"size:64;not null;default:''" json:"modelVersion"`
StartedAt time.Time `gorm:"not null" json:"startedAt"`
LastHeartbeatAt time.Time `gorm:"not null;index" json:"lastHeartbeatAt"`
LastCollectedAt time.Time `gorm:"not null" json:"lastCollectedAt"`
LoadUsed int `gorm:"not null;default:0" json:"loadUsed"`
LoadCapacity int `gorm:"not null;default:0" json:"loadCapacity"`
ControlTunnelStatus string `gorm:"size:32;not null;default:'unavailable'" json:"controlTunnelStatus"`
ControlTunnelDetail string `gorm:"size:256;not null;default:''" json:"controlTunnelDetail"`
ControlLatencyMs int `gorm:"not null;default:0" json:"controlLatencyMs"`
VideoPlaneStatus string `gorm:"size:32;not null;default:'unavailable'" json:"videoPlaneStatus"`
VideoPlaneDetail string `gorm:"size:256;not null;default:''" json:"videoPlaneDetail"`
ActiveStreamCount int `gorm:"not null;default:0" json:"activeStreamCount"`
BackfillStatus string `gorm:"size:32;not null;default:'idle'" json:"backfillStatus"`
BackfillQueueDepth int `gorm:"not null;default:0" json:"backfillQueueDepth"`
RecoveryPhase string `gorm:"size:32;not null;default:''" json:"recoveryPhase"`
ProjectionVersion int64 `gorm:"not null;default:1" json:"projectionVersion"`
common.ControlBy
common.ModelTime
}
func (Node) TableName() string { return "sense_edge_nodes" }
// Event records projection state transitions without storing credentials or
// raw heartbeat payloads.
type Event struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
NodeID string `gorm:"size:64;not null;index" json:"nodeId"`
EventType string `gorm:"size:32;not null;index" json:"eventType"`
FromStatus string `gorm:"size:32;not null;default:''" json:"fromStatus"`
ToStatus string `gorm:"size:32;not null" json:"toStatus"`
Detail string `gorm:"size:256;not null;default:''" json:"detail"`
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
}
func (Event) TableName() string { return "sense_edge_node_events" }
+167
View File
@@ -0,0 +1,167 @@
package edge_node
import (
"context"
"errors"
"fmt"
"strings"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInvalidQuery = errors.New("边缘节点查询条件不符合要求")
ErrNotFound = errors.New("边缘节点不存在")
ErrInvalidSample = errors.New("边缘节点心跳样本不符合要求")
)
type Service struct {
coreService.Service
Now func() time.Time
}
func NewService(db *gorm.DB) *Service {
return &Service{Service: coreService.Service{Orm: db}, Now: time.Now}
}
func (s *Service) List(request PageRequest) (PageResponse, error) {
if request.GetPageSize() > 100 {
return PageResponse{}, ErrInvalidQuery
}
var count int64
if err := s.Orm.Model(&Node{}).Count(&count).Error; err != nil {
return PageResponse{}, fmt.Errorf("count edge nodes: %w", err)
}
var models []Node
if err := s.Orm.Order("name ASC, id ASC").Limit(request.GetPageSize()).Offset((request.GetPageIndex() - 1) * request.GetPageSize()).Find(&models).Error; err != nil {
return PageResponse{}, fmt.Errorf("list edge nodes: %w", err)
}
result := PageResponse{List: make([]Response, 0, len(models)), Count: count}
result.Summary.HeartbeatTimeout = int64(HeartbeatTimeout / time.Second)
var all []Node
if err := s.Orm.Find(&all).Error; err != nil {
return PageResponse{}, fmt.Errorf("summarize edge nodes: %w", err)
}
result.Summary.Total = len(all)
for _, item := range all {
status := s.status(item)
switch status {
case StatusOffline:
result.Summary.Offline++
case StatusRecovering:
result.Summary.Recovering++
default:
result.Summary.Online++
}
result.Summary.LoadUsed += item.LoadUsed
result.Summary.LoadCapacity += item.LoadCapacity
result.Summary.BackfillQueueDepth += item.BackfillQueueDepth
}
for _, item := range models {
result.List = append(result.List, s.response(item, nil))
}
return result, nil
}
func (s *Service) Get(id string) (Response, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return Response{}, ErrInvalidQuery
}
var model Node
if err := s.Orm.First(&model, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Response{}, ErrNotFound
}
return Response{}, fmt.Errorf("get edge node: %w", err)
}
var events []Event
if err := s.Orm.Where("node_id = ?", id).Order("occurred_at DESC, id DESC").Limit(20).Find(&events).Error; err != nil {
return Response{}, fmt.Errorf("list edge node events: %w", err)
}
return s.response(model, events), nil
}
// ApplyHeartbeat updates the Sense-owned projection atomically and records
// only state transitions. Callers are internal adapters, not public APIs.
func (s *Service) ApplyHeartbeat(ctx context.Context, sample HeartbeatSample) error {
now := sample.CollectedAt.UTC()
if now.IsZero() {
now = s.now()
}
sample.ID = strings.TrimSpace(sample.ID)
if sample.ID == "" || len(sample.ID) > 64 || strings.TrimSpace(sample.Name) == "" || sample.LastHeartbeatAt.IsZero() || sample.StartedAt.IsZero() || sample.LoadUsed < 0 || sample.LoadCapacity < 0 || sample.LoadUsed > sample.LoadCapacity || sample.BackfillQueueDepth < 0 {
return ErrInvalidSample
}
sample.LastHeartbeatAt = sample.LastHeartbeatAt.UTC()
sample.LastCollectedAt = now
return s.Orm.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var previous Node
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&previous, "id = ?", sample.ID).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("lock edge node projection: %w", err)
}
from := ""
if err == nil {
from = statusAt(previous, now)
sample.CreatedAt = previous.CreatedAt
sample.ProjectionVersion = previous.ProjectionVersion + 1
} else {
sample.ProjectionVersion = 1
}
if err = tx.Save(&sample.Node).Error; err != nil {
return fmt.Errorf("save edge node projection: %w", err)
}
to := statusAt(sample.Node, now)
if from == to && from != "" {
return nil
}
if from == StatusOffline {
timeoutAt := previous.LastHeartbeatAt.UTC().Add(HeartbeatTimeout)
if err = tx.Create(&Event{NodeID: sample.ID, EventType: "heartbeat_timeout", FromStatus: StatusOnline, ToStatus: StatusOffline, Detail: "节点心跳超过 90 秒未更新", OccurredAt: timeoutAt}).Error; err != nil {
return err
}
}
eventType, detail := "heartbeat_received", "收到节点首次心跳"
if from == StatusOffline {
eventType, detail = "node_recovered", "节点心跳恢复,通道状态继续独立收敛"
} else if to == StatusRecovering {
eventType, detail = "recovery_started", "节点进入恢复阶段"
}
return tx.Create(&Event{NodeID: sample.ID, EventType: eventType, FromStatus: from, ToStatus: to, Detail: detail, OccurredAt: now}).Error
})
}
func (s *Service) response(model Node, events []Event) Response {
now := s.now()
freshness := int64(now.Sub(model.LastHeartbeatAt.UTC()).Seconds())
if freshness < 0 {
freshness = 0
}
uptime := int64(model.LastHeartbeatAt.UTC().Sub(model.StartedAt.UTC()).Seconds())
if uptime < 0 {
uptime = 0
}
return Response{ID: model.ID, Name: model.Name, Location: model.Location, Status: statusAt(model, now), RuntimeVersion: model.RuntimeVersion, ModelVersion: model.ModelVersion, StartedAt: model.StartedAt, UptimeSeconds: uptime, LastHeartbeatAt: model.LastHeartbeatAt, LastCollectedAt: model.LastCollectedAt, FreshnessSeconds: freshness, Stale: freshness > int64(HeartbeatTimeout/time.Second), LoadUsed: model.LoadUsed, LoadCapacity: model.LoadCapacity, ControlTunnelStatus: model.ControlTunnelStatus, ControlTunnelDetail: model.ControlTunnelDetail, ControlLatencyMs: model.ControlLatencyMs, VideoPlaneStatus: model.VideoPlaneStatus, VideoPlaneDetail: model.VideoPlaneDetail, ActiveStreamCount: model.ActiveStreamCount, BackfillStatus: model.BackfillStatus, BackfillQueueDepth: model.BackfillQueueDepth, RecoveryPhase: model.RecoveryPhase, ProjectionVersion: model.ProjectionVersion, Events: events}
}
func (s *Service) status(model Node) string { return statusAt(model, s.now()) }
func (s *Service) now() time.Time {
if s.Now != nil {
return s.Now().UTC()
}
return time.Now().UTC()
}
func statusAt(model Node, now time.Time) string {
if now.UTC().Sub(model.LastHeartbeatAt.UTC()) > HeartbeatTimeout {
return StatusOffline
}
if strings.TrimSpace(model.RecoveryPhase) != "" && model.RecoveryPhase != "converged" {
return StatusRecovering
}
return StatusOnline
}
@@ -0,0 +1,103 @@
package edge_node
import (
"context"
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func edgeNodeTestService(t *testing.T) (*Service, *gorm.DB, time.Time) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Node{}, &Event{}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 28, 4, 0, 0, 0, time.UTC)
service := NewService(db)
service.Now = func() time.Time { return now }
return service, db, now
}
func TestListClassifiesOnlineOfflineAndRecoveringWithoutErasingLastKnownState(t *testing.T) {
service, db, now := edgeNodeTestService(t)
if err := SeedSyntheticFixture(db, now); err != nil {
t.Fatal(err)
}
page, err := service.List(PageRequest{})
if err != nil {
t.Fatal(err)
}
if page.Count != 3 || page.Summary.Online != 1 || page.Summary.Offline != 1 || page.Summary.Recovering != 1 || page.Summary.BackfillQueueDepth != 17 {
t.Fatalf("unexpected summary: %+v", page.Summary)
}
var offline Response
for _, item := range page.List {
if item.Status == StatusOffline {
offline = item
}
}
if !offline.Stale || offline.ControlTunnelStatus != ChannelReady || offline.VideoPlaneStatus != ChannelReady || offline.BackfillQueueDepth != 12 {
t.Fatalf("offline projection lost last-known state: %+v", offline)
}
}
func TestApplyHeartbeatRecordsRecoveryAndPreservesIndependentChannelConvergence(t *testing.T) {
service, db, now := edgeNodeTestService(t)
old := Node{ID: "EDGE-1", Name: "旧节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now.Add(-5 * time.Minute), LastCollectedAt: now.Add(-5 * time.Minute), LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelReady, BackfillStatus: BackfillPending, BackfillQueueDepth: 8, ProjectionVersion: 3}
if err := db.Create(&old).Error; err != nil {
t.Fatal(err)
}
sample := HeartbeatSample{Node: Node{ID: "EDGE-1", Name: "旧节点", StartedAt: old.StartedAt, LastHeartbeatAt: now.Add(-5 * time.Second), LoadUsed: 4, LoadCapacity: 16, ControlTunnelStatus: ChannelReady, VideoPlaneStatus: ChannelUnavailable, BackfillStatus: BackfillPending, BackfillQueueDepth: 3, RecoveryPhase: "video_reconnecting"}, CollectedAt: now}
if err := service.ApplyHeartbeat(context.Background(), sample); err != nil {
t.Fatal(err)
}
item, err := service.Get("EDGE-1")
if err != nil {
t.Fatal(err)
}
if item.Status != StatusRecovering || item.ProjectionVersion != 4 || item.VideoPlaneStatus != ChannelUnavailable || item.BackfillQueueDepth != 3 {
t.Fatalf("unexpected recovery projection: %+v", item)
}
if len(item.Events) != 2 || item.Events[0].EventType != "node_recovered" || item.Events[0].FromStatus != StatusOffline || item.Events[0].ToStatus != StatusRecovering || item.Events[1].EventType != "heartbeat_timeout" || item.Events[1].ToStatus != StatusOffline {
t.Fatalf("unexpected events: %+v", item.Events)
}
}
func TestApplyHeartbeatRejectsInvalidSampleAndDoesNotPersist(t *testing.T) {
service, db, now := edgeNodeTestService(t)
err := service.ApplyHeartbeat(context.Background(), HeartbeatSample{Node: Node{ID: "EDGE-2", Name: "节点", StartedAt: now.Add(-time.Hour), LastHeartbeatAt: now, LoadUsed: 17, LoadCapacity: 16}, CollectedAt: now})
if !errors.Is(err, ErrInvalidSample) {
t.Fatalf("err=%v", err)
}
var count int64
db.Model(&Node{}).Count(&count)
if count != 0 {
t.Fatalf("count=%d", count)
}
}
func TestGetRejectsMissingOrMalformedID(t *testing.T) {
service, _, _ := edgeNodeTestService(t)
if _, err := service.Get(""); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("err=%v", err)
}
if _, err := service.Get("missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err=%v", err)
}
}
func TestListRejectsOversizedPage(t *testing.T) {
service, _, _ := edgeNodeTestService(t)
request := PageRequest{}
request.PageSize = 101
if _, err := service.List(request); !errors.Is(err, ErrInvalidQuery) {
t.Fatalf("err=%v", err)
}
}
@@ -0,0 +1,82 @@
package local_event
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"git.ilapage.cn/ila/yovision/Sense/server/common"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
service := &Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
return nil, err
}
return service, nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
request := &PageRequest{}
if err = e.MakeContext(c).Bind(request).Errors; err != nil {
e.audit(c, service, "List", auditFailure, "本地事件查询条件格式不正确")
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
return
}
list, count, err := service.List(request)
if err != nil {
e.audit(c, service, "List", auditFailure, "本地事件列表查询失败")
e.writeError(err)
return
}
e.audit(c, service, "List", auditSuccess, "读取本地事件列表")
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.writeError(err)
return
}
item, err := service.Get(c.Param("id"))
if err != nil {
e.audit(c, service, "Get", auditFailure, "本地事件详情查询失败")
e.writeError(err)
return
}
e.audit(c, service, "Get", auditSuccess, "读取本地事件详情 "+item.ID)
e.OK(item, "查询成功")
}
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
err := WriteAccessAudit(service.Orm, AccessAudit{
Action: action, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c),
ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now(),
})
if err != nil {
api.GetRequestLogger(c).Errorf("local event access audit failed: %s", err.Error())
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidFilter):
e.Error(http.StatusBadRequest, err, ErrInvalidFilter.Error())
case errors.Is(err, ErrNotFound):
e.Error(http.StatusNotFound, err, ErrNotFound.Error())
default:
e.Error(http.StatusInternalServerError, err, "本地事件查询失败")
}
}
@@ -0,0 +1,38 @@
package local_event
import (
"time"
"gorm.io/gorm"
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
)
const (
auditSuccess = "1"
auditFailure = "2"
)
type AccessAudit struct {
Action string
Status string
Username string
UserID int
ClientIP string
Route string
Remark string
At time.Time
}
// WriteAccessAudit persists a minimal, synchronous GoAdmin operation record.
// It deliberately excludes query values, response bodies and evidence data.
func WriteAccessAudit(db *gorm.DB, input AccessAudit) error {
model := adminModels.SysOperaLog{
Title: "本地事件", BusinessType: "query", Method: "local_event.API." + input.Action,
RequestMethod: "GET", OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
}
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
return db.Create(&model).Error
}
+36
View File
@@ -0,0 +1,36 @@
package local_event
import (
"time"
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
)
type PageRequest struct {
commonDTO.Pagination `search:"-"`
CandidateState string `form:"candidateState"`
EvidenceState string `form:"evidenceState"`
RuleRef string `form:"ruleRef"`
Keyword string `form:"keyword"`
OccurredAfter string `form:"occurredAfter"`
OccurredBefore string `form:"occurredBefore"`
}
type Response struct {
ID string `json:"id"`
OccurredAt time.Time `json:"occurredAt"`
SourceRef string `json:"sourceRef"`
SourceLabel string `json:"sourceLabel"`
RuleRef string `json:"ruleRef"`
RuleName string `json:"ruleName"`
RuleVersion string `json:"ruleVersion"`
ZoneRef string `json:"zoneRef"`
CandidateState string `json:"candidateState"`
EvidenceState string `json:"evidenceState"`
EvidenceRef string `json:"evidenceRef,omitempty"`
EvidenceDetail string `json:"evidenceDetail,omitempty"`
RetainUntil time.Time `json:"retainUntil"`
RetentionState string `json:"retentionState"`
RemainingDays int `json:"remainingDays"`
CreatedAt time.Time `json:"createdAt"`
}
@@ -0,0 +1,20 @@
package local_event
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SeedSyntheticFixture explicitly installs anonymous development/test data.
// Production startup never calls this function.
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
now = now.UTC().Truncate(time.Second)
items := []EventCandidate{
{ID: "00000000-0000-4000-8000-000000000001", OccurredAt: now.Add(-18 * time.Minute), SourceRef: "SEN-CAM-03", SourceLabel: "东门通道", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EAST-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateSuccess, EvidenceRef: "local-evidence/fixture-001", EvidenceDetail: "已生成事件截图与短片索引", RetainUntil: now.Add(12 * 24 * time.Hour)},
{ID: "00000000-0000-4000-8000-000000000002", OccurredAt: now.Add(-23 * time.Minute), SourceRef: "SEN-CAM-08", SourceLabel: "仓库北区", RuleRef: "rule-person-stay", RuleName: "人员滞留", RuleVersion: "v2", ZoneRef: "ZONE-WAREHOUSE-02", CandidateState: CandidateStateConfirmed, EvidenceState: EvidenceStatePending, EvidenceDetail: "证据短片正在生成", RetainUntil: now.Add(12 * 24 * time.Hour)},
{ID: "00000000-0000-4000-8000-000000000003", OccurredAt: now.Add(-31 * time.Minute), SourceRef: "SEN-CAM-11", SourceLabel: "设备间入口", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EQUIPMENT-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, EvidenceDetail: "源视频片段暂不可用", RetainUntil: now.Add(2 * 24 * time.Hour)},
}
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
}

Some files were not shown because too many files have changed in this diff Show More