feat: add versioned Sense areas and directional lines (#27)
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var activeService atomic.Pointer[Service]
|
||||
|
||||
// ExportCurrent is a project-internal adapter port for a future versioned
|
||||
// contract mapper. The internal Area type is not a shared Brain contract.
|
||||
func ExportCurrent(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return nil, fmt.Errorf("area service is not ready")
|
||||
}
|
||||
return service.List(ctx, deviceID)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const epsilon = 1e-9
|
||||
|
||||
func ValidateGeometry(kind, direction string, points []Point) error {
|
||||
for _, point := range points {
|
||||
if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
|
||||
return fmt.Errorf("坐标必须位于画面范围内")
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindPolygon:
|
||||
if len(points) < 3 {
|
||||
return fmt.Errorf("多边形至少需要 3 个点")
|
||||
}
|
||||
if math.Abs(polygonArea(points)) < epsilon {
|
||||
return fmt.Errorf("多边形不能退化为直线")
|
||||
}
|
||||
if selfIntersects(points, true) {
|
||||
return fmt.Errorf("多边形不能自交")
|
||||
}
|
||||
case KindLine:
|
||||
if len(points) != 2 {
|
||||
return fmt.Errorf("警戒线必须恰好包含 2 个点")
|
||||
}
|
||||
if distance(points[0], points[1]) < epsilon {
|
||||
return fmt.Errorf("警戒线两点不能重合")
|
||||
}
|
||||
if direction != DirectionAToB && direction != DirectionBToA && direction != DirectionBoth {
|
||||
return fmt.Errorf("警戒线方向不正确")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("区域类型不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func polygonArea(points []Point) float64 {
|
||||
sum := 0.0
|
||||
for i := range points {
|
||||
next := (i + 1) % len(points)
|
||||
sum += points[i].X*points[next].Y - points[next].X*points[i].Y
|
||||
}
|
||||
return sum / 2
|
||||
}
|
||||
func distance(a, b Point) float64 { dx, dy := a.X-b.X, a.Y-b.Y; return dx*dx + dy*dy }
|
||||
func selfIntersects(points []Point, closed bool) bool {
|
||||
segments := len(points) - 1
|
||||
if closed {
|
||||
segments = len(points)
|
||||
}
|
||||
for i := 0; i < segments; i++ {
|
||||
a1, a2 := points[i], points[(i+1)%len(points)]
|
||||
if distance(a1, a2) < epsilon {
|
||||
return true
|
||||
}
|
||||
for j := i + 1; j < segments; j++ {
|
||||
if j == i+1 || closed && i == 0 && j == segments-1 {
|
||||
continue
|
||||
}
|
||||
b1, b2 := points[j], points[(j+1)%len(points)]
|
||||
if segmentsIntersect(a1, a2, b1, b2) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func segmentsIntersect(a, b, c, d Point) bool {
|
||||
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
|
||||
return o1*o2 < 0 && o3*o4 < 0
|
||||
}
|
||||
func orientation(a, b, c Point) float64 { return (b.X-a.X)*(c.Y-a.Y) - (b.Y-a.Y)*(c.X-a.X) }
|
||||
@@ -0,0 +1,19 @@
|
||||
package area
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateGeometry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, kind, direction string
|
||||
points []Point
|
||||
valid bool
|
||||
}{{"polygon", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}, true}, {"self intersection", KindPolygon, "", []Point{{.1, .1}, {.9, .9}, {.9, .1}, {.1, .9}}, false}, {"outside", KindPolygon, "", []Point{{-.1, .1}, {.9, .1}, {.5, .8}}, false}, {"line", KindLine, DirectionAToB, []Point{{.1, .1}, {.9, .9}}, true}, {"line direction", KindLine, "", []Point{{.1, .1}, {.9, .9}}, false}, {"line degenerate", KindLine, DirectionBoth, []Point{{.1, .1}, {.1, .1}}, false}}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateGeometry(test.kind, test.direction, test.points)
|
||||
if (err == nil) != test.valid {
|
||||
t.Fatalf("err=%v valid=%v", err, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
type Module struct{ service *Service }
|
||||
|
||||
func NewModule(service *Service) *Module { return &Module{service: service} }
|
||||
func (m *Module) Register(app *platform.App) {
|
||||
activeService.Store(m.service)
|
||||
app.Handle("GET /api/v1/areas", identity.Require(identity.PermissionAreaRead, http.HandlerFunc(m.list)))
|
||||
app.Handle("POST /api/v1/areas", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.create)))
|
||||
app.Handle("PATCH /api/v1/areas/{id}", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.update)))
|
||||
app.Handle("POST /api/v1/areas/{id}/recalibrate", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.recalibrate)))
|
||||
app.Handle("POST /api/v1/areas/{id}/disable", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.disable)))
|
||||
}
|
||||
|
||||
type writeRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Direction string `json:"direction"`
|
||||
Points []Point `json:"points"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (m *Module) list(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.List(r.Context(), r.URL.Query().Get("device_id"))
|
||||
if err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
func (m *Module) create(w http.ResponseWriter, r *http.Request) {
|
||||
var req writeRequest
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Create(r.Context(), actor, req.DeviceID, req.ProfileToken, req.Name, req.Kind, req.Direction, req.Points)
|
||||
writeResult(w, item, err, http.StatusCreated)
|
||||
}
|
||||
func (m *Module) update(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, false) }
|
||||
func (m *Module) recalibrate(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, true) }
|
||||
func (m *Module) writeExisting(w http.ResponseWriter, r *http.Request, recalibrate bool) {
|
||||
var req writeRequest
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Direction, req.Points, req.Version, recalibrate)
|
||||
writeResult(w, item, err, http.StatusOK)
|
||||
}
|
||||
func (m *Module) disable(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version)
|
||||
writeResult(w, item, err, http.StatusOK)
|
||||
}
|
||||
func writeResult(w http.ResponseWriter, item Area, err error, status int) {
|
||||
if err != nil {
|
||||
code := "area_invalid"
|
||||
httpStatus := http.StatusBadRequest
|
||||
if err == ErrConflict {
|
||||
code = "version_conflict"
|
||||
httpStatus = http.StatusConflict
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
code = "area_not_found"
|
||||
httpStatus = http.StatusNotFound
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: httpStatus, Code: code, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
platform.WriteJSON(w, status, item)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package area
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);`
|
||||
@@ -0,0 +1,117 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
profile func(string, string) (admission.Profile, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
return &Service{store: store, profile: admission.VerifiedProfile, now: time.Now}
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, actor identity.Principal, deviceID, profileToken, name, kind, direction string, points []Point) (Area, error) {
|
||||
profile, err := s.profile(deviceID, profileToken)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if err := ValidateGeometry(kind, direction, points); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Area{}, fmt.Errorf("名称不能为空")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
item := Area{ID: newID(), DeviceID: deviceID, ProfileToken: profileToken, Width: profile.Width, Height: profile.Height, Name: name, Kind: kind, Direction: direction, Points: points, Enabled: true, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.Save(ctx, item, 0); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.create", item.ID, "success", map[string]any{"version": 1, "kind": kind})
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, direction string, points []Point, expected int64, recalibrate bool) (Area, error) {
|
||||
current, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if current.Version != expected {
|
||||
return Area{}, ErrConflict
|
||||
}
|
||||
profile, err := s.profile(current.DeviceID, current.ProfileToken)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
resolutionChanged := profile.Width != current.Width || profile.Height != current.Height
|
||||
if resolutionChanged && !recalibrate {
|
||||
current.RecalibrationRequired = true
|
||||
current.Version++
|
||||
current.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, current, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
if err := ValidateGeometry(current.Kind, direction, points); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
current.Name = strings.TrimSpace(name)
|
||||
current.Direction = direction
|
||||
current.Points = points
|
||||
current.Width = profile.Width
|
||||
current.Height = profile.Height
|
||||
current.RecalibrationRequired = false
|
||||
current.Version++
|
||||
current.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, current, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.publish", id, "success", map[string]any{"version": current.Version, "recalibrated": recalibrate})
|
||||
return current, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Area, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
if item.Version != expected {
|
||||
return Area{}, ErrConflict
|
||||
}
|
||||
item.Enabled = false
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, item, expected); err != nil {
|
||||
return Area{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "area.disable", id, "success", map[string]any{"version": item.Version})
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) List(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
items, err := s.store.List(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range items {
|
||||
if profile, profileErr := s.profile(items[i].DeviceID, items[i].ProfileToken); profileErr == nil && (profile.Width != items[i].Width || profile.Height != items[i].Height) {
|
||||
items[i].RecalibrationRequired = true
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "area_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
func TestVersionsConflictAndRecalibration(t *testing.T) {
|
||||
service := NewService(NewMemoryStore())
|
||||
width := 1920
|
||||
service.profile = func(string, string) (admission.Profile, error) {
|
||||
return admission.Profile{Width: width, Height: 1080}, nil
|
||||
}
|
||||
item, err := service.Create(context.Background(), identity.Principal{}, "device", "main", "区域", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := service.Update(context.Background(), identity.Principal{}, item.ID, "区域2", "", item.Points, item.Version, false)
|
||||
if err != nil || updated.Version != 2 {
|
||||
t.Fatalf("updated=%#v err=%v", updated, err)
|
||||
}
|
||||
if _, err := service.Update(context.Background(), identity.Principal{}, item.ID, "冲突", "", item.Points, item.Version, false); err != ErrConflict {
|
||||
t.Fatalf("conflict err=%v", err)
|
||||
}
|
||||
width = 1280
|
||||
flagged, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, updated.Version, false)
|
||||
if err != nil || !flagged.RecalibrationRequired {
|
||||
t.Fatalf("flagged=%#v err=%v", flagged, err)
|
||||
}
|
||||
recalibrated, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, flagged.Version, true)
|
||||
if err != nil || recalibrated.RecalibrationRequired || recalibrated.Width != 1280 {
|
||||
t.Fatalf("recalibrated=%#v err=%v", recalibrated, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("area not found")
|
||||
ErrConflict = errors.New("area version conflict")
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Save(context.Context, Area, int64) error
|
||||
Get(context.Context, string) (Area, error)
|
||||
List(context.Context, string) ([]Area, error)
|
||||
}
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
versions map[string][]Area
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{versions: map[string][]Area{}} }
|
||||
func (s *MemoryStore) Save(_ context.Context, item Area, expected int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
values := s.versions[item.ID]
|
||||
if len(values) == 0 {
|
||||
if expected != 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
} else if values[len(values)-1].Version != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
s.versions[item.ID] = append(values, item)
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Get(_ context.Context, id string) (Area, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
values := s.versions[id]
|
||||
if len(values) == 0 {
|
||||
return Area{}, ErrNotFound
|
||||
}
|
||||
return values[len(values)-1], nil
|
||||
}
|
||||
func (s *MemoryStore) List(_ context.Context, deviceID string) ([]Area, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := []Area{}
|
||||
for _, values := range s.versions {
|
||||
item := values[len(values)-1]
|
||||
if deviceID == "" || item.DeviceID == deviceID {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} }
|
||||
func (s *PostgresStore) Save(ctx context.Context, item Area, expected int64) error {
|
||||
tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current int64
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(max(version),0) FROM sense_area_versions WHERE id=$1`, item.ID).Scan(¤t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != expected {
|
||||
return ErrConflict
|
||||
}
|
||||
points, _ := json.Marshal(item.Points)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_area_versions(id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, item.ID, item.Version, item.DeviceID, item.ProfileToken, item.Width, item.Height, item.Name, item.Kind, item.Direction, points, item.Enabled, item.RecalibrationRequired, item.CreatedAt, item.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Area, error) {
|
||||
return scan(s.database.QueryRowContext(ctx, `SELECT id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE id=$1 ORDER BY version DESC LIMIT 1`, id))
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scan(row scanner) (Area, error) {
|
||||
var item Area
|
||||
var points []byte
|
||||
err := row.Scan(&item.ID, &item.Version, &item.DeviceID, &item.ProfileToken, &item.Width, &item.Height, &item.Name, &item.Kind, &item.Direction, &points, &item.Enabled, &item.RecalibrationRequired, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Area{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(points, &item.Points)
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) List(ctx context.Context, deviceID string) ([]Area, error) {
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT DISTINCT ON(id) id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE ($1='' OR device_id=$1) ORDER BY id,version DESC`, deviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Area{}
|
||||
for rows.Next() {
|
||||
item, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package area
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
KindPolygon = "polygon"
|
||||
KindLine = "line"
|
||||
DirectionAToB = "a_to_b"
|
||||
DirectionBToA = "b_to_a"
|
||||
DirectionBoth = "both"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
type Area struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Points []Point `json:"points"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int64 `json:"version"`
|
||||
RecalibrationRequired bool `json:"recalibration_required"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sense
|
||||
|
||||
import (
|
||||
"yovision.local/sense/app/sense/area"
|
||||
"yovision.local/sense/internal/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerModule(func(app *platform.App) error {
|
||||
var store area.Store
|
||||
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
|
||||
store = area.NewMemoryStore()
|
||||
} else {
|
||||
store = area.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081204, Name: "sense_area", SQL: area.MigrationSQL})
|
||||
area.NewModule(area.NewService(store)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));
|
||||
CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);
|
||||
|
||||
Reference in New Issue
Block a user