package device import ( "context" "encoding/json" "fmt" "time" "data-terminal-cloud/internal/deviceauth" "data-terminal-cloud/internal/firmwareversion" "github.com/jackc/pgx/v5/pgxpool" ) type BootstrapReport struct { FirmwareVersion string BoardRevision string RTCSDKVersion string Codec string SampleRate int Network string DeviceTime time.Time } type Binding struct { Bound bool `json:"bound"` HouseholdID *string `json:"household_id,omitempty"` SubjectID *string `json:"subject_id,omitempty"` SubjectName *string `json:"subject_name,omitempty"` } type Bootstrap struct { DeviceID string `json:"device_id"` Status string `json:"status"` Model string `json:"model"` HardwareRevision *string `json:"hardware_revision,omitempty"` MinimumSecurityVersion *string `json:"minimum_security_version,omitempty"` MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"` FirmwareCompatible bool `json:"firmware_compatible"` ConfigRevision int64 `json:"config_revision"` Binding Binding `json:"binding"` } type Configuration struct { DeviceID string `json:"device_id"` Revision int64 `json:"revision"` DesiredConfig json.RawMessage `json:"desired_config"` ReportedRevision *int64 `json:"reported_revision,omitempty"` Binding Binding `json:"binding"` } type Command struct { ID string `json:"id"` Type string `json:"type"` Payload json.RawMessage `json:"payload"` Status string `json:"status"` ExpiresAt time.Time `json:"expires_at"` DeliveryAttempts int `json:"delivery_attempts"` CreatedAt time.Time `json:"created_at"` } type CommandAcknowledgement struct { Status string ResultSummary map[string]any } type Repository struct{ pool *pgxpool.Pool } func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } func (r *Repository) ReportBootstrap(ctx context.Context, identity deviceauth.Identity, report BootstrapReport) (Bootstrap, error) { reportedConfig, err := json.Marshal(map[string]any{ "codec": report.Codec, "sample_rate": report.SampleRate, "network": report.Network, "device_time": report.DeviceTime.UTC().Format(time.RFC3339), }) if err != nil { return Bootstrap{}, fmt.Errorf("marshal device report: %w", err) } tag, err := r.pool.Exec(ctx, ` UPDATE device_twin SET firmware_version = $3, board_revision = NULLIF($4, ''), rtc_sdk_version = NULLIF($5, ''), reported_config = $6::jsonb, last_reported_at = now(), updated_at = now() WHERE device_id = $1::uuid AND tenant_id = $2::uuid`, identity.ID, identity.TenantID, report.FirmwareVersion, report.BoardRevision, report.RTCSDKVersion, reportedConfig, ) if err != nil { return Bootstrap{}, fmt.Errorf("update device bootstrap report: %w", err) } if tag.RowsAffected() != 1 { return Bootstrap{}, fmt.Errorf("device twin is missing") } return r.readBootstrap(ctx, identity) } func (r *Repository) GetConfiguration(ctx context.Context, identity deviceauth.Identity) (Configuration, error) { var result Configuration var householdID, subjectID, subjectName *string err := r.pool.QueryRow(ctx, ` SELECT d.public_id::text, dt.desired_revision, dt.desired_config, dt.reported_revision, h.public_id::text, s.public_id::text, s.preferred_name FROM device d JOIN device_twin dt ON dt.device_id = d.id AND dt.tenant_id = d.tenant_id LEFT JOIN device_binding b ON b.device_id = d.id AND b.tenant_id = d.tenant_id AND b.unbound_at IS NULL LEFT JOIN household h ON h.id = b.household_id AND h.tenant_id = b.tenant_id LEFT JOIN subject_profile s ON s.id = b.subject_id AND s.tenant_id = b.tenant_id WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid`, identity.ID, identity.TenantID, ).Scan(&result.DeviceID, &result.Revision, &result.DesiredConfig, &result.ReportedRevision, &householdID, &subjectID, &subjectName) if err != nil { return Configuration{}, fmt.Errorf("read device configuration: %w", err) } result.Binding = newBinding(householdID, subjectID, subjectName) return result, nil } func (r *Repository) PullCommands(ctx context.Context, identity deviceauth.Identity, limit int) ([]Command, error) { if limit < 1 || limit > 20 { limit = 10 } tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(context.Background()) if _, err := tx.Exec(ctx, `UPDATE device_command SET status='EXPIRED',completed_at=now(),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at<=now()`, identity.TenantID, identity.ID); err != nil { return nil, err } rows, err := tx.Query(ctx, `WITH selected AS (SELECT id FROM device_command WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now() ORDER BY created_at,id LIMIT $3 FOR UPDATE SKIP LOCKED) UPDATE device_command dc SET status=CASE WHEN dc.status='ACKNOWLEDGED' THEN 'ACKNOWLEDGED' ELSE 'DELIVERED' END,delivery_attempts=delivery_attempts+1,delivered_at=COALESCE(delivered_at,now()),version=version+1,updated_at=now() FROM selected WHERE dc.id=selected.id RETURNING dc.public_id::text,dc.command_type,dc.payload,dc.status,dc.expires_at,dc.delivery_attempts,dc.created_at`, identity.TenantID, identity.ID, limit) if err != nil { return nil, err } defer rows.Close() items := []Command{} for rows.Next() { var item Command if err := rows.Scan(&item.ID, &item.Type, &item.Payload, &item.Status, &item.ExpiresAt, &item.DeliveryAttempts, &item.CreatedAt); err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return items, nil } func (r *Repository) AcknowledgeCommand(ctx context.Context, identity deviceauth.Identity, publicID string, input CommandAcknowledgement) (Command, error) { resultJSON, err := json.Marshal(input.ResultSummary) if err != nil { return Command{}, err } tx, err := r.pool.Begin(ctx) if err != nil { return Command{}, err } defer tx.Rollback(context.Background()) var item Command var internalID string err = tx.QueryRow(ctx, `UPDATE device_command SET status=$4,result_summary=$5::jsonb,acknowledged_at=COALESCE(acknowledged_at,now()),completed_at=CASE WHEN $4 IN ('SUCCEEDED','FAILED') THEN COALESCE(completed_at,now()) ELSE completed_at END,version=version+1,updated_at=now() WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND device_id=$3::uuid AND expires_at>now() AND (($4='ACKNOWLEDGED' AND status='DELIVERED') OR ($4 IN ('SUCCEEDED','FAILED') AND status IN ('DELIVERED','ACKNOWLEDGED')) OR status=$4) RETURNING id::text,public_id::text,command_type,payload,status,expires_at,delivery_attempts,created_at`, publicID, identity.TenantID, identity.ID, input.Status, resultJSON).Scan(&internalID, &item.ID, &item.Type, &item.Payload, &item.Status, &item.ExpiresAt, &item.DeliveryAttempts, &item.CreatedAt) if err != nil { return Command{}, fmt.Errorf("acknowledge device command: %w", err) } assignmentStatus, progress := firmwareAssignmentProgress(input) if assignmentStatus != "" { if _, err := tx.Exec(ctx, `UPDATE firmware_assignment SET status=$2,progress_percent=COALESCE($3,progress_percent),last_result=$4::jsonb,last_reported_at=now(),updated_at=now() WHERE command_id=$1::uuid`, internalID, assignmentStatus, progress, resultJSON); err != nil { return Command{}, err } if err := recordFirmwareAssignmentEvent(ctx, tx, internalID, input.Status, assignmentStatus, progress, resultJSON); err != nil { return Command{}, err } if _, err := tx.Exec(ctx, `UPDATE firmware_rollout ro SET status=CASE WHEN EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status='FAILED') THEN 'PAUSED' ELSE 'COMPLETED' END,completed_at=CASE WHEN NOT EXISTS(SELECT 1 FROM firmware_assignment fa WHERE fa.rollout_id=ro.id AND fa.status NOT IN ('CONFIRMED','FAILED')) THEN now() ELSE completed_at END,updated_at=now() WHERE id IN (SELECT fa.rollout_id FROM firmware_assignment fa JOIN device_command dc ON dc.id=fa.command_id WHERE dc.public_id=$1::uuid) AND NOT EXISTS(SELECT 1 FROM firmware_assignment open WHERE open.rollout_id=ro.id AND open.status NOT IN ('CONFIRMED','FAILED'))`, publicID); err != nil { return Command{}, err } } if err := tx.Commit(ctx); err != nil { return Command{}, err } return item, nil } func firmwareAssignmentProgress(input CommandAcknowledgement) (string, *int) { if input.Status == "SUCCEEDED" { value := 100 return "CONFIRMED", &value } if input.Status == "FAILED" { return "FAILED", nil } if input.Status != "ACKNOWLEDGED" { return "", nil } phase, _ := input.ResultSummary["phase"].(string) status := "" switch phase { case "downloading": status = "DOWNLOADING" case "installing", "staging", "reboot_pending", "boot_verifying": status = "INSTALLING" default: return "", nil } value, ok := input.ResultSummary["progress_percent"].(float64) if !ok { return status, nil } progress := int(value) if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } return status, &progress } func (r *Repository) readBootstrap(ctx context.Context, identity deviceauth.Identity) (Bootstrap, error) { var result Bootstrap var householdID, subjectID, subjectName, currentFirmware *string err := r.pool.QueryRow(ctx, ` SELECT d.public_id::text, d.status, d.model, d.hardware_revision, d.minimum_security_version, d.minimum_firmware_version, dt.firmware_version, dt.desired_revision, h.public_id::text, s.public_id::text, s.preferred_name FROM device d JOIN device_twin dt ON dt.device_id = d.id AND dt.tenant_id = d.tenant_id LEFT JOIN device_binding b ON b.device_id = d.id AND b.tenant_id = d.tenant_id AND b.unbound_at IS NULL LEFT JOIN household h ON h.id = b.household_id AND h.tenant_id = b.tenant_id LEFT JOIN subject_profile s ON s.id = b.subject_id AND s.tenant_id = b.tenant_id WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid`, identity.ID, identity.TenantID, ).Scan(&result.DeviceID, &result.Status, &result.Model, &result.HardwareRevision, &result.MinimumSecurityVersion, &result.MinimumFirmwareVersion, ¤tFirmware, &result.ConfigRevision, &householdID, &subjectID, &subjectName) if err != nil { return Bootstrap{}, fmt.Errorf("read device bootstrap: %w", err) } result.Binding = newBinding(householdID, subjectID, subjectName) result.FirmwareCompatible = result.MinimumFirmwareVersion == nil || (currentFirmware != nil && firmwareversion.AtLeast(*currentFirmware, *result.MinimumFirmwareVersion)) return result, nil } func newBinding(householdID, subjectID, subjectName *string) Binding { return Binding{Bound: householdID != nil && subjectID != nil, HouseholdID: householdID, SubjectID: subjectID, SubjectName: subjectName} }