Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0d83299
✨ feat(e2e): 增加分层 Host 与 HIL 门禁
JunLang-7 Aug 21, 2026
6539833
🔒️ fix(e2e): 收紧分层门禁的失败证据与设备输入
JunLang-7 Aug 21, 2026
559b1fd
👷 ci(e2e): 暂停 HIL 自动调度保留手工入口
JunLang-7 Aug 21, 2026
f7c71b7
🔒️ fix(e2e): 防止恢复详情写入公开产物
JunLang-7 Aug 21, 2026
5c87b44
✨ feat(e2e): 增加 SparkBot 语音 HIL 旅程
JunLang-7 Aug 21, 2026
8da6fe8
✨ feat(e2e): 支持 PCB 语音 HIL
JunLang-7 Aug 21, 2026
b07fb8c
✅ test(voice): 固化百炼 SparkBot 实板测试模板
ZhaoXingPeng Aug 22, 2026
3e2f929
🐛 fix(linx): 保持 listen 会话模式与协议一致
ZhaoXingPeng Aug 22, 2026
7ae4f5a
🐛 fix(test): 提升百炼 SparkBot 唤醒注入重跑稳定性
ZhaoXingPeng Aug 22, 2026
156d4e7
🐛 fix(linx): 完善 SparkBot 传输边界与重连诊断
ZhaoXingPeng Aug 22, 2026
04c3d8d
🐛 fix(voice): 增强 SparkBot 唤醒恢复与 Linx 诊断
ZhaoXingPeng Aug 23, 2026
5bdcc85
🐛 fix(test): 补齐 Linx 音频配置测试夹具
ZhaoXingPeng Aug 23, 2026
e89a133
Merge remote-tracking branch 'origin/main' into fix/sparkbot-wake-rec…
ZhaoXingPeng Aug 23, 2026
015f88f
🐛 fix(mcp): 补齐 Linx 工具列表分页游标
ZhaoXingPeng Aug 23, 2026
81faa3c
🐛 fix(voice): 恢复 SparkBot 唤醒确认音
ZhaoXingPeng Aug 23, 2026
363655d
🐛 fix(voice): 对齐 SparkBot auto 监听模式
ZhaoXingPeng Aug 23, 2026
8e20852
🐛 fix(voice): 修复串口注入背压并补齐 Linx 工具类型
ZhaoXingPeng Aug 23, 2026
498fa4a
🐛 fix(test): 放宽串口回合结束门禁等待
ZhaoXingPeng Aug 23, 2026
fa3af12
🐛 fix(test): 按输入长度延长回合门禁
ZhaoXingPeng Aug 23, 2026
d8832e5
🐛 fix(test): 接受本地VAD回合结束证据
ZhaoXingPeng Aug 23, 2026
545b8e4
🐛 fix(voice): 扩大 SparkBot 音频有界突发缓冲
ZhaoXingPeng Aug 23, 2026
c98a5af
🐛 fix(test): 接受 auto 模式远端 STT 回合结束
ZhaoXingPeng Aug 23, 2026
5f90e15
🐛 fix(test): 修正 auto 多轮状态门禁边界
ZhaoXingPeng Aug 23, 2026
74c2a25
🐛 fix(mcp): 对齐 PR338 单页工具发现协议
ZhaoXingPeng Aug 23, 2026
bfa80ee
Merge PR 349 voice wake recovery baseline
JunLang-7 Aug 23, 2026
9516dcf
✨ feat(e2e): 完整接入语音 HIL TTS 与唤醒门禁
JunLang-7 Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
self-hosted-runner:
labels:
- voicelife-hil
- sparkbot
- pcb
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ jobs:
--journey im-gateway-strong-reminder \
--profile host \
--artifact-dir artifacts/im-gateway-e2e \
--timeout 60
--timeout 60 \
--retries 0
- name: 运行 Host 快速恢复 E2E
run: |
E2E_RECOVERY_SUITE=quick python3 scripts/run_e2e.py \
Expand All @@ -101,13 +102,36 @@ jobs:
--artifact-dir artifacts/im-gateway-e2e \
--timeout 120 \
--retries 0
- name: 上传 Host E2E 脱敏证据
- name: 验证故意失败 evidence
if: always()
shell: bash
run: |
set +e
VOICELIFE_E2E_CONTRACT_FAILURE=1 python3 scripts/run_e2e.py \
--layer host \
--journey lifecycle-example \
--profile host \
--artifact-dir artifacts/im-gateway-e2e/contract-failure \
--timeout 60 \
--retries 0
failure_status=$?
set -e
test "$failure_status" -eq 20
- name: 校验 Host E2E 脱敏 evidence
id: validate-host-evidence
if: always()
run: python3 scripts/check_e2e_artifacts.py artifacts/im-gateway-e2e
- name: 写入 Host E2E job summary
if: always()
run: python3 scripts/render_e2e_summary.py artifacts/im-gateway-e2e >> "$GITHUB_STEP_SUMMARY"
- name: 上传 Host E2E 脱敏证据
if: always() && steps.validate-host-evidence.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: im-gateway-host-e2e-evidence
path: artifacts/im-gateway-e2e
if-no-files-found: ignore
retention-days: 14

coverage:
name: 覆盖率(Codecov)
Expand Down
154 changes: 154 additions & 0 deletions .github/workflows/hil-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
name: HIL Manual

on:
workflow_dispatch:
inputs:
profile:
description: Device profile to run (the default runs both profiles)
required: false
type: choice
default: all
options:
- all
- sparkbot
- pcb
journey:
description: HIL journey
required: false
type: choice
default: im-pairing
options:
- im-pairing
- voice
tts_provider:
description: Host TTS fixture provider used by the voice journey
required: false
type: choice
default: dashscope
options:
- dashscope
- aliyun-nls
device:
description: Optional descriptor name; requires a single profile selection
required: false
type: string
default: ""

permissions:
contents: read

concurrency:
group: hil-nightly-${{ github.ref }}-${{ inputs.profile || 'all' }}
cancel-in-progress: false

jobs:
hil:
name: HIL / ${{ matrix.profile }}
runs-on: [self-hosted, voicelife-hil, "${{ matrix.profile }}"]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
profile: ${{ fromJSON(inputs.profile == 'sparkbot' && '["sparkbot"]' || inputs.profile == 'pcb' && '["pcb"]' || '["sparkbot", "pcb"]') }}
env:
HIL_DEVICE_ROOT: ${{ vars.VOICELIFE_HIL_DEVICE_ROOT || '/opt/voicelife/hil/devices' }}
HIL_LEASE_ROOT: ${{ vars.VOICELIFE_HIL_LEASE_ROOT || '/opt/voicelife/hil/leases' }}
VOICELIFE_HIL_SERVER: ${{ secrets.VOICELIFE_HIL_SERVER }}
VOICELIFE_HIL_SERVER_DIR: ${{ secrets.VOICELIFE_HIL_SERVER_DIR }}
VOICELIFE_HIL_GATEWAY_ORIGIN: ${{ secrets.VOICELIFE_HIL_GATEWAY_ORIGIN }}
VOICELIFE_HIL_USER_ID: ${{ secrets.VOICELIFE_HIL_USER_ID }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
ALIYUN_NLS_APPKEY: ${{ secrets.ALIYUN_NLS_APPKEY }}
ALIYUN_NLS_TOKEN: ${{ secrets.ALIYUN_NLS_TOKEN }}
ALIYUN_NLS_URL: ${{ vars.ALIYUN_NLS_URL }}
HIL_DEVICE_NAME: ${{ inputs.device || '' }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: 检查受控 Runner 配置
run: |
set -euo pipefail
device_name="$HIL_DEVICE_NAME"
if [[ -n "$device_name" ]]; then
[[ "${{ inputs.profile || 'all' }}" != "all" ]]
[[ "$device_name" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ ]]
descriptor="$HIL_DEVICE_ROOT/$device_name.json"
else
descriptor="$HIL_DEVICE_ROOT/${{ matrix.profile }}.json"
fi
test -r "$descriptor"
test -n "${VOICELIFE_HIL_SERVER:-}"
test -n "${VOICELIFE_HIL_SERVER_DIR:-}"
test -n "${VOICELIFE_HIL_GATEWAY_ORIGIN:-}"
test -n "${VOICELIFE_HIL_USER_ID:-}"
python3 -c 'import serial, esptool' >/dev/null
if [[ "${{ inputs.journey || 'im-pairing' }}" == "voice" ]]; then
if [[ "${{ inputs.tts_provider || 'dashscope' }}" == "dashscope" ]]; then
test -n "${DASHSCOPE_API_KEY:-}"
python3 -c 'import dashscope' >/dev/null
else
test -n "${ALIYUN_NLS_APPKEY:-}"
test -n "${ALIYUN_NLS_TOKEN:-}"
python3 -c 'import nls' >/dev/null
fi
command -v ffmpeg >/dev/null
fi
- name: 运行 HIL journey
id: journey
run: |
set +e
mkdir -p "artifacts/hil-${{ matrix.profile }}"
device_name="$HIL_DEVICE_NAME"
if [[ -n "$device_name" ]]; then
device_path="$HIL_DEVICE_ROOT/$device_name.json"
else
device_path="$HIL_DEVICE_ROOT/${{ matrix.profile }}.json"
fi
run_args=(
python3 scripts/run_e2e.py \
--layer hil \
--journey "${{ inputs.journey || 'im-pairing' }}" \
--profile "${{ matrix.profile }}" \
--artifact-dir "artifacts/hil-${{ matrix.profile }}" \
--timeout 900 \
--retries 0 \
--device "$device_path" \
--lease-dir "$HIL_LEASE_ROOT" \
--server "$VOICELIFE_HIL_SERVER" \
--server-dir "$VOICELIFE_HIL_SERVER_DIR" \
--gateway-origin "$VOICELIFE_HIL_GATEWAY_ORIGIN" \
--user-id "$VOICELIFE_HIL_USER_ID"
)
if [[ "${{ inputs.journey || 'im-pairing' }}" == "voice" ]]; then
run_args+=(
--input-tts "${{ inputs.tts_provider || 'dashscope' }}"
--tts-model "${VOICELIFE_TTS_MODEL:-qwen-audio-3.0-tts-flash}"
--voice "${VOICELIFE_TTS_VOICE:-longanlingxi}"
--text '你好牛牛,请介绍一下你自己。'
--text '把刚才的回答再简短一点。'
--text '请用一句话总结我们刚才的对话。'
--expect-terminal
)
fi
"${run_args[@]}"
run_status=$?
exit "$run_status"
- name: 校验 HIL 脱敏 evidence
id: validate-hil-evidence
if: always()
run: python3 scripts/check_e2e_artifacts.py "artifacts/hil-${{ matrix.profile }}"
- name: 写入 HIL job summary
if: always()
run: python3 scripts/render_e2e_summary.py "artifacts/hil-${{ matrix.profile }}" >> "$GITHUB_STEP_SUMMARY"
- name: 上传 HIL 脱敏 evidence
if: always() && steps.validate-hil-evidence.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: hil-${{ matrix.profile }}-evidence
path: artifacts/hil-${{ matrix.profile }}
if-no-files-found: error
retention-days: 14
10 changes: 9 additions & 1 deletion .github/workflows/im-recovery-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,18 @@ jobs:
--artifact-dir artifacts/im-gateway-recovery \
--timeout 300 \
--retries 0
- name: 上传恢复矩阵脱敏证据
- name: 校验恢复矩阵脱敏 evidence
id: validate-recovery-evidence
if: always()
run: python3 scripts/check_e2e_artifacts.py artifacts/im-gateway-recovery

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not scan raw recovery details as public artifacts

run-recovery-e2e.mjs writes recovery-<run_id>.json into this directory, and each snapshot row always contains the action_token field (even when its value is null). check_e2e_artifacts.py scans every JSON file, treats that key as sensitive, and therefore exits 1 after an otherwise successful recovery run; the subsequent upload is skipped and the nightly job is reported failed. The same directory-level validation was added to .github/workflows/ci.yml, so the PR Host recovery gate is affected too. Validate only the generated evidence-*.json files, or sanitize/allowlist the detailed diagnostic file before running this check.

- name: 写入恢复矩阵 job summary
if: always()
run: python3 scripts/render_e2e_summary.py artifacts/im-gateway-recovery >> "$GITHUB_STEP_SUMMARY"
- name: 上传恢复矩阵脱敏证据
if: always() && steps.validate-recovery-evidence.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: im-gateway-recovery-nightly-evidence
path: artifacts/im-gateway-recovery
if-no-files-found: ignore
retention-days: 14
15 changes: 15 additions & 0 deletions components/voicelife_audio_esp/src/esp32s3_pcm_audio_port.cc
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Status Esp32s3PcmAudioPorts::Impl::InputPort::StartCapture(voice::VoiceMode mode

Status Esp32s3PcmAudioPorts::Impl::InputPort::StopCapture() { return owner_.StopCapture(); }

Status Esp32s3PcmAudioPorts::Impl::InputPort::DiscardPendingInput() { return owner_.DiscardPendingInput(); }

void Esp32s3PcmAudioPorts::Impl::InputPort::Close() { (void)owner_.CloseInput(); }

Status Esp32s3PcmAudioPorts::Impl::OutputPort::Open(const voice::AudioFormat& format) {
Expand Down Expand Up @@ -537,6 +539,19 @@ Status Esp32s3PcmAudioPorts::Impl::StopCapture() {
#endif
}

Status Esp32s3PcmAudioPorts::Impl::DiscardPendingInput() {
#ifndef ESP_PLATFORM
return detail::Unavailable("ESP32-S3 PCM Audio Port 只能在 ESP-IDF 目标运行");
#else
std::lock_guard<std::mutex> lock(mutex_);
const std::size_t queued = input_queue_.size();
input_queue_.clear();
if (assembler_) assembler_->Reset();
ESP_LOGI(detail::kAudioRuntimeTag, "INPUT_BOUNDARY_RESET queued=%u", static_cast<unsigned>(queued));
return Status::Ok();
#endif
}

Status Esp32s3PcmAudioPorts::Impl::CloseInput() {
const Status stop_status = StopCapture();
#ifdef ESP_PLATFORM
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class Esp32s3PcmAudioPorts::Impl final {
Status Open(const voice::AudioFormat& format) override;
Status StartCapture(voice::VoiceMode mode) override;
Status StopCapture() override;
Status DiscardPendingInput() override;
void Close() override;

private:
Expand Down Expand Up @@ -107,6 +108,7 @@ class Esp32s3PcmAudioPorts::Impl final {
Status OpenOutput(const voice::AudioFormat& format);
Status StartCapture(voice::VoiceMode mode);
Status StopCapture();
Status DiscardPendingInput();
Status CloseInput();
Status PushOutput(voice::AudioFrame frame);
Status FlushOutput();
Expand Down
1 change: 1 addition & 0 deletions components/voicelife_linx/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ idf_component_register(
"../../third_party/cjson/cJSON.c"
INCLUDE_DIRS "include" "../../third_party/cjson"
REQUIRES voicelife_contracts voicelife_voice
PRIV_REQUIRES pthread
)
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ class LinxSpeechProviderAdapter final : public voice::SpeechProviderAdapter {
[[nodiscard]] voice::VoiceSessionConfig ActiveSessionConfig() const;
Status Send(Result<std::string> encoded);
void Emit(voice::VoiceEvent event);

LinxTransportPort& transport_;
LinxProtocolCodecPort& codec_;
LinxConnectionConfig connection_;
Expand Down
15 changes: 10 additions & 5 deletions components/voicelife_linx/include/voicelife/linx/linx_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ namespace voicelife::linx {

/** 保存 Linx WebSocket 连接所需的非敏感配置引用。 */
struct LinxConnectionConfig {
// Linx uses this value to choose its downstream send strategy. Keep it in
// the same latency budget as the board playback queue rather than deriving
// an unbounded duration from the negotiated packet size.
static constexpr uint32_t kDefaultPlaybackBufferDurationMs = 200;
// Linx uses this value to choose its downstream send strategy. The
// WebSocket contract documents 1000 ms as the PCM default; keeping the
// negotiated value aligned avoids a server-side strategy mismatch.
static constexpr uint32_t kDefaultPlaybackBufferDurationMs = 1000;

std::string websocket_url;
// A reference such as secret://linx/device-token. The resolved token is
Expand All @@ -26,14 +26,19 @@ struct LinxConnectionConfig {
std::string client_id;
std::optional<std::string> agent_id;
uint32_t playback_buffer_duration_ms = kDefaultPlaybackBufferDurationMs;
// Physical audio remains PCM at the board boundary. When set, this is the
// wire format advertised in hello and handled by the Linx provider codec.
// Keeping the two formats separate lets the local wake detector and serial
// PCM fixture continue to operate without exposing encoded frames to them.
std::optional<voice::AudioFormat> preferred_audio;

/**
* @brief 校验连接配置是否完整。
* @return 必需字段均非空时返回 true。
*/
[[nodiscard]] bool valid() const {
return !websocket_url.empty() && !token_ref.empty() && !device_id.empty() && !client_id.empty() &&
playback_buffer_duration_ms > 0;
playback_buffer_duration_ms > 0 && (!preferred_audio.has_value() || preferred_audio->valid());
}
};

Expand Down
25 changes: 15 additions & 10 deletions components/voicelife_linx/src/linx_json_codec.cc
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ Result<std::string> LinxJsonCodec::EncodeHello(const voice::VoiceSessionConfig&
if (!config.audio.valid() || !connection.valid()) {
return Result<std::string>::Failure(ErrorCode::kInvalidArgument, "Linx hello 音频参数无效");
}
const voice::AudioFormat wire_audio = connection.preferred_audio.value_or(config.audio);
if (!wire_audio.valid()) {
return Result<std::string>::Failure(ErrorCode::kInvalidArgument, "Linx hello 线上音频参数无效");
}
JsonPtr root(cJSON_CreateObject());
cJSON_AddStringToObject(root.get(), "type", "hello");
cJSON_AddNumberToObject(root.get(), "version", 1);
Expand All @@ -125,15 +129,15 @@ Result<std::string> LinxJsonCodec::EncodeHello(const voice::VoiceSessionConfig&
cJSON_AddStringToObject(root.get(), "transport", "websocket");

cJSON* audio = cJSON_AddObjectToObject(root.get(), "audio_params");
cJSON_AddStringToObject(audio, "format", CodecName(config.audio.codec));
cJSON_AddNumberToObject(audio, "sample_rate", config.audio.sample_rate_hz);
cJSON_AddNumberToObject(audio, "channels", config.audio.channels);
cJSON_AddNumberToObject(audio, "bit_depth", config.audio.bits_per_sample);
cJSON_AddStringToObject(audio, "endianness", "little");
cJSON_AddNumberToObject(audio, "frame_duration", config.audio.frame_duration_ms);

if (config.audio.codec == voice::AudioCodec::kPcmS16Le) {
const uint32_t frame_size = config.audio.sample_rate_hz * config.audio.frame_duration_ms / 1000U;
cJSON_AddStringToObject(audio, "format", CodecName(wire_audio.codec));
cJSON_AddNumberToObject(audio, "sample_rate", wire_audio.sample_rate_hz);
cJSON_AddNumberToObject(audio, "channels", wire_audio.channels);
cJSON_AddNumberToObject(audio, "frame_duration", wire_audio.frame_duration_ms);

if (wire_audio.codec == voice::AudioCodec::kPcmS16Le) {
cJSON_AddNumberToObject(audio, "bit_depth", wire_audio.bits_per_sample);
cJSON_AddStringToObject(audio, "endianness", "little");
const uint32_t frame_size = wire_audio.sample_rate_hz * wire_audio.frame_duration_ms / 1000U;
cJSON_AddNumberToObject(audio, "frame_size", frame_size);
cJSON_AddStringToObject(audio, "sample_format", "signed_int16");
cJSON_AddNumberToObject(audio, "play_buffer_duration", connection.playback_buffer_duration_ms);
Expand Down Expand Up @@ -165,7 +169,8 @@ Result<std::string> LinxJsonCodec::EncodeListenStop(const voice::VoiceSessionCon
JsonPtr root(cJSON_CreateObject());
cJSON_AddStringToObject(root.get(), "type", "listen");
cJSON_AddStringToObject(root.get(), "state", "stop");
cJSON_AddStringToObject(root.get(), "mode", ModeName(config.mode));
// Linx defines mode on listen.start only. Keep stop to the documented
// shape used by the reference SparkBot client.
if (!config.session_id.empty()) {
cJSON_AddStringToObject(root.get(), "session_id", config.session_id.c_str());
}
Expand Down
3 changes: 2 additions & 1 deletion components/voicelife_linx/src/linx_ota.cc
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ Result<LinxConnectionConfig> BuildLinxConnectionConfig(const LinxOtaResponse& re
.token_ref = std::string(token_reference),
.device_id = std::string(device_id),
.client_id = std::string(client_id),
.agent_id = std::nullopt};
.agent_id = std::nullopt,
.preferred_audio = std::nullopt};
if (!config.valid()) {
return Result<LinxConnectionConfig>::Failure(ErrorCode::kInvalidArgument, "Linx OTA 连接配置无效");
}
Expand Down
Loading
Loading