Files
controller-v2/client/src/lib/luxsinApi.ts
T

798 lines
26 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;
}
// Strip whitespace / newlines that may be present in API responses.
// Chrome 91 atob() is stricter than modern browsers and rejects these.
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);
}
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;
dreMode: 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;
subwoofer_enable: number;
subwoofer_value: number;
subwoofer_rate: number;
subwoofer_gain: number;
loudness_enable: number;
loudness_bass_gain: number;
loudness_treble_gain: number;
loudness_threshold_gain: number;
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 PeqFilter {
fc: number;
gain: number;
q: number;
type: number;
}
export interface PeqState {
filters: PeqFilter[];
peqSelect?: 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;
}
// ============================================================
// Input/Output Labels
// ============================================================
export const INPUT_LABELS = ["USB", "USB-C", "Coaxial", "Optical", "Bluetooth", "IIS", "RCA"];
export const OUTPUT_LABELS = ["XLR", "RCA", "Headset", "XLR/RCA"];
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;
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]", 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 query = Object.entries(params)
.map(([k, v]) => `${k}=${v}`)
.join("&");
await fetch(`${this.baseUrl}/dev/info.cgi?action=setting&${query}`);
}
async getPeqState(): Promise<PeqState> {
const response = await fetch(`${this.baseUrl}/dev/info.cgi?action=syncPeq`);
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 curve switch) without a full preset save. */
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> {
const encoded = encodeCustomBase64(JSON.stringify(body));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
});
}
/** Remove one or more headphone PEQ profiles. */
async removePeq(names: string[]): Promise<void> {
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
const form = new URLSearchParams();
form.set("json", encoded);
await fetch(`${this.baseUrl}/dev/info.cgi`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: form.toString(),
});
}
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 (firmware >= 26). */
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 (e.g. bootSound extended steps). */
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]);
}
/** UI display: build number 26 → `1.0.0.26`. Already-prefixed values are unchanged. */
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}`;
}
/** Max `bootSound` index available for the current firmware. */
export function getBootSoundMaxIndex(firmwareVersion: number): number {
return firmwareVersion >= 26 ? 10 : 6;
}
/** 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;
}
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)));
}
/** 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-X8",
version: 28,
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,
dreMode: 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,
subwoofer_enable: 0,
subwoofer_value: 80,
subwoofer_rate: 0,
subwoofer_gain: 0,
loudness_enable: 0,
loudness_bass_gain: 3,
loudness_treble_gain: 2,
loudness_threshold_gain: 60,
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 = {
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}`;
}
/** 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;
}