package device import ( "context" "encoding/json" "errors" "fmt" "net/http" "time" "data-terminal-cloud/internal/deviceauth" "data-terminal-cloud/internal/idempotency" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrTelemetryConfigAhead = errors.New("reported configuration is ahead of desired configuration") ErrTelemetryConversationMissing = errors.New("telemetry conversation is missing or belongs to another device") ErrTelemetryCommandConflict = errors.New("telemetry command result conflicts with command state") ) type TelemetryNetwork struct { Type string `json:"type"` RSSI *int `json:"rssi,omitempty"` } type TelemetryRTC struct { Joined bool `json:"joined"` Uplink bool `json:"uplink"` Downlink bool `json:"downlink"` } type TelemetryRecordingQueue struct { PendingSegments int `json:"pending_segments"` FailedSegments int `json:"failed_segments"` LastFailureCode string `json:"last_failure_code,omitempty"` LastFailureAt *time.Time `json:"last_failure_at,omitempty"` } type TelemetryStatus struct { FirmwareVersion string `json:"firmware_version"` ReportedConfigRevision int64 `json:"reported_config_revision"` Network TelemetryNetwork `json:"network"` RTC TelemetryRTC `json:"rtc"` RecordingQueue TelemetryRecordingQueue `json:"recording_queue"` ConversationStatus string `json:"conversation_status,omitempty"` } type TelemetryError struct { Code string `json:"code"` Severity string `json:"severity"` } type TelemetryCommandResult struct { CommandID string `json:"command_id"` Status string `json:"status"` ResultSummary map[string]any `json:"result_summary"` } type TelemetryEvent struct { EventKey string `json:"event_key"` SequenceNo int64 `json:"sequence_no"` OccurredAt time.Time `json:"occurred_at"` EventType string `json:"event_type"` ConversationID string `json:"conversation_id,omitempty"` Status *TelemetryStatus `json:"status,omitempty"` Error *TelemetryError `json:"error,omitempty"` CommandResult *TelemetryCommandResult `json:"command_result,omitempty"` } type TelemetryBatchInput struct { Events []TelemetryEvent `json:"events"` } type TelemetryConflict struct { EventKey string `json:"event_key"` SequenceNo int64 `json:"sequence_no"` Reason string `json:"reason"` } type TelemetryConflictError struct { Conflicts []TelemetryConflict } func (e *TelemetryConflictError) Error() string { return "telemetry event conflicts with stored event" } type TelemetryAction struct { Type string `json:"type"` ConversationID string `json:"conversation_id,omitempty"` } type TelemetryBatchResponse struct { AcceptedCount int `json:"accepted_count"` DuplicateCount int `json:"duplicate_count"` DesiredConfigRevision int64 `json:"desired_config_revision"` Actions []TelemetryAction `json:"actions"` } type TelemetryService struct { pool *pgxpool.Pool executor *idempotency.Executor } func NewTelemetryService(pool *pgxpool.Pool, executor *idempotency.Executor) (*TelemetryService, error) { if pool == nil || executor == nil { return nil, errors.New("telemetry database and idempotency executor are required") } return &TelemetryService{pool: pool, executor: executor}, nil } func (s *TelemetryService) Submit(ctx context.Context, identity deviceauth.Identity, idempotencyKey string, rawRequest []byte, input TelemetryBatchInput) (TelemetryBatchResponse, bool, error) { tenantID := identity.TenantID result, err := s.executor.Execute(ctx, idempotency.Request{ TenantID: &tenantID, ActorKey: "device:" + identity.PublicID, Scope: "POST /device/v2/telemetry:batch", Key: idempotencyKey, RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour, }, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) { response, submitErr := s.submit(operationContext, tx, identity, input) if submitErr != nil { return idempotency.Result{}, submitErr } body, marshalErr := json.Marshal(response) if marshalErr != nil { return idempotency.Result{}, marshalErr } resourceID := identity.ID return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil }) if err != nil { return TelemetryBatchResponse{}, false, err } var response TelemetryBatchResponse if err := json.Unmarshal(result.Body, &response); err != nil { return TelemetryBatchResponse{}, result.Replayed, fmt.Errorf("decode telemetry response: %w", err) } return response, result.Replayed, nil } func (s *TelemetryService) submit(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input TelemetryBatchInput) (TelemetryBatchResponse, error) { var desiredRevision int64 var reportedRevision *int64 if err := tx.QueryRow(ctx, `SELECT desired_revision,reported_revision FROM device_twin WHERE device_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&desiredRevision, &reportedRevision); err != nil { return TelemetryBatchResponse{}, fmt.Errorf("read device telemetry state: %w", err) } response := TelemetryBatchResponse{DesiredConfigRevision: desiredRevision, Actions: []TelemetryAction{}} conflicts := make([]TelemetryConflict, 0) stopActions := make(map[string]struct{}) for _, event := range input.Events { eventPayload, err := json.Marshal(event) if err != nil { return TelemetryBatchResponse{}, err } digest := idempotency.Hash(eventPayload) rows, err := tx.Query(ctx, `SELECT event_key,sequence_no,payload_digest FROM device_telemetry_event WHERE device_id=$1::uuid AND (event_key=$2 OR sequence_no=$3) FOR UPDATE`, identity.ID, event.EventKey, event.SequenceNo) if err != nil { return TelemetryBatchResponse{}, err } matches := 0 exact := false for rows.Next() { matches++ var storedKey, storedDigest string var storedSequence int64 if err := rows.Scan(&storedKey, &storedSequence, &storedDigest); err != nil { rows.Close() return TelemetryBatchResponse{}, err } if storedKey == event.EventKey && storedSequence == event.SequenceNo && storedDigest == digest { exact = true } } rows.Close() if rows.Err() != nil { return TelemetryBatchResponse{}, rows.Err() } if matches > 0 { if matches == 1 && exact { response.DuplicateCount++ continue } conflicts = append(conflicts, TelemetryConflict{EventKey: event.EventKey, SequenceNo: event.SequenceNo, Reason: "EVENT_KEY_OR_SEQUENCE_ALREADY_USED"}) continue } var conversationInternalID *string if event.ConversationID != "" { var internalID, conversationStatus string err := tx.QueryRow(ctx, `SELECT id::text,status FROM conversation WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND device_id=$3::uuid`, event.ConversationID, identity.TenantID, identity.ID).Scan(&internalID, &conversationStatus) if errors.Is(err, pgx.ErrNoRows) { return TelemetryBatchResponse{}, ErrTelemetryConversationMissing } if err != nil { return TelemetryBatchResponse{}, err } conversationInternalID = &internalID if conversationStatus == "STOPPING" || conversationStatus == "COMPLETED" || conversationStatus == "FAILED" || conversationStatus == "EXPIRED" { stopActions[event.ConversationID] = struct{}{} } } var errorCode, errorSeverity *string var commandInternalID *string switch event.EventType { case "status": if event.Status.ReportedConfigRevision > desiredRevision { return TelemetryBatchResponse{}, ErrTelemetryConfigAhead } if _, err := tx.Exec(ctx, `UPDATE device_twin SET telemetry_sequence=$3, reported_revision=CASE WHEN reported_revision IS NULL OR reported_revision<=$4 THEN $4 ELSE reported_revision END, reported_config=CASE WHEN reported_revision IS NULL OR reported_revision<=$4 THEN reported_config || jsonb_build_object('telemetry',$5::jsonb) ELSE reported_config END, firmware_version=NULLIF($6,''),last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID, event.SequenceNo, event.Status.ReportedConfigRevision, eventPayload, event.Status.FirmwareVersion); err != nil { return TelemetryBatchResponse{}, err } if reportedRevision == nil || *reportedRevision < event.Status.ReportedConfigRevision { value := event.Status.ReportedConfigRevision reportedRevision = &value } case "error": errorCode, errorSeverity = &event.Error.Code, &event.Error.Severity if _, err := tx.Exec(ctx, `UPDATE device_twin SET telemetry_sequence=$3,last_error_code=$4,last_error_severity=$5,last_error_at=$6,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID, event.SequenceNo, event.Error.Code, event.Error.Severity, event.OccurredAt); err != nil { return TelemetryBatchResponse{}, err } case "command_result": internalID, err := applyTelemetryCommandResult(ctx, tx, identity, *event.CommandResult) if err != nil { return TelemetryBatchResponse{}, err } commandInternalID = &internalID if _, err := tx.Exec(ctx, `UPDATE device_twin SET telemetry_sequence=$3,last_reported_at=now(),updated_at=now() WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND telemetry_sequence<$3`, identity.ID, identity.TenantID, event.SequenceNo); err != nil { return TelemetryBatchResponse{}, err } } if _, err := tx.Exec(ctx, `INSERT INTO device_telemetry_event( tenant_id,device_id,conversation_id,event_key,sequence_no,event_type,occurred_at,payload_digest,payload,error_code,error_severity,command_id ) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::uuid)`, identity.TenantID, identity.ID, conversationInternalID, event.EventKey, event.SequenceNo, event.EventType, event.OccurredAt, digest, eventPayload, errorCode, errorSeverity, commandInternalID); err != nil { return TelemetryBatchResponse{}, err } response.AcceptedCount++ } if len(conflicts) > 0 { return TelemetryBatchResponse{}, &TelemetryConflictError{Conflicts: conflicts} } if _, err := tx.Exec(ctx, `SELECT record_device_presence($1::uuid,$2::uuid,now())`, identity.TenantID, identity.ID); err != nil { return TelemetryBatchResponse{}, err } if reportedRevision == nil || *reportedRevision < desiredRevision { response.Actions = append(response.Actions, TelemetryAction{Type: "refresh_config"}) } var hasCommands bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_command WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now())`, identity.TenantID, identity.ID).Scan(&hasCommands); err != nil { return TelemetryBatchResponse{}, err } if hasCommands { response.Actions = append(response.Actions, TelemetryAction{Type: "pull_commands"}) } for conversationID := range stopActions { response.Actions = append(response.Actions, TelemetryAction{Type: "stop_conversation", ConversationID: conversationID}) } return response, nil } func applyTelemetryCommandResult(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input TelemetryCommandResult) (string, error) { resultJSON, err := json.Marshal(input.ResultSummary) if err != nil { return "", err } 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`, input.CommandID, identity.TenantID, identity.ID, input.Status, resultJSON).Scan(&internalID) if errors.Is(err, pgx.ErrNoRows) { return "", ErrTelemetryCommandConflict } if err != nil { return "", err } assignmentStatus, progress := firmwareAssignmentProgress(CommandAcknowledgement{Status: input.Status, ResultSummary: input.ResultSummary}) 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 "", err } if err := recordFirmwareAssignmentEvent(ctx, tx, internalID, input.Status, assignmentStatus, progress, resultJSON); err != nil { return "", 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 WHERE fa.command_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'))`, internalID); err != nil { return "", err } } return internalID, nil }