Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

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
}