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 }