From 0b76472b962d3bfd73f024d057bb38c451c40e0e Mon Sep 17 00:00:00 2001 From: eafonyang Date: Mon, 6 Jul 2026 18:09:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=A2=9E=E5=BC=BA=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E7=8A=B6=E6=80=81=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8F=8APEQ=E8=AF=B7=E6=B1=82=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增subwoofer相关参数及hearing数据支持 - 添加parseHearingData函数用于解析听力配置数据 - 扩展INPUT_LABELS支持HDMI-EARC,并新增Bluetooth和Headset索引常量 - 改进POST请求错误处理,增加请求失败时抛出异常 - 调整bootSound文档说明,指向固件特性配置 - 更新firmware版本格式化显示规则 - 增加音量直通模式和静音锁定逻辑的辅助函数 - 移除无用的dreMode和LED相关字段 - 更新MOCK_DEVICE_STATE样例数据匹配新结构和默认值 --- client/src/lib/luxsinApi.ts | 149 +++++++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 43 deletions(-) diff --git a/client/src/lib/luxsinApi.ts b/client/src/lib/luxsinApi.ts index ceb9b73..185c384 100644 --- a/client/src/lib/luxsinApi.ts +++ b/client/src/lib/luxsinApi.ts @@ -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; + 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 { await this.postPeqJson(body); } /** POST `json=` — matches legacy axios `upgradePeq`. */ private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise { + 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 = {