230 lines
8.0 KiB
Go
230 lines
8.0 KiB
Go
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
|
||
|
|
}
|