/**
* smart_home_api.js
*
* Минимальная JS-библиотека для REST-запросов к серверу (callback-style).
* - Авторизация: Bearer token (или кастомный заголовок, если поменяешь)
* - Единая обработка ошибок: сетевые, таймаут, не-JSON, статус=false/error
* - Модули: сейчас только Scripts, остальные по аналогии
*/
import { ScriptsApi } from "./modules/ScriptsApi.js";
import { DevicesApi } from "./modules/DevicesApi.js";
import { AreasApi } from "./modules/AreasApi.js";
/* =========================
Utils
========================= */
function build_query(params) {
if (!params || typeof params !== "object") return "";
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null) return;
usp.append(k, String(v));
});
const s = usp.toString();
return s ? `?${s}` : "";
}
function join_url(base_url, path) {
const b = String(base_url || "").replace(/\/+$/, "");
const p = String(path || "").replace(/^\/+/, "");
return `${b}/${p}`;
}
function safe_json_parse(text) {
try {
return { ok: true, data: JSON.parse(text) };
} catch (e) {
return { ok: false, error: e };
}
}
/* =========================
Core client
========================= */
export class SmartHomeApi {
/**
* @param {Object} opts
* @param {string} opts.base_url - например: http://192.168.2.101
* @param {string} [opts.token] - токен авторизации
* @param {number} [opts.timeout_ms=15000]
* @param {Object} [opts.default_headers]
* @param {Function} [opts.on_unauthorized] - cb(details)
* @param {string} [opts.proxy_path] например "/proxy.php" (включает авто-прокси)
*/
constructor(opts) {
this.base_url = opts?.base_url || "";
this.token = opts?.token || "";
this.timeout_ms = Number.isFinite(opts?.timeout_ms) ? opts.timeout_ms : 15000;
this.default_headers = opts?.default_headers || {};
this.on_unauthorized = typeof opts?.on_unauthorized === "function" ? opts.on_unauthorized : null;
this.proxy_path = opts?.proxy_path || ""; // "" => без прокси
// modules
this.scripts = new ScriptsApi(this);
this.devices = new DevicesApi(this);
this.areas = new AreasApi(this);
}
set_base_url(base_url) {
this.base_url = base_url || "";
}
set_token(token) {
this.token = token || "";
}
set_proxy_path(proxy_path) {
this.proxy_path = proxy_path || "";
}
_wrap_path(path, extra_query) {
// Если включён прокси — ходим на /proxy.php?path=<api_path>&...
if (!this.proxy_path) {
if (!extra_query) return path;
return `${path}${build_query(extra_query)}`;
}
const q = { path, ...(extra_query || {}) };
return `${this.proxy_path}${build_query(q)}`;
}
/**
* Унифицированный запрос.
*
* cb(err, data, meta)
* - err: { type, message, status_code?, raw?, details? }
* - data: распарсенный json (или string, если сервер не вернул json)
* - meta: { url, method, status_code, headers }
*/
request(method, path, body, cb, opts) {
const callback = typeof cb === "function" ? cb : () => {};
const url = join_url(this.base_url, path);
const controller = new AbortController();
const timeout_ms = Number.isFinite(opts?.timeout_ms) ? opts.timeout_ms : this.timeout_ms;
const t = setTimeout(() => controller.abort(), timeout_ms);
const headers = {
...this.default_headers,
...(opts?.headers || {}),
};
// Авторизация (подстрой, если у тебя другой формат)
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
let payload = undefined;
if (body !== undefined && body !== null) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
fetch(url, {
method,
headers,
body: payload,
signal: controller.signal,
})
.then(async (res) => {
clearTimeout(t);
const meta = {
url,
method,
status_code: res.status,
headers: res.headers,
};
const text = await res.text();
const parsed = safe_json_parse(text);
const data = parsed.ok ? parsed.data : text;
// HTTP-level ошибки
if (!res.ok) {
const err = {
type: "http_error",
message: `HTTP ${res.status}`,
status_code: res.status,
raw: data,
};
if (res.status === 401 || res.status === 403) {
if (this.on_unauthorized) {
try {
this.on_unauthorized({ error: err, meta });
} catch (_) {}
}
}
return callback(err, null, meta);
}
// API-level ошибки (по твоим примерам бывает status:false или status:"error")
if (parsed.ok && data && typeof data === "object") {
const st = data.status;
if (st === false || st === "error") {
const err = {
type: "api_error",
message: data.message || "API error",
status_code: res.status,
raw: data,
field: data.field,
};
return callback(err, null, meta);
}
}
return callback(null, data, meta);
})
.catch((e) => {
clearTimeout(t);
const is_abort = e && (e.name === "AbortError" || String(e).includes("AbortError"));
const err = is_abort
? { type: "timeout", message: `Timeout after ${timeout_ms}ms` }
: { type: "network_error", message: e?.message || "Network error", details: e };
return callback(err, null, { url, method, status_code: 0, headers: null });
});
}
get(path, cb, opts) {
return this.request("GET", path, null, cb, opts);
}
post(path, body, cb, opts) {
return this.request("POST", path, body, cb, opts);
}
api_get(api_path, cb, extra_query, opts) {
return this.get(this._wrap_path(api_path, extra_query), cb, opts);
}
api_post(api_path, body, cb, extra_query, opts) {
return this.post(this._wrap_path(api_path, extra_query), body, cb, opts);
}
}
/* =========================
Example usage
========================= */
// import { SmartHomeApi } from "./smart_home_api.js";
//
// const api = new SmartHomeApi({
// base_url: "http://192.168.2.101",
// token: "YOUR_TOKEN",
// timeout_ms: 20000,
// on_unauthorized: ({ error }) => console.log("auth problem:", error),
// });
//
// api.scripts.actions_list((err, res) => {
// if (err) return console.error("actions_list error:", err);
// console.log("actions:", res);
// });
//
// api.scripts.run({ alias: "script_alias", params: { x: 1 } }, (err, res) => {
// if (err) return console.error("run error:", err);
// console.log("run result:", res);
// });