Files
story/cmd/sim-device/main.go
T

612 lines
21 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"data-terminal-cloud/internal/deviceauth"
)
type simulator struct {
baseURL, deviceID string
secret []byte
client *http.Client
}
type apiResult struct {
Status int
Header http.Header
Body []byte
}
type envelope struct {
Data json.RawMessage `json:"data"`
}
type createdConversation struct {
ID string `json:"conversation_id"`
RTC struct {
RoomID string `json:"room_id"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
} `json:"rtc"`
}
type deviceConversationState struct {
Status string `json:"status"`
Action string `json:"action"`
FailureCode string `json:"failure_code"`
}
type uploadBatch struct {
ID string `json:"id"`
Status string `json:"status"`
Segments []struct {
ID string `json:"id"`
Status string `json:"status"`
UploadURL string `json:"upload_url"`
ContentType string `json:"content_type"`
LastErrorCode string `json:"last_error_code"`
LocalDeleteAfter *time.Time `json:"local_delete_after"`
} `json:"segments"`
}
func main() {
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "模拟设备失败:", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
secret := []byte(env("SIM_DEVICE_SECRET", "local-simulated-device-secret-32-bytes-minimum"))
if len(secret) < 32 {
return errors.New("SIM_DEVICE_SECRET must contain at least 32 characters")
}
sim := &simulator{
baseURL: strings.TrimRight(env("SIM_API_URL", "http://localhost:58080"), "/"),
deviceID: env("SIM_DEVICE_ID", "10000000-0000-0000-0000-000000000511"),
secret: secret, client: &http.Client{Timeout: 15 * time.Second},
}
scenarios := parseScenarios(env("SIM_SCENARIO", "bootstrap"))
if scenarios["offline"] {
if err := sim.offlineRetry(ctx); err != nil {
return err
}
}
if scenarios["bootstrap"] || scenarios["conversation"] || scenarios["all"] {
if err := sim.bootstrap(ctx); err != nil {
return err
}
}
conversationID := strings.TrimSpace(os.Getenv("SIM_CONVERSATION_ID"))
if scenarios["conversation"] || scenarios["all"] {
id, err := sim.conversation(ctx)
if err != nil {
return err
}
conversationID = id
}
if scenarios["transcript"] || scenarios["all"] {
if conversationID == "" {
return errors.New("transcript scenario requires conversation or SIM_CONVERSATION_ID")
}
if err := sim.transcript(ctx, conversationID); err != nil {
return err
}
}
if scenarios["telemetry"] || scenarios["all"] {
if err := sim.telemetry(ctx, conversationID); err != nil {
return err
}
}
if scenarios["commands"] || scenarios["all"] {
if err := sim.commands(ctx); err != nil {
return err
}
}
if scenarios["media"] {
if conversationID == "" {
return errors.New("media scenario requires SIM_CONVERSATION_ID")
}
if err := sim.media(ctx, conversationID); err != nil {
return err
}
}
return nil
}
func (s *simulator) bootstrap(ctx context.Context) error {
query := url.Values{
"firmware_version": {env("SIM_FIRMWARE_VERSION", "0.2.0-sim")}, "board_rev": {"sim-rev-a"}, "rtc_sdk_version": {"mock-rtc-1"},
"codec": {"opus"}, "sample_rate": {"16000"}, "network": {"simulated"},
"device_time": {time.Now().UTC().Format(time.RFC3339)},
}
first, err := s.request(ctx, http.MethodGet, "/device/v2/bootstrap?"+query.Encode(), nil, "")
if err != nil {
return err
}
var bootstrap struct {
Device struct {
FirmwareCompatible bool `json:"firmware_compatible"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
} `json:"device"`
}
if err := decodeData(first.Body, &bootstrap); err != nil {
return err
}
if !bootstrap.Device.FirmwareCompatible {
minimum := "当前策略"
if bootstrap.Device.MinimumFirmwareVersion != nil {
minimum = *bootstrap.Device.MinimumFirmwareVersion
}
return fmt.Errorf("firmware update required before conversation; minimum=%s", minimum)
}
second, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
if err != nil {
return err
}
fmt.Printf("bootstrap HTTP %d; config HTTP %d\n", first.Status, second.Status)
return nil
}
func (s *simulator) offlineRetry(ctx context.Context) error {
offline := *s
offline.baseURL = "http://127.0.0.1:1"
offline.client = &http.Client{Timeout: 250 * time.Millisecond}
if _, err := offline.request(ctx, http.MethodGet, "/device/v2/config", nil, ""); err == nil {
return errors.New("offline simulation unexpectedly reached a server")
}
fmt.Println("offline request retained locally; reconnecting with a new nonce")
_, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
return err
}
func (s *simulator) conversation(ctx context.Context) (string, error) {
createBody := jsonBody(map[string]any{
"device_time": time.Now().UTC(), "monotonic_ms": 1000,
"capabilities": map[string]any{
"rtc": true, "audio_tracks": []string{"near", "far"}, "upload_resume": true,
"rtc_credential_renewal": true,
},
"network": map[string]any{"type": "simulated", "rssi": -48}, "requested_goal": "open_conversation",
})
createKey := "sim-create-" + nonce()
createdResponse, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
if err != nil {
return "", err
}
var created createdConversation
if err := decodeData(createdResponse.Body, &created); err != nil {
return "", err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return "", fmt.Errorf("create replay failed: %w", err)
}
activate := jsonBody(map[string]any{
"joined_at": time.Now().UTC(), "room_id": created.RTC.RoomID, "rtc_user_id": created.RTC.UserID,
"rtc_join_result": "success", "config_revision": 1, "connection_reference": "sim-device",
})
activationPath := "/device/v2/conversations/" + created.ID + "/activate"
if _, err := s.request(ctx, http.MethodPost, activationPath, activate, "sim-activate-"+nonce()); err != nil {
return "", err
}
if err := s.waitForConversationActive(ctx, created.ID, 15*time.Second); err != nil {
return "", err
}
heartbeat := jsonBody(map[string]any{
"sequence_no": 1, "device_time": time.Now().UTC(), "monotonic_ms": 2000, "conversation_status": "ACTIVE",
"rtc": map[string]any{"joined": true, "uplink": true, "downlink": true}, "network": map[string]any{"type": "simulated", "rssi": -50},
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
"rtc_credential_expires_at": created.RTC.ExpiresAt,
"recording_queue": map[string]any{"pending_segments": 2, "failed_segments": 0}, "command_results": []any{},
})
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/heartbeat", heartbeat, ""); err != nil {
return "", err
}
stop := jsonBody(map[string]any{
"reason": "simulated_hangup", "device_time": time.Now().UTC(), "last_heartbeat_sequence": 1,
"rtc_left": true, "recording": map[string]any{"near_segments": 1, "far_segments": 1},
})
stopKey := "sim-stop-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey); err != nil {
return "", err
}
replay, err = s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return "", fmt.Errorf("stop replay failed: %w", err)
}
time.Sleep(2 * time.Second)
fmt.Println("conversation and repeated requests completed:", created.ID)
return created.ID, nil
}
func (s *simulator) waitForConversationActive(ctx context.Context, conversationID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
statusResponse, err := s.request(ctx, http.MethodGet, "/device/v2/conversations/"+conversationID, nil, "")
if err != nil {
return err
}
var state deviceConversationState
if err := decodeData(statusResponse.Body, &state); err != nil {
return err
}
switch state.Action {
case "continue":
if state.Status != "ACTIVE" {
return fmt.Errorf("conversation returned continue in unexpected state %s", state.Status)
}
return nil
case "leave_room":
code := state.FailureCode
if code == "" {
code = strings.ToLower(state.Status)
}
return fmt.Errorf("conversation cannot start; leave RTC room: %s", code)
case "wait":
default:
return fmt.Errorf("conversation returned unsupported device action %q", state.Action)
}
if time.Now().After(deadline) {
return errors.New("provider did not activate the simulated conversation within 15 seconds")
}
if err := waitForContext(ctx, 500*time.Millisecond); err != nil {
return err
}
}
}
func (s *simulator) transcript(ctx context.Context, conversationID string) error {
now := time.Now().UTC()
body := jsonBody(map[string]any{"turns": []any{map[string]any{
"turn_key": "sim-final-" + nonce(), "sequence_no": 100, "speaker": "subject",
"start_at": now.Add(-2 * time.Second), "end_at": now,
"final_text": "这是模拟设备离线缓存后补交的最终字幕。", "interrupted": false,
}}})
key := "sim-transcript-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key); err != nil {
return err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return fmt.Errorf("transcript replay failed: %w", err)
}
fmt.Println("transcript supplement and replay completed")
return nil
}
func (s *simulator) telemetry(ctx context.Context, conversationID string) error {
sequence := time.Now().UnixNano()
event := map[string]any{
"event_key": "sim-status-" + strconv.FormatInt(sequence, 10), "sequence_no": sequence,
"occurred_at": time.Now().UTC(), "event_type": "status",
"status": map[string]any{
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
"network": map[string]any{"type": "simulated", "rssi": -52},
"rtc": map[string]any{"joined": false, "uplink": false, "downlink": false},
"recording_queue": map[string]any{"pending_segments": 0, "failed_segments": 0}, "conversation_status": "COMPLETED",
},
}
if conversationID != "" {
event["conversation_id"] = conversationID
}
body := jsonBody(map[string]any{"events": []any{event}})
key := "sim-telemetry-" + nonce()
if _, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key); err != nil {
return err
}
replay, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key)
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
return fmt.Errorf("telemetry replay failed: %w", err)
}
fmt.Println("telemetry batch and replay completed")
return nil
}
func (s *simulator) commands(ctx context.Context) error {
response, err := s.request(ctx, http.MethodGet, "/device/v2/commands?limit=20", nil, "")
if err != nil {
return err
}
var data struct {
Items []simulatedDeviceCommand `json:"items"`
}
if err := decodeData(response.Body, &data); err != nil {
return err
}
completed, failed := 0, 0
for _, command := range data.Items {
if command.Type == "firmware_update" {
outcome, err := s.executeFirmwareCommand(ctx, command)
if err != nil {
return err
}
switch outcome {
case firmwareCompleted:
completed++
case firmwareFailed:
failed++
case firmwareInterrupted:
fmt.Printf("firmware command %s intentionally interrupted; server state retained for recovery\n", command.ID)
return nil
case firmwareRebootRequired:
fmt.Printf("firmware command %s armed the inactive slot; restart the simulator to perform boot health confirmation\n", command.ID)
return nil
}
continue
}
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"result_code": "SIMULATED_OK"}); err != nil {
return err
}
completed++
}
fmt.Printf("processed %d pending commands: %d completed, %d simulated failures\n", len(data.Items), completed, failed)
return nil
}
func (s *simulator) media(ctx context.Context, conversationID string) error {
durationMS, err := strconv.Atoi(env("SIM_MEDIA_DURATION_MS", "100"))
if err != nil || durationMS < 1 || durationMS > 600000 {
return errors.New("SIM_MEDIA_DURATION_MS must be between 1 and 600000")
}
mediaPath := strings.TrimSpace(os.Getenv("SIM_MEDIA_FILE"))
content := syntheticWAV(durationMS)
if mediaPath != "" {
content, err = os.ReadFile(mediaPath)
if err != nil {
return fmt.Errorf("read simulated media file: %w", err)
}
}
digest := sha256.Sum256(content)
manifest := jsonBody(map[string]any{"segments": []any{map[string]any{
"track": env("SIM_MEDIA_TRACK", "near"), "sequence_no": int(time.Now().Unix() % 1000000000),
"codec": "wav_pcm_s16le", "content_type": "audio/wav", "duration_ms": durationMS,
"expected_size": len(content), "expected_digest": hex.EncodeToString(digest[:]),
}}})
prepared, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/upload-batches", manifest, "sim-media-"+nonce())
if err != nil {
return err
}
var batch uploadBatch
if err := decodeData(prepared.Body, &batch); err != nil {
return err
}
if len(batch.Segments) != 1 || batch.Segments[0].UploadURL == "" {
return errors.New("media preparation did not return one upload URL")
}
uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, batch.Segments[0].UploadURL, bytes.NewReader(content))
if err != nil {
return err
}
uploadRequest.Header.Set("Content-Type", "audio/wav")
uploadResponse, err := s.client.Do(uploadRequest)
if err != nil {
return fmt.Errorf("upload simulated media: %w", err)
}
uploadResponse.Body.Close()
if uploadResponse.StatusCode < 200 || uploadResponse.StatusCode >= 300 {
return fmt.Errorf("upload simulated media returned HTTP %d", uploadResponse.StatusCode)
}
if _, err := s.request(ctx, http.MethodPost, "/device/v2/upload-batches/"+batch.ID+"/complete", []byte(`{}`), ""); err != nil {
return err
}
verifyTimeout, err := simulatorDuration("SIM_MEDIA_VERIFY_TIMEOUT", 20*time.Second, 5*time.Minute)
if err != nil {
return err
}
verified, err := s.waitForVerifiedMedia(ctx, batch.ID, verifyTimeout)
if err != nil {
return err
}
if verified.LocalDeleteAfter == nil {
return errors.New("verified media segment did not return local_delete_after")
}
deleteWaitMax, err := simulatorDuration("SIM_MEDIA_DELETE_WAIT_MAX", 30*time.Second, 10*time.Minute)
if err != nil {
return err
}
wait := time.Until(*verified.LocalDeleteAfter) + 100*time.Millisecond
if wait > deleteWaitMax {
fmt.Printf("media verified; local encrypted copy retained until %s (outside simulator wait window)\n", verified.LocalDeleteAfter.UTC().Format(time.RFC3339))
return nil
}
if wait > 0 {
if err := waitForContext(ctx, wait); err != nil {
return err
}
}
// The simulator only releases its in-memory copy. SIM_MEDIA_FILE is an operator-owned
// fixture and is intentionally never removed from disk.
content = nil
ack := jsonBody(map[string]any{"status": "DELETED"})
if _, err := s.request(ctx, http.MethodPost, "/device/v2/media-segments/"+verified.ID+"/local-delete-ack", ack, "sim-local-delete-"+nonce()); err != nil {
return err
}
fmt.Println("media verified and local encrypted copy deletion acknowledged:", batch.ID)
return nil
}
type verifiedMediaSegment struct {
ID string
LocalDeleteAfter *time.Time
}
func (s *simulator) waitForVerifiedMedia(ctx context.Context, batchID string, timeout time.Duration) (verifiedMediaSegment, error) {
deadline := time.Now().Add(timeout)
for {
response, err := s.request(ctx, http.MethodGet, "/device/v2/upload-batches/"+batchID, nil, "")
if err != nil {
return verifiedMediaSegment{}, err
}
var batch uploadBatch
if err := decodeData(response.Body, &batch); err != nil {
return verifiedMediaSegment{}, err
}
if batch.Status == "FAILED" {
code := "SEGMENT_REJECTED"
if len(batch.Segments) > 0 && batch.Segments[0].LastErrorCode != "" {
code = batch.Segments[0].LastErrorCode
}
return verifiedMediaSegment{}, fmt.Errorf("media verification failed: %s", code)
}
if batch.Status == "COMPLETED" {
for _, segment := range batch.Segments {
if segment.Status == "VERIFIED" && segment.ID != "" {
return verifiedMediaSegment{ID: segment.ID, LocalDeleteAfter: segment.LocalDeleteAfter}, nil
}
}
return verifiedMediaSegment{}, errors.New("completed media batch has no verified segment")
}
if time.Now().After(deadline) {
return verifiedMediaSegment{}, fmt.Errorf("media verification did not complete within %s", timeout)
}
if err := waitForContext(ctx, 300*time.Millisecond); err != nil {
return verifiedMediaSegment{}, err
}
}
}
func simulatorDuration(key string, fallback, maximum time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 || parsed > maximum {
return 0, fmt.Errorf("%s must be a positive duration no greater than %s", key, maximum)
}
return parsed, nil
}
func waitForContext(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (s *simulator) request(ctx context.Context, method, path string, body []byte, key string) (apiResult, error) {
request, err := http.NewRequestWithContext(ctx, method, s.baseURL+path, bytes.NewReader(body))
if err != nil {
return apiResult{}, err
}
timestamp := time.Now().UTC().Format(time.RFC3339)
requestNonce, err := newNonce()
if err != nil {
return apiResult{}, err
}
hash := sha256.Sum256(body)
digest := hex.EncodeToString(hash[:])
request.Header.Set(deviceauth.HeaderDeviceID, s.deviceID)
request.Header.Set(deviceauth.HeaderTimestamp, timestamp)
request.Header.Set(deviceauth.HeaderNonce, requestNonce)
request.Header.Set(deviceauth.HeaderContentSHA256, digest)
request.Header.Set(deviceauth.HeaderSignature, deviceauth.SignatureHeader(s.secret, method, request.URL.RequestURI(), timestamp, requestNonce, digest))
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
response, err := s.client.Do(request)
if err != nil {
return apiResult{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return apiResult{}, err
}
result := apiResult{Status: response.StatusCode, Header: response.Header.Clone(), Body: responseBody}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return result, fmt.Errorf("%s returned HTTP %d: %s", request.URL.Path, response.StatusCode, responseBody)
}
return result, nil
}
func decodeData(body []byte, target any) error {
var result envelope
if err := json.Unmarshal(body, &result); err != nil {
return err
}
return json.Unmarshal(result.Data, target)
}
func parseScenarios(value string) map[string]bool {
result := map[string]bool{}
for _, item := range strings.Split(strings.ToLower(value), ",") {
if item = strings.TrimSpace(item); item != "" {
result[item] = true
}
}
return result
}
func syntheticWAV(durationMS int) []byte {
samples := 16000 * durationMS / 1000
dataSize := samples * 2
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
buffer.WriteString("RIFF")
_ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize))
buffer.WriteString("WAVEfmt ")
_ = binary.Write(buffer, binary.LittleEndian, uint32(16))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
_ = binary.Write(buffer, binary.LittleEndian, uint32(16000))
_ = binary.Write(buffer, binary.LittleEndian, uint32(32000))
_ = binary.Write(buffer, binary.LittleEndian, uint16(2))
_ = binary.Write(buffer, binary.LittleEndian, uint16(16))
buffer.WriteString("data")
_ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize))
buffer.Write(make([]byte, dataSize))
return buffer.Bytes()
}
func jsonBody(value any) []byte {
body, err := json.Marshal(value)
if err != nil {
panic(err)
}
return body
}
func nonce() string {
value, err := newNonce()
if err != nil {
panic(err)
}
return value
}
func newNonce() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}