1046 lines
35 KiB
TypeScript
1046 lines
35 KiB
TypeScript
// ============================================================
|
||
// LUXSIN X9 API — Client Library
|
||
// Custom Base64 decode + HTTP API wrappers
|
||
// ============================================================
|
||
|
||
const ALPHABET_CUSTOM = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/";
|
||
const ALPHABET_STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
|
||
// ============================================================
|
||
// Volume Conversion: dB (-100 to 0) <-> Device Value (0 to 200)
|
||
// ============================================================
|
||
export function dbToVolume(db: number): number {
|
||
// Formula: volume = 2 * db + 200
|
||
// e.g., -56dB -> 2 * (-56) + 200 = 88
|
||
return Math.round(2 * db + 200);
|
||
}
|
||
|
||
export function volumeToDb(vol: number): number {
|
||
// Formula: db = (vol - 200) / 2
|
||
// e.g., 88 -> (88 - 200) / 2 = -56dB
|
||
return (vol - 200) / 2;
|
||
}
|
||
|
||
export function decodeCustomBase64(encoded: string): string {
|
||
let translated = "";
|
||
for (let i = 0; i < encoded.length; i++) {
|
||
const char = encoded.charAt(i);
|
||
const index = ALPHABET_CUSTOM.indexOf(char);
|
||
translated += index !== -1 ? ALPHABET_STANDARD.charAt(index) : char;
|
||
}
|
||
// Chrome 91 atob() is stricter than modern browsers.
|
||
translated = translated.replace(/\s/g, "");
|
||
const padNeeded = (4 - (translated.length % 4)) % 4;
|
||
if (padNeeded > 0) {
|
||
translated += "=".repeat(padNeeded);
|
||
}
|
||
const decoded = atob(translated);
|
||
const bytes = new Uint8Array(decoded.length);
|
||
for (let i = 0; i < decoded.length; i++) {
|
||
bytes[i] = decoded.charCodeAt(i);
|
||
}
|
||
return new TextDecoder("utf-8").decode(bytes);
|
||
}
|
||
|
||
export function encodeCustomBase64(data: string): string {
|
||
// btoa only supports Latin1; convert UTF-8 bytes to a binary string first.
|
||
const bytes = new TextEncoder().encode(data);
|
||
let binary = "";
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
const standard = btoa(binary);
|
||
let translated = "";
|
||
for (let i = 0; i < standard.length; i++) {
|
||
const char = standard.charAt(i);
|
||
const index = ALPHABET_STANDARD.indexOf(char);
|
||
translated += index !== -1 ? ALPHABET_CUSTOM.charAt(index) : char;
|
||
}
|
||
return translated;
|
||
}
|
||
|
||
// ============================================================
|
||
// Device State Type
|
||
// ============================================================
|
||
export interface DeviceState {
|
||
device: string;
|
||
version: string | number;
|
||
mac: string;
|
||
language: number;
|
||
volume: number;
|
||
soundStep: number;
|
||
input: number;
|
||
output: number;
|
||
audioFormat: string;
|
||
pcm: number;
|
||
hdmimutepolar: number;
|
||
hdmiType: number;
|
||
vu: number;
|
||
vuSensor: number;
|
||
vu_count: number;
|
||
screenLight: number;
|
||
knob_breathlight: number;
|
||
buttonLight: number;
|
||
buttonShort: number;
|
||
screenOff: number;
|
||
sleep: number;
|
||
autoHome: number;
|
||
bootSound: number;
|
||
dsp_enable: number;
|
||
audio_enable: number;
|
||
peqEnable: number;
|
||
peqSelect: number;
|
||
balance: number;
|
||
xlr: number;
|
||
dacGain: number;
|
||
dacArc: number;
|
||
dacImpedance: number;
|
||
/** 耳机最大音量:0 = 0dB,1 = -5dB … 10 = -50dB;旧固件可能不返回。 */
|
||
hpMaxVolume?: number;
|
||
/** 耳机启动音量:0 = 默认,1 = -20dB … 7 = -50dB;旧固件可能不返回。 */
|
||
hpStartupVolume?: number;
|
||
dacVolumeDirect: number;
|
||
analogGain: number;
|
||
effect_enable: number;
|
||
effect_value: number;
|
||
width_enable: number;
|
||
width_value: number;
|
||
scene_enable: number;
|
||
scene_value: number;
|
||
crossfeed_enable: number;
|
||
crossfeed_value: number;
|
||
/** Custom crossfeed cut-off frequency in Hz (500–1000, step 1). */
|
||
crossfeed_custom_fc: number;
|
||
/** Custom crossfeed attenuation, stored as dB×10 (1.0 dB=10 … 15.0 dB=150). */
|
||
crossfeed_custom_gain: number;
|
||
subwoofer_enable: number;
|
||
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;
|
||
}
|
||
|
||
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 {
|
||
fc: number;
|
||
gain: number;
|
||
q: number;
|
||
type: number;
|
||
}
|
||
|
||
export interface PeqState {
|
||
filters: PeqFilter[];
|
||
peqSelect?: number;
|
||
peqEnable?: number;
|
||
peq?: Array<{
|
||
name: string;
|
||
filters?: PeqFilter[] | string;
|
||
autoPre?: number;
|
||
preamp?: number;
|
||
canDel?: number;
|
||
brand?: string;
|
||
model?: string;
|
||
target?: string;
|
||
form?: string;
|
||
}>;
|
||
}
|
||
|
||
/** Shared PEQ preset fields for device POST bodies. */
|
||
export interface PeqPresetBody {
|
||
name: string;
|
||
filters: PeqFilter[];
|
||
autoPre?: number;
|
||
preamp?: number;
|
||
canDel?: number;
|
||
brand?: string;
|
||
model?: string;
|
||
target?: string;
|
||
form?: string;
|
||
}
|
||
|
||
/** PEQ preset metadata used when building peqChange / peqApply bodies. */
|
||
export type PeqPresetMeta = {
|
||
name: string;
|
||
autoPre?: number;
|
||
preamp?: number;
|
||
canDel?: number;
|
||
brand?: string;
|
||
model?: string;
|
||
target?: string;
|
||
form?: string;
|
||
};
|
||
|
||
/** Build device PEQ POST body; optional brand/model/target/form included only when non-empty. */
|
||
export function buildPeqPresetBody(peq: PeqPresetMeta, filters: PeqFilter[]): PeqPresetBody {
|
||
const body: PeqPresetBody = {
|
||
name: peq.name,
|
||
filters,
|
||
autoPre: peq.autoPre ?? 0,
|
||
preamp: peq.preamp ?? 0,
|
||
canDel: peq.canDel ?? 1,
|
||
};
|
||
const brand = peq.brand?.trim();
|
||
const model = peq.model?.trim();
|
||
const target = peq.target?.trim();
|
||
const form = peq.form?.trim();
|
||
if (brand) body.brand = brand;
|
||
if (model) body.model = model;
|
||
if (target) body.target = target;
|
||
if (form) body.form = form;
|
||
return body;
|
||
}
|
||
|
||
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
|
||
export interface PeqChangePayload {
|
||
peqChange: PeqPresetBody;
|
||
}
|
||
|
||
/** POST body for `/dev/info.cgi` — apply A/B comparison curve without saving preset metadata. */
|
||
export interface PeqApplyPayload {
|
||
peqApply: PeqPresetBody;
|
||
}
|
||
|
||
/** POST body for `/dev/info.cgi` — rename an existing PEQ preset. */
|
||
export interface PeqRenamePayload {
|
||
peqRename: {
|
||
oldName: string;
|
||
newName: string;
|
||
};
|
||
}
|
||
|
||
/** POST body for `/dev/info.cgi` — reorder PEQ presets. */
|
||
export interface PeqSoftPayload {
|
||
peqSoft: string[];
|
||
peqSelect: number;
|
||
}
|
||
|
||
// ============================================================
|
||
// Input/Output Labels
|
||
// ============================================================
|
||
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"];
|
||
export const SCREEN_OFF_LABELS = ["Off", "30s", "1 min", "3 min", "5 min"];
|
||
export const SLEEP_LABELS = ["Off", "1 min", "5 min", "10 min"];
|
||
export const AUTO_HOME_LABELS = ["Off", "20s", "40s", "60s"];
|
||
export const FILTER_TYPE_LABELS = ["Low Pass", "High Pass", "Band Pass", "Notch", "Peak", "Low Shelf", "High Shelf", "All Pass"];
|
||
export const DAC_ARC_LABELS = ["ARC", "eARC"];
|
||
export const BT_STATUS_LABELS = ["Disconnected", "Playing", "Paused"];
|
||
|
||
function resolveDeviceProtocol(): "http" | "https" {
|
||
if (typeof window !== "undefined" && window.location.protocol === "https:") {
|
||
return "https";
|
||
}
|
||
return "http";
|
||
}
|
||
|
||
function normalizeDeviceHost(input: string): string {
|
||
return input.trim().replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
||
}
|
||
|
||
// ============================================================
|
||
// API Class
|
||
// ============================================================
|
||
export class LuxsinAPI {
|
||
private baseUrl: string;
|
||
private peqRequestQueue: Promise<void> = Promise.resolve();
|
||
|
||
/** EQ 写入、试听、选择和目录读取按调用顺序执行;单次失败不阻塞后续重试。 */
|
||
private enqueuePeqRequest<T>(request: () => Promise<T>): Promise<T> {
|
||
const result = this.peqRequestQueue.then(request);
|
||
this.peqRequestQueue = result.then(() => undefined, () => undefined);
|
||
return result;
|
||
}
|
||
|
||
constructor(ip: string) {
|
||
const host = normalizeDeviceHost(ip);
|
||
const protocol = resolveDeviceProtocol();
|
||
this.baseUrl = `${protocol}://${host}`;
|
||
}
|
||
|
||
private maybeRedirectForHttpsCert(error: unknown) {
|
||
// On HTTPS pages, first-time device access may fail before user trusts the cert.
|
||
if (typeof window === "undefined") return;
|
||
if (window.location.protocol !== "https:") return;
|
||
if (!(error instanceof Error)) return;
|
||
// fetch network failure is typically reported as TypeError/Failed to fetch.
|
||
const msg = (error.message || "").toLowerCase();
|
||
if (error.name === "TypeError" || msg.includes("failed to fetch") || msg.includes("networkerror")) {
|
||
window.location.href = this.baseUrl;
|
||
}
|
||
}
|
||
|
||
async getDeviceState(): Promise<DeviceState> {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncData`);
|
||
const text = await response.text();
|
||
const decoded = decodeCustomBase64(text.trim());
|
||
const state = JSON.parse(decoded) as DeviceState;
|
||
console.log("[syncData] decoded", state);
|
||
return state;
|
||
} catch (error) {
|
||
this.maybeRedirectForHttpsCert(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getMsgCount(): Promise<number> {
|
||
const response = await fetch(`${this.baseUrl}/msgCount`);
|
||
const text = await response.text();
|
||
return parseInt(text.trim(), 10);
|
||
}
|
||
|
||
async refreshState(): Promise<void> {
|
||
await fetch(`${this.baseUrl}/msgAdd`);
|
||
}
|
||
|
||
async setSetting(params: Record<string, string | number>): Promise<void> {
|
||
const request = async () => {
|
||
const query = Object.entries(params)
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join("&");
|
||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
|
||
if (!response.ok) throw new Error(`PEQ request failed: ${response.status}`);
|
||
};
|
||
if (params.peqSelect !== undefined) return this.enqueuePeqRequest(request);
|
||
return request();
|
||
}
|
||
|
||
async getPeqState(): Promise<PeqState> {
|
||
return this.enqueuePeqRequest(async () => {
|
||
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
|
||
if (!response.ok) throw new Error(`PEQ read failed: ${response.status}`);
|
||
const text = await response.text();
|
||
const decoded = decodeCustomBase64(text.trim());
|
||
return JSON.parse(decoded) as PeqState;
|
||
});
|
||
}
|
||
|
||
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
|
||
await this.postPeqJson({ peqChange: { filters } } as PeqChangePayload);
|
||
}
|
||
|
||
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
||
async upgradePeqChange(body: PeqChangePayload): Promise<void> {
|
||
await this.postPeqJson(body);
|
||
}
|
||
|
||
/** 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> {
|
||
return this.enqueuePeqRequest(async () => {
|
||
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);
|
||
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. */
|
||
async removePeq(names: string[]): Promise<void> {
|
||
return this.enqueuePeqRequest(async () => {
|
||
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||
const form = new URLSearchParams();
|
||
form.set("json", encoded);
|
||
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}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Rename a headphone PEQ profile. */
|
||
async renamePeq(oldName: string, newName: string): Promise<void> {
|
||
return this.enqueuePeqRequest(async () => {
|
||
const payload: PeqRenamePayload = { peqRename: { oldName, newName } };
|
||
const encoded = encodeCustomBase64(JSON.stringify(payload));
|
||
const form = new URLSearchParams();
|
||
form.set("json", encoded);
|
||
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 rename failed: ${response.status}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Reorder headphone PEQ profiles. */
|
||
async sortPeq(names: string[], peqSelect: number): Promise<void> {
|
||
return this.enqueuePeqRequest(async () => {
|
||
const payload: PeqSoftPayload = { peqSoft: names, peqSelect };
|
||
const encoded = encodeCustomBase64(JSON.stringify(payload));
|
||
const form = new URLSearchParams();
|
||
form.set("json", encoded);
|
||
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 sort failed: ${response.status}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
setVolume(volume: number) { return this.setSetting({ volume }); }
|
||
setInput(input: number) { return this.setSetting({ input }); }
|
||
setOutput(output: number) { return this.setSetting({ output }); }
|
||
setBalance(balance: number) { return this.setSetting({ balance }); }
|
||
setLanguage(language: number) { return this.setSetting({ language }); }
|
||
setDspEnable(enable: boolean) { return this.setSetting({ dsp_enable: enable ? 0 : 1 }); }
|
||
setAudioEnable(enable: boolean) { return this.setSetting({ audio_enable: enable ? 1 : 0 }); }
|
||
setPeqEnable(enable: boolean) { return this.setSetting({ peqEnable: enable ? 1 : 0 }); }
|
||
setPeqSelect(index: number) { return this.setSetting({ peqSelect: index }); }
|
||
setVu(vu: number) { return this.setSetting({ vu }); }
|
||
setScreenLight(level: number) { return this.setSetting({ screenLight: level }); }
|
||
setKnobLight(level: number) { return this.setSetting({ knob_breathlight: level }); }
|
||
setButtonLight(on: boolean) { return this.setSetting({ buttonLight: on ? 0 : 1 }); }
|
||
setScreenOff(timer: number) { return this.setSetting({ screenOff: timer }); }
|
||
setSleep(timer: number) { return this.setSetting({ sleep: timer }); }
|
||
setAutoHome(timer: number) { return this.setSetting({ autoHome: timer }); }
|
||
setLoudnessEnable(enable: boolean) { return this.setSetting({ loudness_enable: enable ? 1 : 0 }); }
|
||
setSubwooferEnable(enable: boolean) { return this.setSetting({ subwoofer_enable: enable ? 1 : 0 }); }
|
||
btPlayPause() { return this.setSetting({ bt_play: 1 }); }
|
||
btNext() { return this.setSetting({ bt_next: 1 }); }
|
||
btPrev() { return this.setSetting({ bt_next: 0 }); }
|
||
setEffectEnable(enable: boolean) { return this.setSetting({ effect_enable: enable ? 1 : 0 }); }
|
||
setWidthEnable(enable: boolean) { return this.setSetting({ width_enable: enable ? 1 : 0 }); }
|
||
setSceneEnable(enable: boolean) { return this.setSetting({ scene_enable: enable ? 1 : 0 }); }
|
||
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 (see firmwareFeatures.bootSoundExtendedSteps). */
|
||
setBootSound(level: number) {
|
||
return this.setSetting({ bootSound: Math.max(0, Math.min(10, Math.round(level))) });
|
||
}
|
||
/** Device power off — `GET .../dev/info.cgi?action=setting&power=0` */
|
||
powerOff() { return this.setSetting({ power: 0 }); }
|
||
}
|
||
|
||
/** 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;
|
||
const text = String(version).trim();
|
||
if (!text) return 0;
|
||
const direct = Number(text);
|
||
if (Number.isFinite(direct)) return direct;
|
||
const parts = text.match(/\d+/g);
|
||
if (!parts?.length) return 0;
|
||
return Number(parts[parts.length - 1]);
|
||
}
|
||
|
||
/**
|
||
* Display firmware version.
|
||
* n < 2010: X.Y.0.Z (e.g. 2005 → 2.0.0.5).
|
||
* n >= 2010: third segment carries the two-digit tail (e.g. 2010 → 2.0.10.0, 2011 → 2.0.11.0).
|
||
*/
|
||
export function formatFirmwareVersionDisplay(version: unknown): string {
|
||
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);
|
||
if (n >= 2010) {
|
||
// Since 2.0.10.0 the third segment counts up (09 → 10), fourth segment stays 0.
|
||
return `${major}.${minor}.${n % 100}.0`;
|
||
}
|
||
const patch = 0;
|
||
const build = n % 10;
|
||
return `${major}.${minor}.${patch}.${build}`;
|
||
}
|
||
|
||
/** Pre-out volume passthrough mode selected (0dB or -12dB). */
|
||
export function isVolumePassthroughActive(dacVolumeDirect: number | undefined): boolean {
|
||
return dacVolumeDirect === 1 || dacVolumeDirect === 2;
|
||
}
|
||
|
||
/** 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 { getBootSoundMaxIndex } from "@/config/firmwareFeatures";
|
||
|
||
/** Normalize `bootSound` from syncData (0 = default … 10 = -50 dB). */
|
||
export function normalizeBootSound(value: unknown): number {
|
||
const n = typeof value === "number" ? value : Number(value);
|
||
if (!Number.isFinite(n)) return 0;
|
||
return Math.max(0, Math.min(10, Math.round(n)));
|
||
}
|
||
|
||
export function readBootSoundFromState(state: DeviceState | null | undefined): number {
|
||
if (!state) return 0;
|
||
const raw = state.bootSound ?? (state as Record<string, unknown>).bootsound;
|
||
return normalizeBootSound(raw);
|
||
}
|
||
|
||
// ============================================================
|
||
// Mock data for demo/offline mode
|
||
// ============================================================
|
||
export const MOCK_DEVICE_STATE: DeviceState = {
|
||
device: "Luxsin-X9",
|
||
version: "1.2.3",
|
||
mac: "AA:BB:CC:DD:EE:FF",
|
||
language: 0,
|
||
volume: 75,
|
||
soundStep: 2,
|
||
input: 0,
|
||
output: 0,
|
||
audioFormat: "PCM 44.1 KHz",
|
||
pcm: 1,
|
||
hdmimutepolar: 0,
|
||
hdmiType: 0,
|
||
vu: 0,
|
||
vuSensor: 0,
|
||
vu_count: 5,
|
||
screenLight: 0,
|
||
knob_breathlight: 1,
|
||
buttonLight: 0,
|
||
buttonShort: 0,
|
||
screenOff: 2,
|
||
sleep: 0,
|
||
autoHome: 1,
|
||
bootSound: 1,
|
||
dsp_enable: 0,
|
||
audio_enable: 1,
|
||
peqEnable: 1,
|
||
peqSelect: 0,
|
||
balance: 0,
|
||
xlr: 0,
|
||
dacGain: 0,
|
||
dacArc: 0,
|
||
dacImpedance: 0,
|
||
hpMaxVolume: 0,
|
||
hpStartupVolume: 0,
|
||
dacVolumeDirect: 0,
|
||
analogGain: 0,
|
||
effect_enable: 0,
|
||
effect_value: 50,
|
||
width_enable: 0,
|
||
width_value: 50,
|
||
scene_enable: 0,
|
||
scene_value: 0,
|
||
crossfeed_enable: 0,
|
||
crossfeed_value: 0,
|
||
crossfeed_custom_fc: 700,
|
||
crossfeed_custom_gain: 45,
|
||
subwoofer_enable: 0,
|
||
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,
|
||
};
|
||
|
||
export const MOCK_PEQ_STATE: PeqState = {
|
||
filters: [
|
||
{ fc: 80, gain: 3.0, q: 0.7, type: 5 },
|
||
{ fc: 250, gain: -1.5, q: 1.4, type: 4 },
|
||
{ fc: 1000, gain: 0.0, q: 1.0, type: 4 },
|
||
{ fc: 4000, gain: 2.0, q: 1.4, type: 4 },
|
||
{ fc: 12000, gain: 1.5, q: 0.7, type: 6 },
|
||
],
|
||
};
|
||
|
||
// ============================================================
|
||
// Luxsin cloud audio catalog (brands / models)
|
||
// Dev: proxied via Vite (`/luxsin-audio-api` → `/audio`) to avoid Origin-based 403.
|
||
// ============================================================
|
||
|
||
export interface LuxsinAudioBrand {
|
||
id: number;
|
||
name: string;
|
||
}
|
||
|
||
export interface LuxsinAudioModel {
|
||
id: number;
|
||
name: string;
|
||
form?: string;
|
||
}
|
||
|
||
export interface LuxsinAudioModelListItem {
|
||
brandName: string;
|
||
modelName: string;
|
||
form?: string;
|
||
source?: string;
|
||
}
|
||
|
||
export function buildLuxsinAudioUrl(resourcePath: string): string {
|
||
const path = resourcePath.replace(/^\//, "");
|
||
if (import.meta.env.DEV) {
|
||
return `/luxsin-audio-api/${path}`;
|
||
}
|
||
return `https://api.luxsin.com.cn/audio/${path}`;
|
||
}
|
||
|
||
/**
|
||
* Firmware version-name cache. `getVerName` results are stable for a given
|
||
* model+version, so we cache them in `localStorage` for 30 minutes to avoid
|
||
* hitting the cloud API every time the SystemPage is opened.
|
||
*/
|
||
const VER_NAME_CACHE_PREFIX = "luxsin:verName:";
|
||
const VER_NAME_CACHE_TTL_MS = 30 * 60 * 1000;
|
||
|
||
function verNameCacheKey(model: string, version: string): string {
|
||
return `${VER_NAME_CACHE_PREFIX}${model}:${version}`;
|
||
}
|
||
|
||
/**
|
||
* Synchronously read a cached firmware version name for `model`+`version`.
|
||
* Returns `null` when there is no entry, it is malformed, or it has expired
|
||
* (expired entries are dropped so the next fetch refreshes them). Storage
|
||
* access is best-effort — any failure (private mode / quota) yields `null`.
|
||
*/
|
||
export function readCachedFirmwareVersionName(
|
||
model: string,
|
||
version: string | number,
|
||
): string | null {
|
||
const m = String(model ?? "").trim();
|
||
const v = String(version ?? "").trim();
|
||
if (!m || !v) return null;
|
||
const key = verNameCacheKey(m, v);
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return null;
|
||
const parsed = JSON.parse(raw) as { verName?: unknown; ts?: unknown };
|
||
const verName = typeof parsed?.verName === "string" ? parsed.verName.trim() : "";
|
||
const ts = typeof parsed?.ts === "number" ? parsed.ts : 0;
|
||
if (!verName || !ts) return null;
|
||
if (Date.now() - ts > VER_NAME_CACHE_TTL_MS) {
|
||
localStorage.removeItem(key);
|
||
return null;
|
||
}
|
||
return verName;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writeCachedFirmwareVersionName(
|
||
model: string,
|
||
version: string,
|
||
verName: string,
|
||
): void {
|
||
try {
|
||
localStorage.setItem(
|
||
verNameCacheKey(model, version),
|
||
JSON.stringify({ verName, ts: Date.now() }),
|
||
);
|
||
} catch {
|
||
/* storage unavailable (private mode / quota) — caching is best-effort */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch the firmware display version name from the cloud catalog.
|
||
* `GET getVerName?model=<device>&version=<version>` → `{ code, msg, data: { verName } }`.
|
||
* Serves from the 30-minute `localStorage` cache when available; otherwise the
|
||
* protocol (http/https) follows the current page, matching the other
|
||
* `//api.luxsin.com.cn/audio` calls; dev goes through the Vite proxy to avoid
|
||
* Origin-based 403/CORS. Returns `null` when unavailable so callers can fall back.
|
||
*/
|
||
export async function fetchFirmwareVersionName(
|
||
model: string,
|
||
version: string | number,
|
||
): Promise<string | null> {
|
||
const m = String(model ?? "").trim();
|
||
const v = String(version ?? "").trim();
|
||
if (!m || !v) return null;
|
||
|
||
// Serve from the browser cache within the TTL to avoid frequent requests.
|
||
const cached = readCachedFirmwareVersionName(m, v);
|
||
if (cached) return cached;
|
||
|
||
const query = `getVerName?model=${encodeURIComponent(m)}&version=${encodeURIComponent(v)}`;
|
||
const url = import.meta.env.DEV
|
||
? `/luxsin-audio-api/${query}`
|
||
: `//api.luxsin.com.cn/audio/${query}`;
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) return null;
|
||
const json = (await res.json()) as { data?: { verName?: unknown } };
|
||
const verName = json?.data?.verName;
|
||
const result = typeof verName === "string" && verName.trim() ? verName.trim() : null;
|
||
if (result) writeCachedFirmwareVersionName(m, v, result);
|
||
return result;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Fetches `getBrand`; body is custom Base64 text, decodes to JSON array of `{ id, name }`. */
|
||
export async function fetchLuxsinAudioBrands(): Promise<LuxsinAudioBrand[]> {
|
||
const res = await fetch(buildLuxsinAudioUrl("getBrand"));
|
||
if (!res.ok) {
|
||
throw new Error(`getBrand HTTP ${res.status}`);
|
||
}
|
||
const body = (await res.text()).trim();
|
||
const json = decodeCustomBase64(body);
|
||
const data = JSON.parse(json) as unknown;
|
||
if (!Array.isArray(data)) {
|
||
throw new Error("getBrand: response is not an array");
|
||
}
|
||
return data
|
||
.map((row) => {
|
||
if (!row || typeof row !== "object") return null;
|
||
const r = row as { id?: unknown; name?: unknown };
|
||
const name = typeof r.name === "string" ? r.name : "";
|
||
if (!name) return null;
|
||
const id = typeof r.id === "number" ? r.id : Number(r.id);
|
||
return { id: Number.isFinite(id) ? id : 0, name };
|
||
})
|
||
.filter((b): b is LuxsinAudioBrand => b !== null);
|
||
}
|
||
|
||
/** Fetches `getModel?brandName=...`; body is custom Base64 text, decodes to JSON array of model rows. */
|
||
export async function fetchLuxsinAudioModels(brandName: string): Promise<LuxsinAudioModel[]> {
|
||
const brand = brandName.trim();
|
||
if (!brand) return [];
|
||
|
||
const res = await fetch(buildLuxsinAudioUrl(`getModel?brandName=${encodeURIComponent(brand)}`));
|
||
if (!res.ok) {
|
||
throw new Error(`getModel HTTP ${res.status}`);
|
||
}
|
||
const body = (await res.text()).trim();
|
||
const json = decodeCustomBase64(body);
|
||
const data = JSON.parse(json) as unknown;
|
||
if (!Array.isArray(data)) {
|
||
throw new Error("getModel: response is not an array");
|
||
}
|
||
return data
|
||
.map((row) => {
|
||
if (!row || typeof row !== "object") return null;
|
||
const r = row as { id?: unknown; name?: unknown; form?: unknown };
|
||
const name = typeof r.name === "string" ? r.name : "";
|
||
if (!name) return null;
|
||
const id = typeof r.id === "number" ? r.id : Number(r.id);
|
||
const form = typeof r.form === "string" ? r.form : undefined;
|
||
return {
|
||
id: Number.isFinite(id) ? id : 0,
|
||
name,
|
||
...(form ? { form } : {}),
|
||
};
|
||
})
|
||
.filter((m): m is LuxsinAudioModel => m !== null);
|
||
}
|
||
|
||
/** Fetches `modelList?key=...&count=...`; body is custom Base64 text, decodes to brand+model rows. */
|
||
export async function fetchLuxsinAudioModelList(
|
||
key: string,
|
||
count = 1000,
|
||
): Promise<LuxsinAudioModelListItem[]> {
|
||
const q = key.trim();
|
||
if (!q) return [];
|
||
const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1000;
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(`modelList?key=${encodeURIComponent(q)}&count=${safeCount}`),
|
||
);
|
||
if (!res.ok) {
|
||
throw new Error(`modelList HTTP ${res.status}`);
|
||
}
|
||
const body = (await res.text()).trim();
|
||
const json = decodeCustomBase64(body);
|
||
const data = JSON.parse(json) as unknown;
|
||
if (!Array.isArray(data)) {
|
||
throw new Error("modelList: response is not an array");
|
||
}
|
||
return data
|
||
.map((row): LuxsinAudioModelListItem | null => {
|
||
if (!row || typeof row !== "object") return null;
|
||
const r = row as {
|
||
brand_name?: unknown;
|
||
name?: unknown;
|
||
form?: unknown;
|
||
source?: unknown;
|
||
};
|
||
const brandName = typeof r.brand_name === "string" ? r.brand_name : "";
|
||
const modelName = typeof r.name === "string" ? r.name : "";
|
||
if (!brandName || !modelName) return null;
|
||
const form = typeof r.form === "string" ? r.form : undefined;
|
||
const source = typeof r.source === "string" ? r.source : undefined;
|
||
return {
|
||
brandName,
|
||
modelName,
|
||
...(form ? { form } : {}),
|
||
...(source ? { source } : {}),
|
||
};
|
||
})
|
||
.filter((item): item is LuxsinAudioModelListItem => item !== null);
|
||
}
|
||
|
||
/** Fetches `getCurve` and returns decoded text payload. */
|
||
export async function fetchLuxsinAudioCurve(
|
||
brand: string,
|
||
name: string,
|
||
target: string,
|
||
): Promise<string> {
|
||
const brandName = encodeURIComponent(brand.trim());
|
||
const modelName = encodeURIComponent(name.trim());
|
||
const targetName = encodeURIComponent(target.trim());
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(`getCurve?brand=${brandName}&name=${modelName}&target=${targetName}`),
|
||
);
|
||
if (!res.ok) {
|
||
throw new Error(`getCurve HTTP ${res.status}`);
|
||
}
|
||
const body = (await res.text()).trim();
|
||
return decodeCustomBase64(body);
|
||
}
|
||
|
||
// ============================================================
|
||
// Share Code API — shareCreate / shareList / shareQuery / shareAccept
|
||
// ============================================================
|
||
|
||
const SHARE_DEVICE_MODELS = ["Luxsin-X9", "Luxsin-X8"] as const;
|
||
|
||
export type ShareDeviceModel = (typeof SHARE_DEVICE_MODELS)[number];
|
||
|
||
/** Map DeviceState.device to app-api share code model enum. */
|
||
export function resolveShareDeviceModel(device: string): ShareDeviceModel | null {
|
||
const trimmed = device.trim();
|
||
if ((SHARE_DEVICE_MODELS as readonly string[]).includes(trimmed)) {
|
||
return trimmed as ShareDeviceModel;
|
||
}
|
||
const hyphenated = trimmed.replace(/\s+/g, "-");
|
||
if ((SHARE_DEVICE_MODELS as readonly string[]).includes(hyphenated)) {
|
||
return hyphenated as ShareDeviceModel;
|
||
}
|
||
const lower = trimmed.toLowerCase();
|
||
if (lower.includes("x9")) return "Luxsin-X9";
|
||
if (lower.includes("x8")) return "Luxsin-X8";
|
||
return null;
|
||
}
|
||
|
||
export interface ShareCodeCreateResponse {
|
||
code: number;
|
||
msg: string;
|
||
share_code?: string;
|
||
expire_at?: string;
|
||
eq_data?: Record<string, unknown>;
|
||
}
|
||
|
||
export interface ShareCodeListItem {
|
||
share_code: string;
|
||
expire_at: string;
|
||
eq_data: Record<string, unknown>;
|
||
}
|
||
|
||
export interface ShareCodeListResponse {
|
||
code: number;
|
||
msg: string;
|
||
share_codes: ShareCodeListItem[];
|
||
}
|
||
|
||
export interface ShareCodeAcceptResponse {
|
||
code: number;
|
||
msg: string;
|
||
eq_data?: Record<string, unknown>;
|
||
model?: string;
|
||
}
|
||
|
||
export interface ShareCodeQueryResponse {
|
||
code: number;
|
||
msg: string;
|
||
eq_data?: Record<string, unknown>;
|
||
expire_at?: string;
|
||
model?: string;
|
||
}
|
||
|
||
/** Unwrap stringified PEQ filters (remove JSON escape layers) for API submit. */
|
||
export function normalizePeqFiltersForSubmit(filters: unknown): PeqFilter[] {
|
||
if (Array.isArray(filters)) return filters as PeqFilter[];
|
||
if (typeof filters !== "string" || !filters.trim()) return [];
|
||
|
||
let current: unknown = filters.trim();
|
||
for (let depth = 0; depth < 3; depth++) {
|
||
if (Array.isArray(current)) return current as PeqFilter[];
|
||
if (typeof current !== "string") return [];
|
||
try {
|
||
current = JSON.parse(current);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
return Array.isArray(current) ? (current as PeqFilter[]) : [];
|
||
}
|
||
|
||
/** POST `/audio/shareCreate` — 创建 EQ 分享码,返回 share_code + expire_at + eq_data */
|
||
export async function createShareCode(
|
||
mac: string,
|
||
model: ShareDeviceModel,
|
||
eqData: Record<string, unknown>,
|
||
): Promise<ShareCodeCreateResponse> {
|
||
const payload = {
|
||
...eqData,
|
||
...(eqData.filters !== undefined
|
||
? { filters: normalizePeqFiltersForSubmit(eqData.filters) }
|
||
: {}),
|
||
};
|
||
const res = await fetch(buildLuxsinAudioUrl("shareCreate"), {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ mac, model, eq_data: payload }),
|
||
});
|
||
if (!res.ok) throw new Error(`shareCreate HTTP ${res.status}`);
|
||
return (await res.json()) as ShareCodeCreateResponse;
|
||
}
|
||
|
||
/** GET `/audio/shareList?mac=...` — 查询某 MAC 尚未过期的分享码列表 */
|
||
export async function listShareCodes(mac: string): Promise<ShareCodeListResponse> {
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(`shareList?mac=${encodeURIComponent(mac)}`),
|
||
);
|
||
if (!res.ok) throw new Error(`shareList HTTP ${res.status}`);
|
||
return (await res.json()) as ShareCodeListResponse;
|
||
}
|
||
|
||
/** GET `/audio/shareQuery?shareCode=...` — 预览分享码 EQ 数据(不记录导入) */
|
||
export async function queryShareCode(shareCode: string): Promise<ShareCodeQueryResponse> {
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(`shareQuery?shareCode=${encodeURIComponent(shareCode)}`),
|
||
);
|
||
if (!res.ok) throw new Error(`shareQuery HTTP ${res.status}`);
|
||
return (await res.json()) as ShareCodeQueryResponse;
|
||
}
|
||
|
||
/** GET `/audio/shareAccept?mac=...&model=...&shareCode=...` — 确认导入分享码并记录流水 */
|
||
export async function acceptShareCode(
|
||
mac: string,
|
||
model: ShareDeviceModel,
|
||
shareCode: string,
|
||
): Promise<ShareCodeAcceptResponse> {
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(
|
||
`shareAccept?mac=${encodeURIComponent(mac)}&model=${encodeURIComponent(model)}&shareCode=${encodeURIComponent(shareCode)}`,
|
||
),
|
||
);
|
||
if (!res.ok) throw new Error(`shareAccept HTTP ${res.status}`);
|
||
return (await res.json()) as ShareCodeAcceptResponse;
|
||
}
|
||
|
||
export interface ShareCodeDeleteResponse {
|
||
code: number;
|
||
msg: string;
|
||
}
|
||
|
||
/** GET `/audio/shareDelete?mac=...&shareCode=...` — 删除分享码 */
|
||
export async function deleteShareCode(
|
||
mac: string,
|
||
shareCode: string,
|
||
): Promise<ShareCodeDeleteResponse> {
|
||
const res = await fetch(
|
||
buildLuxsinAudioUrl(
|
||
`shareDelete?mac=${encodeURIComponent(mac)}&shareCode=${encodeURIComponent(shareCode)}`,
|
||
),
|
||
);
|
||
if (!res.ok) throw new Error(`shareDelete HTTP ${res.status}`);
|
||
return (await res.json()) as ShareCodeDeleteResponse;
|
||
}
|