chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user