519 lines
17 KiB
TypeScript
519 lines
17 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;
|
|
}
|
|
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;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}>;
|
|
}
|
|
|
|
/** POST body for `/dev/info.cgi` — full peq preset update (custom base64 `json` field). */
|
|
export interface PeqChangePayload {
|
|
peqChange: {
|
|
name: string;
|
|
filters: PeqFilter[];
|
|
autoPre?: number;
|
|
preamp?: number;
|
|
canDel?: number;
|
|
brand?: string;
|
|
model?: string;
|
|
target?: string;
|
|
form?: string;
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// 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());
|
|
return JSON.parse(decoded) as DeviceState;
|
|
} 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 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> {
|
|
const payload = JSON.stringify({ peqChange: { filters } });
|
|
const encoded = encodeCustomBase64(payload);
|
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: `json=${encodeURIComponent(encoded)}`,
|
|
});
|
|
}
|
|
|
|
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
|
async upgradePeqChange(body: PeqChangePayload): Promise<void> {
|
|
const payload = JSON.stringify(body);
|
|
const encoded = encodeCustomBase64(payload);
|
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: `json=${encodeURIComponent(encoded)}`,
|
|
});
|
|
}
|
|
|
|
/** Remove one or more headphone PEQ profiles. */
|
|
async removePeq(names: string[]): Promise<void> {
|
|
const payload = JSON.stringify({ peqRemove: names });
|
|
const encoded = encodeCustomBase64(payload);
|
|
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
body: `json=${encodeURIComponent(encoded)}`,
|
|
});
|
|
}
|
|
|
|
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 }); }
|
|
setBootSound(on: boolean) { return this.setSetting({ bootSound: on ? 1 : 0 }); }
|
|
/** Device power off — `GET .../dev/info.cgi?action=setting&power=0` */
|
|
powerOff() { return this.setSetting({ power: 0 }); }
|
|
}
|
|
|
|
// ============================================================
|
|
// 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,
|
|
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,
|
|
};
|
|
|
|
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);
|
|
}
|