Files
story/internal/onboarding/device_control.go
T

230 lines
8.0 KiB
Go
Raw Normal View History

package onboarding
import (
"context"
"encoding/json"
"errors"
"strings"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/deviceconfig"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// These routes use DEVREQ1 factory authentication. A device never supplies the
// platform UUID or tenant used to read configurations or acknowledge commands.
type DeviceControlReport struct {
Firmware string `json:"firmware_version"`
Revision int64 `json:"reported_revision"`
Speaker int `json:"speaker"`
Busy bool `json:"busy"`
RSSI int `json:"rssi"`
DisplayName *string `json:"display_name,omitempty"`
Power *DevicePowerReport `json:"power,omitempty"`
ScreenWake *DeviceScreenWake `json:"screen_wake,omitempty"`
}
type DeviceScreenWake struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
}
type DeviceScreenWakeReceipt struct {
DeviceScreenWake
Result string `json:"result"`
}
type DevicePowerReport struct {
Mode string `json:"mode"`
MicrophonePaused bool `json:"microphone_paused"`
BacklightPercent int `json:"backlight_percent"`
MicrophoneReads uint64 `json:"microphone_reads"`
}
func controlIdentity(d Device) deviceauth.Identity {
return deviceauth.Identity{ID: d.ID, PublicID: d.PublicID, TenantID: d.TenantID,
Status: d.Status, ActivationState: d.Lifecycle, EnrollmentMode: d.Mode,
BindingGeneration: d.Generation}
}
func validateControlReport(in DeviceControlReport) error {
if wake := in.ScreenWake; wake != nil {
if _, err := uuid.Parse(wake.ID); err != nil || len(wake.ID) != 36 || wake.Revision <= 0 ||
in.Power == nil || in.Power.Mode != "active" {
return fail(400, "INVALID_CONTROL_REPORT", "屏幕唤醒状态不正确")
}
}
if p := in.Power; p != nil {
if p.BacklightPercent < -1 || p.BacklightPercent > 100 || p.MicrophoneReads > 4294967295 ||
(p.Mode != "active" && p.Mode != "standby" && p.Mode != "transition") ||
(p.Mode == "standby" && (!p.MicrophonePaused || p.BacklightPercent != 0)) ||
(p.Mode == "active" && (p.MicrophonePaused || p.BacklightPercent <= 0)) {
return fail(400, "INVALID_CONTROL_REPORT", "待机状态不正确")
}
}
if in.DisplayName != nil && !deviceconfig.ValidDisplayName(*in.DisplayName) {
return fail(400, "INVALID_CONTROL_REPORT", "设备名称不正确")
}
if in.Firmware == "" || len(in.Firmware) > 80 || strings.TrimSpace(in.Firmware) != in.Firmware ||
in.Revision < 0 || in.Speaker < 0 || in.Speaker > 100 || in.RSSI < -127 || in.RSSI > 0 {
return fail(400, "INVALID_CONTROL_REPORT", "设备运行状态不正确")
}
return nil
}
// An authenticated physical wake only changes power. A revision fence prevents
// an offline gesture or a lost-response retry from undoing newer cloud settings.
// Its receipt and the actual report commit with the desired state under one lock.
func reconcileScreenWake(ctx context.Context, tx pgx.Tx, d Device, wake *DeviceScreenWake) (*DeviceScreenWakeReceipt, error) {
if wake == nil {
return nil, nil
}
var revision int64
var desired, previous []byte
if err := tx.QueryRow(ctx, `SELECT desired_revision,desired_config,
COALESCE(reported_config->'screen_wake','null'::jsonb)
FROM device_twin WHERE device_id=$1 AND tenant_id=$2 FOR UPDATE`, d.ID, d.TenantID).Scan(&revision, &desired, &previous); err != nil {
return nil, err
}
var receipt DeviceScreenWakeReceipt
if json.Unmarshal(previous, &receipt) == nil && receipt.ID == wake.ID {
if receipt.Revision != wake.Revision {
return nil, fail(409, "SCREEN_WAKE_CONFLICT", "唤醒请求已变化")
}
return &receipt, nil
}
receipt = DeviceScreenWakeReceipt{DeviceScreenWake: *wake, Result: "superseded"}
if revision != wake.Revision {
return &receipt, nil
}
var config map[string]json.RawMessage
if err := json.Unmarshal(desired, &config); err != nil {
return nil, err
}
var power struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(config["power"], &power); err != nil || (power.Mode != "standby" && power.Mode != "active") {
return &receipt, nil
}
if power.Mode == "standby" {
if _, err := tx.Exec(ctx, `UPDATE device_twin
SET desired_config=jsonb_set(desired_config,'{power}','{"mode":"active"}'::jsonb),
desired_revision=desired_revision+1,updated_at=now()
WHERE device_id=$1 AND tenant_id=$2`, d.ID, d.TenantID); err != nil {
return nil, err
}
}
receipt.Result = "applied"
return &receipt, nil
}
func (s *Service) DeviceControlSync(ctx context.Context, d Device, in DeviceControlReport) (map[string]any, error) {
if !deviceAllowed(d) {
return nil, errAccess
}
if err := validateControlReport(in); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
wakeReceipt, err := reconcileScreenWake(ctx, tx, d, in.ScreenWake)
if err != nil {
return nil, err
}
// Revision zero means the device has not applied cloud configuration yet.
// Report hardware readings, never copy desired values into reported_config.
values := map[string]any{"volume": map[string]any{"speaker": in.Speaker},
"network": map[string]any{"type": "wifi", "rssi": in.RSSI}}
// Older firmware omits the field; omission must not claim a name was applied.
if in.DisplayName != nil {
values["identity"] = map[string]any{"display_name": *in.DisplayName}
}
if in.Power != nil {
values["power"] = in.Power
}
if wakeReceipt != nil {
values["screen_wake"] = wakeReceipt
}
reported, _ := json.Marshal(values)
tag, err := tx.Exec(ctx, `UPDATE device_twin SET firmware_version=$3,
reported_config=reported_config || $4::jsonb,
reported_revision=NULLIF($5,0),
last_reported_at=now(),updated_at=now()
WHERE device_id=$1 AND tenant_id=$2 AND $5<=desired_revision
AND ($5=0 OR reported_revision IS NULL OR $5>=reported_revision)`, d.ID, d.TenantID, in.Firmware, reported, in.Revision)
if err != nil {
return nil, err
}
if tag.RowsAffected() != 1 {
return nil, fail(409, "CONFIG_REVISION_CONFLICT", "配置版本已变化,请重新同步")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
repo := device.NewRepository(s.pool)
config, err := repo.GetConfiguration(ctx, controlIdentity(d))
if err != nil {
return nil, err
}
items := []device.Command{}
if !in.Busy {
items, err = repo.PullCommands(ctx, controlIdentity(d), 1)
if err != nil {
return nil, err
}
}
out := map[string]any{"configuration": config, "commands": items}
if wakeReceipt != nil {
out["screen_wake"] = wakeReceipt
}
return out, nil
}
type DeviceControlAck struct {
ID string `json:"command_id"`
Status string `json:"status"`
Result map[string]any `json:"result_summary"`
}
func validateControlAck(in DeviceControlAck) error {
if _, err := uuid.Parse(in.ID); err != nil {
return fail(400, "INVALID_COMMAND_ACK", "命令编号不正确")
}
if in.Status != "ACKNOWLEDGED" && in.Status != "SUCCEEDED" && in.Status != "FAILED" {
return fail(400, "INVALID_COMMAND_ACK", "命令状态不正确")
}
raw, err := json.Marshal(in.Result)
if err != nil || len(raw) > 4096 {
return fail(400, "INVALID_COMMAND_ACK", "诊断结果过大或格式不正确")
}
return nil
}
func (s *Service) DeviceControlAcknowledge(ctx context.Context, d Device, in DeviceControlAck) (map[string]any, error) {
if !deviceAllowed(d) {
return nil, errAccess
}
if err := validateControlAck(in); err != nil {
return nil, err
}
if in.Result == nil {
in.Result = map[string]any{}
}
_, err := device.NewRepository(s.pool).AcknowledgeCommand(ctx, controlIdentity(d), in.ID,
device.CommandAcknowledgement{Status: in.Status, ResultSummary: in.Result})
if err != nil {
// A missing, foreign, expired, or terminal command cannot gain authority.
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
return nil, fail(409, "DEVICE_COMMAND_STATE_CONFLICT", "命令不存在、已过期或状态已变化")
}
return map[string]any{"status": "ok"}, nil
}