Newer
Older
smart-home-server / webclient / src / js / sh / SmartHomeApi.js
/**
 * 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);
// });