feat(api): 增强设备状态数据结构及PEQ请求处理

- 新增subwoofer相关参数及hearing数据支持
- 添加parseHearingData函数用于解析听力配置数据
- 扩展INPUT_LABELS支持HDMI-EARC,并新增Bluetooth和Headset索引常量
- 改进POST请求错误处理,增加请求失败时抛出异常
- 调整bootSound文档说明,指向固件特性配置
- 更新firmware版本格式化显示规则
- 增加音量直通模式和静音锁定逻辑的辅助函数
- 移除无用的dreMode和LED相关字段
- 更新MOCK_DEVICE_STATE样例数据匹配新结构和默认值
This commit is contained in:
eafonyang
2026-07-06 18:09:14 +08:00
parent 6261ab02dc
commit 0b76472b96
+106 -43
View File
@@ -28,11 +28,8 @@ export function decodeCustomBase64(encoded: string): string {
const index = ALPHABET_CUSTOM.indexOf(char);
translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char;
}
// Strip whitespace / newlines that may be present in API responses.
// Chrome 91 atob() is stricter than modern browsers and rejects these.
// Chrome 91 atob() is stricter than modern browsers.
translated = translated.replace(/\s/g, "");
// Ensure correct Base64 padding (=). Modern atob() tolerates missing padding,
// but Chrome 91 throws "The string to be decoded is not correctly encoded".
const padNeeded = (4 - (translated.length % 4)) % 4;
if (padNeeded > 0) {
translated += "=".repeat(padNeeded);
@@ -98,7 +95,6 @@ export interface DeviceState {
dacGain: number;
dacArc: number;
dacImpedance: number;
dreMode: number;
dacVolumeDirect: number;
analogGain: number;
effect_enable: number;
@@ -113,19 +109,56 @@ export interface DeviceState {
subwoofer_value: number;
subwoofer_rate: number;
subwoofer_gain: number;
subwoofer_mix_type: number;
subwoofer_delay: number;
subwoofer_delay_main: number;
subwoofer_delay_r: number;
subwoofer_delay_main_r: number;
subwoofer_lpf_enable: number;
subwoofer_hpf_enable: number;
loudness_enable: number;
loudness_bass_gain: number;
loudness_treble_gain: number;
loudness_threshold_gain: number;
hearing_enable: number;
hearing_select: number;
hearing_data: HearingProfile[] | string;
bt_status: number;
bt_srcname: string;
bt_title: string;
bt_artist: string;
msgCount: number;
led_enable: number;
led_red: number;
led_green: number;
led_blue: number;
}
export interface HearingProfile {
n: string;
l: number[];
r: number[];
}
/** Parse `hearing_data` from syncData (array or JSON string). */
export function parseHearingData(raw: unknown): HearingProfile[] {
if (raw == null || raw === "") return [];
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const n = typeof row.n === "string" ? row.n.trim() : String(row.n ?? "").trim();
if (!n) return null;
const l = Array.isArray(row.l) ? row.l.map((v) => Number(v)) : [];
const r = Array.isArray(row.r) ? row.r.map((v) => Number(v)) : [];
return { n, l, r };
})
.filter((item): item is HearingProfile => item !== null);
}
export interface PeqFilter {
@@ -138,7 +171,7 @@ export interface PeqFilter {
export interface PeqState {
filters: PeqFilter[];
peqSelect?: number;
peqEnable?: number; // 0: off, 1: on
peqEnable?: number;
peq?: Array<{
name: string;
filters?: PeqFilter[] | string;
@@ -210,8 +243,14 @@ export interface PeqApplyPayload {
// ============================================================
// Input/Output Labels
// ============================================================
export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"];
export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "HDMI-EARC", "RCA"];
export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"];
/** Device `input` index for Bluetooth source. */
export const INPUT_BLUETOOTH_INDEX = 4;
/** Device `output` index for headphone / headset. */
export const OUTPUT_HEADSET_INDEX = 2;
export const LANGUAGE_LABELS = ["English", "繁體中文", "简体中文"];
export const SCREEN_LIGHT_LABELS = ["Bright", "Medium", "Dark"];
export const KNOB_LIGHT_LABELS = ["Off", "Bright", "Medium", "Dark"];
@@ -263,7 +302,7 @@ export class LuxsinAPI {
const text = await response.text();
const decoded = decodeCustomBase64(text.trim());
const state = JSON.parse(decoded) as DeviceState;
console.log("[syncData]", state);
console.log("[syncData] decoded", state);
return state;
} catch (error) {
this.maybeRedirectForHttpsCert(error);
@@ -304,21 +343,29 @@ export class LuxsinAPI {
await this.postPeqJson(body);
}
/** Apply current EQ filters (e.g. A/B curve switch) without a full preset save. */
/** Apply current EQ filters (e.g. A/B comparison curve) without saving preset metadata. */
async upgradePeqApply(body: PeqApplyPayload): Promise<void> {
await this.postPeqJson(body);
}
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
if (import.meta.env.DEV) {
const action =
"peqChange" in body ? "peqChange" : "peqApply" in body ? "peqApply" : "peq";
console.log(`[dev/info.cgi] POST ${action}`, body);
}
const encoded = encodeCustomBase64(JSON.stringify(body));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
});
if (!response.ok) {
throw new Error(`PEQ request failed: ${response.status}`);
}
}
/** Remove one or more headphone PEQ profiles. */
@@ -326,11 +373,14 @@ export class LuxsinAPI {
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
const response = await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
});
if (!response.ok) {
throw new Error(`PEQ remove failed: ${response.status}`);
}
}
setVolume(volume: number) { return this.setSetting({ volume }); }
@@ -360,7 +410,7 @@ export class LuxsinAPI {
setCrossfeedEnable(enable: boolean) { return this.setSetting({ crossfeed_enable: enable ? 1 : 0 }); }
setXlrPolarity(reverse: boolean) { return this.setSetting({ xlr: reverse ? 1 : 0 }); }
setDacArc(earc: boolean) { return this.setSetting({ dacArc: earc ? 1 : 0 }); }
/** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (firmware >= 26). */
/** Boot volume level: 0 = default, 1..6 = -5..-30 dB, 7..10 = -35..-50 dB (see firmwareFeatures.bootSoundExtendedSteps). */
setBootSound(level: number) {
return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) });
}
@@ -368,7 +418,7 @@ export class LuxsinAPI {
powerOff() { return this.setSetting({ power: 0 }); }
}
/** Parse firmware `version` for feature gating (e.g. bootSound extended steps). */
/** Parse firmware `version` for feature gating (see `config/firmwareFeatures.ts`). */
export function parseFirmwareVersion(version: unknown): number {
if (version === null || version === undefined) return 0;
if (typeof version === "number" && Number.isFinite(version)) return version;
@@ -381,32 +431,34 @@ export function parseFirmwareVersion(version: unknown): number {
return Number(parts[parts.length - 1]);
}
/** UI display: build number 26 → `1.0.0.26`. Already-prefixed values are unchanged. */
/** Display firmware version as X.Y.0.Z (e.g. 2005 → 2.0.0.5). */
export function formatFirmwareVersionDisplay(version: unknown): string {
if (version === null || version === undefined) return "";
const raw = String(version).trim();
if (!raw) return "—";
if (/^1\.0\.0\./i.test(raw)) return raw;
return `1.0.0.${raw}`;
if (version === null || version === undefined || String(version).trim() === "") {
return "—";
}
const n = parseFirmwareVersion(version);
const major = Math.floor(n / 1000);
const minor = Math.floor((n % 1000) / 100);
const patch = 0;
const build = n % 10;
return `${major}.${minor}.${patch}.${build}`;
}
/** Max `bootSound` index available for the current firmware. */
export function getBootSoundMaxIndex(firmwareVersion: number): number {
return firmwareVersion >= 26 ? 10 : 6;
/** Pre-out volume passthrough mode selected (0dB or -12dB). */
export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean {
return dacVolumeDirect === 1 || dacVolumeDirect === 2;
}
/** Ambient LED block on System page (firmware build 28+). */
export const AMBIENT_LED_MIN_FIRMWARE_VERSION = 28;
export function supportsAmbientLed(firmwareVersion: number): boolean {
return firmwareVersion >= AMBIENT_LED_MIN_FIRMWARE_VERSION;
/** Lock home volume controls when passthrough is on, except output is headphone. */
export function isHomeVolumeLockedByPassthrough(
dacVolumeDirect: number | undefined,
output: number | undefined,
): boolean {
if (!isVolumePassthroughActive(dacVolumeDirect)) return false;
return (output ?? -1) !== OUTPUT_HEADSET_INDEX;
}
export function clampLedChannel(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(255, Math.round(n)));
}
export { getBootSoundMaxIndex } from "@/config/firmwareFeatures";
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
export function normalizeBootSound(value: unknown): number {
@@ -425,8 +477,8 @@ export function readBootSoundFromState(state: DeviceState | null | undefined): n
// Mock data for demo/offline mode
// ============================================================
export const MOCK_DEVICE_STATE: DeviceState = {
device: "Luxsin-X8",
version: 28,
device: "Luxsin-X9",
version: "1.2.3",
mac: "AA:BB:CC:DD:EE:FF",
language: 0,
volume: 75,
@@ -457,7 +509,6 @@ export const MOCK_DEVICE_STATE: DeviceState = {
dacGain: 0,
dacArc: 0,
dacImpedance: 0,
dreMode: 0,
dacVolumeDirect: 0,
analogGain: 0,
effect_enable: 0,
@@ -472,19 +523,31 @@ export const MOCK_DEVICE_STATE: DeviceState = {
subwoofer_value: 80,
subwoofer_rate: 0,
subwoofer_gain: 0,
subwoofer_mix_type: 0,
subwoofer_delay: 582,
subwoofer_delay_main: 571,
subwoofer_delay_r: 582,
subwoofer_delay_main_r: 571,
subwoofer_lpf_enable: 0,
subwoofer_hpf_enable: 0,
loudness_enable: 0,
loudness_bass_gain: 3,
loudness_treble_gain: 2,
loudness_threshold_gain: 60,
hearing_enable: 0,
hearing_select: 0,
hearing_data: [
{
n: "testing",
l: [1.7, 0.8, 0.5, 0.5, 0.5, 0.5, 0.4],
r: [0.4, 0.4, 0.3, 0.3, 0.2, 0.4, 0.3],
},
],
bt_status: 1,
bt_srcname: "iPhone 15 Pro",
bt_title: "Bohemian Rhapsody",
bt_artist: "Queen",
msgCount: 42,
led_enable: 1,
led_red: 128,
led_green: 64,
led_blue: 200,
};
export const MOCK_PEQ_STATE: PeqState = {