package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "os" "yovision/sense/internal/device" "yovision/sense/internal/store" ) type manifest struct { Site manifestSite `json:"site"` Devices []manifestDevice `json:"devices"` } type manifestSite struct { TenantID string `json:"tenant_id"` ID string `json:"id"` Name string `json:"name"` MaxVideoChannels int `json:"max_video_channels"` } type manifestDevice struct { ID string `json:"id"` TenantID string `json:"tenant_id"` SiteID string `json:"site_id"` SerialNumber string `json:"serial_number"` Name string `json:"name"` Capabilities []string `json:"capabilities"` EndpointRef string `json:"endpoint_ref"` CredentialRef string `json:"credential_ref"` PathName string `json:"path_name"` } func main() { if err := run(os.Args[1:], os.Stdout); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func run(args []string, output io.Writer) error { if len(args) == 0 { return errors.New("usage: sense-lab ") } switch args[0] { case "seed": return seed(args[1:], output) case "status": return status(args[1:], output) default: return fmt.Errorf("unknown command %q", args[0]) } } func seed(args []string, output io.Writer) error { flags := flag.NewFlagSet("seed", flag.ContinueOnError) flags.SetOutput(io.Discard) dsn := flags.String("db", "", "SQLite DSN") manifestPath := flags.String("manifest", "", "manifest JSON path") if err := flags.Parse(args); err != nil { return err } if *dsn == "" || *manifestPath == "" { return errors.New("seed requires -db and -manifest") } file, err := os.Open(*manifestPath) if err != nil { return fmt.Errorf("open manifest: %w", err) } defer file.Close() var value manifest decoder := json.NewDecoder(io.LimitReader(file, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { return fmt.Errorf("decode manifest: %w", err) } repository, err := store.OpenSQLite(context.Background(), *dsn) if err != nil { return err } defer repository.Close() ctx := context.Background() if err := repository.EnsureSite(ctx, device.Site{ TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name, MaxVideoChannels: value.Site.MaxVideoChannels, }); err != nil { return err } for _, input := range value.Devices { capabilities := make([]device.Capability, 0, len(input.Capabilities)) for _, capability := range input.Capabilities { capabilities = append(capabilities, device.Capability(capability)) } if err := repository.CreateDevice(ctx, device.Device{ ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID, SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo, Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending, EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName, }); err != nil { return fmt.Errorf("create device %s: %w", input.ID, err) } } return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)}) } func status(args []string, output io.Writer) error { flags := flag.NewFlagSet("status", flag.ContinueOnError) flags.SetOutput(io.Discard) dsn := flags.String("db", "", "SQLite DSN") expect := flags.Int("expect", -1, "expected device count") requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero") if err := flags.Parse(args); err != nil { return err } if *dsn == "" { return errors.New("status requires -db") } repository, err := store.OpenSQLite(context.Background(), *dsn) if err != nil { return err } defer repository.Close() snapshot, err := repository.ConvergenceSnapshot(context.Background()) if err != nil { return err } if err := json.NewEncoder(output).Encode(snapshot); err != nil { return err } if *expect >= 0 && snapshot.Total != *expect { return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total) } if *requireConverged && snapshot.Unconverged != 0 { return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged) } return nil }