Files
controller-v2/client/src/lib/luxsinApi.ts
T
2026-09-22 11:55:39 +08:00

1046 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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;
}