Initial commit: Ignis Client Python
- Sync and async HTTP clients for Ignis Core WiZ server - 23 endpoints: auth, devices, groups, control, schedules, stats, API keys - Pydantic models with client-side validation - 108 unit tests - README with role table and usage examples
This commit is contained in:
59
ignis_client/__init__.py
Normal file
59
ignis_client/__init__.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from .models import (
|
||||
SCENES,
|
||||
BlinkResponse,
|
||||
CommandRequest,
|
||||
DeleteStatusResponse,
|
||||
DeviceControlResponse,
|
||||
DeviceSchema,
|
||||
DeviceStatusResponse,
|
||||
GroupCommandResult,
|
||||
GroupControlResponse,
|
||||
GroupCreateSchema,
|
||||
GroupStatusItem,
|
||||
GroupStatusResponse,
|
||||
IgnisError,
|
||||
KeyActionRequest,
|
||||
RescanResponse,
|
||||
ScheduleCreateResponse,
|
||||
ScheduleCronRequest,
|
||||
ScheduleOnceRequest,
|
||||
ScheduleTaskItem,
|
||||
ScheduleTasksResponse,
|
||||
ServerBuildInfoResponse,
|
||||
ServerConfigurationStatusResponse,
|
||||
ServerDiscoveryInfoResponse,
|
||||
ServerInfoResponse,
|
||||
ServerUrlInfoResponse,
|
||||
)
|
||||
from .async_client import AsyncIgnisClient
|
||||
from .sync import IgnisClient
|
||||
|
||||
__all__ = [
|
||||
"IgnisClient",
|
||||
"AsyncIgnisClient",
|
||||
"IgnisError",
|
||||
"CommandRequest",
|
||||
"ScheduleOnceRequest",
|
||||
"ScheduleCronRequest",
|
||||
"KeyActionRequest",
|
||||
"DeviceSchema",
|
||||
"GroupCreateSchema",
|
||||
"DeviceControlResponse",
|
||||
"GroupCommandResult",
|
||||
"GroupControlResponse",
|
||||
"BlinkResponse",
|
||||
"DeviceStatusResponse",
|
||||
"GroupStatusItem",
|
||||
"GroupStatusResponse",
|
||||
"ScheduleCreateResponse",
|
||||
"ScheduleTaskItem",
|
||||
"ScheduleTasksResponse",
|
||||
"DeleteStatusResponse",
|
||||
"RescanResponse",
|
||||
"ServerBuildInfoResponse",
|
||||
"ServerConfigurationStatusResponse",
|
||||
"ServerDiscoveryInfoResponse",
|
||||
"ServerInfoResponse",
|
||||
"ServerUrlInfoResponse",
|
||||
"SCENES",
|
||||
]
|
||||
190
ignis_client/async_client.py
Normal file
190
ignis_client/async_client.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import (
|
||||
SCENES,
|
||||
BlinkResponse,
|
||||
CommandRequest,
|
||||
DeleteStatusResponse,
|
||||
DeviceControlResponse,
|
||||
DeviceStatusResponse,
|
||||
GroupControlResponse,
|
||||
GroupStatusResponse,
|
||||
IgnisError,
|
||||
KeyActionRequest,
|
||||
RescanResponse,
|
||||
ScheduleCreateResponse,
|
||||
ScheduleCronRequest,
|
||||
ScheduleOnceRequest,
|
||||
ScheduleTasksResponse,
|
||||
ServerInfoResponse,
|
||||
)
|
||||
|
||||
|
||||
class AsyncIgnisClient:
|
||||
def __init__(self, base_url: str, api_key: str, *, timeout: float = 10.0):
|
||||
self._base = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base,
|
||||
headers={"X-API-Key": self._api_key},
|
||||
timeout=httpx.Timeout(timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.close()
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs) -> Any:
|
||||
resp = await self._client.request(method, path, **kwargs)
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text or f"HTTP {resp.status_code}"
|
||||
raise IgnisError(resp.status_code, detail)
|
||||
return resp.json()
|
||||
|
||||
async def _get(self, path: str, **kwargs) -> Any:
|
||||
return await self._request("GET", path, **kwargs)
|
||||
|
||||
async def _post(self, path: str, **kwargs) -> Any:
|
||||
return await self._request("POST", path, **kwargs)
|
||||
|
||||
async def _delete(self, path: str, **kwargs) -> Any:
|
||||
return await self._request("DELETE", path, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------ Auth
|
||||
|
||||
async def auth_me(self) -> dict[str, Any]:
|
||||
return await self._get("/auth/me")
|
||||
|
||||
# ------------------------------------------------------------------ System
|
||||
|
||||
async def system_info(self) -> ServerInfoResponse:
|
||||
data = await self._get("/system/info")
|
||||
return ServerInfoResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------ Devices
|
||||
|
||||
async def list_devices(self) -> dict[str, dict[str, str]]:
|
||||
return await self._get("/devices")
|
||||
|
||||
async def list_groups(self) -> dict[str, dict[str, Any]]:
|
||||
return await self._get("/devices/groups")
|
||||
|
||||
async def list_scenes(self) -> dict[str, int]:
|
||||
return dict(SCENES)
|
||||
|
||||
async def create_group(
|
||||
self, group_id: str, name: str, macs: list[str]
|
||||
) -> dict[str, Any]:
|
||||
return await self._post(
|
||||
"/devices/groups", json={"id": group_id, "name": name, "macs": macs}
|
||||
)
|
||||
|
||||
async def delete_group(self, group_id: str) -> dict[str, Any]:
|
||||
return await self._delete(f"/devices/groups/{group_id}")
|
||||
|
||||
async def rescan(self) -> RescanResponse:
|
||||
data = await self._post("/devices/rescan")
|
||||
return RescanResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------ Control
|
||||
|
||||
async def control_device(
|
||||
self, device_id: str, payload: CommandRequest
|
||||
) -> DeviceControlResponse:
|
||||
data = await self._post(
|
||||
f"/control/device/{device_id}",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return DeviceControlResponse(**data)
|
||||
|
||||
async def control_group(
|
||||
self, group_id: str, payload: CommandRequest
|
||||
) -> GroupControlResponse:
|
||||
data = await self._post(
|
||||
f"/control/group/{group_id}",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return GroupControlResponse(**data)
|
||||
|
||||
async def blink_device(self, device_id: str) -> BlinkResponse:
|
||||
data = await self._post(f"/control/device/{device_id}/blink")
|
||||
return BlinkResponse(**data)
|
||||
|
||||
async def device_status(self, device_id: str) -> DeviceStatusResponse:
|
||||
data = await self._get(f"/control/device/{device_id}/status")
|
||||
return DeviceStatusResponse(**data)
|
||||
|
||||
async def group_status(self, group_id: str) -> GroupStatusResponse:
|
||||
data = await self._get(f"/control/group/{group_id}/status")
|
||||
return GroupStatusResponse(**data)
|
||||
|
||||
# --------------------------------------------------------------- Schedules
|
||||
|
||||
async def create_once_schedule(
|
||||
self, payload: ScheduleOnceRequest
|
||||
) -> ScheduleCreateResponse:
|
||||
data = await self._post(
|
||||
"/schedules/once",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return ScheduleCreateResponse(**data)
|
||||
|
||||
async def create_cron_schedule(
|
||||
self, payload: ScheduleCronRequest
|
||||
) -> ScheduleCreateResponse:
|
||||
data = await self._post(
|
||||
"/schedules/cron",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return ScheduleCreateResponse(**data)
|
||||
|
||||
async def list_schedules(self) -> ScheduleTasksResponse:
|
||||
data = await self._get("/schedules/tasks")
|
||||
return ScheduleTasksResponse(**data)
|
||||
|
||||
async def delete_schedule(self, job_id: str) -> DeleteStatusResponse:
|
||||
data = await self._delete(f"/schedules/{job_id}")
|
||||
return DeleteStatusResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------- Stats
|
||||
|
||||
async def stats_summary(self, days: int = 7) -> dict[str, Any]:
|
||||
return await self._get("/stats/summary", params={"days": days})
|
||||
|
||||
async def stats_log(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
return await self._get("/stats/log", params={"limit": limit})
|
||||
|
||||
# ---------------------------------------------------------------- API Keys
|
||||
|
||||
async def list_api_keys(self) -> list[dict[str, Any]]:
|
||||
return await self._get("/api-keys")
|
||||
|
||||
async def create_api_key(
|
||||
self, name: str, *, is_admin: bool = False
|
||||
) -> dict[str, Any]:
|
||||
return await self._post(
|
||||
"/api-keys", params={"name": name, "is_admin": is_admin}
|
||||
)
|
||||
|
||||
async def revoke_api_key(self, key_or_id: str) -> dict[str, Any]:
|
||||
return await self._post(
|
||||
"/api-keys/revoke", json=KeyActionRequest(key=key_or_id).model_dump()
|
||||
)
|
||||
|
||||
async def activate_api_key(self, key_or_id: str) -> dict[str, Any]:
|
||||
return await self._post(
|
||||
"/api-keys/activate", json=KeyActionRequest(key=key_or_id).model_dump()
|
||||
)
|
||||
158
ignis_client/example.py
Normal file
158
ignis_client/example.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Пример использования Ignis Client.
|
||||
|
||||
Скопируй каталог `ignis_client/` на целевую машину и положи рядом с ботом.
|
||||
Единственные внешние зависимости — httpx и pydantic.
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/my-bot")
|
||||
from ignis_client import IgnisClient, CommandRequest
|
||||
|
||||
ignis = IgnisClient("http://192.168.1.50:8000", api_key="change-me")
|
||||
"""
|
||||
|
||||
from ignis_client import (
|
||||
IgnisClient,
|
||||
AsyncIgnisClient,
|
||||
CommandRequest,
|
||||
ScheduleOnceRequest,
|
||||
ScheduleCronRequest,
|
||||
)
|
||||
|
||||
SERVER_URL = "http://127.0.0.1:8000"
|
||||
API_KEY = "change-me"
|
||||
|
||||
|
||||
def sync_example():
|
||||
"""Синхронное использование — для простых скриптов и cron-задач."""
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
me = ignis.auth_me()
|
||||
print(f"Авторизован как: {me['name']} (admin={me['is_admin']})")
|
||||
|
||||
devices = ignis.list_devices()
|
||||
print(f"Устройств в сети: {len(devices)}")
|
||||
|
||||
groups = ignis.list_groups()
|
||||
for gid, g in groups.items():
|
||||
print(f" Группа {g['name']}: {g['device_ids']}")
|
||||
|
||||
if devices:
|
||||
mac = next(iter(devices))
|
||||
status = ignis.device_status(mac)
|
||||
print(
|
||||
f" Статус {mac}: on={status.status.get('state')}, "
|
||||
f"brightness={status.status.get('dimming')}"
|
||||
)
|
||||
|
||||
for gid in groups:
|
||||
ignis.control_group(gid, CommandRequest(state=True, brightness=80))
|
||||
print(f" Включил группу {gid}")
|
||||
|
||||
if me["is_admin"]:
|
||||
tasks = ignis.list_schedules()
|
||||
print(f"Активных расписаний: {len(tasks.tasks)}")
|
||||
summary = ignis.stats_summary(days=7)
|
||||
print(f"Статистика за {summary['period_days']} дн:")
|
||||
for g in summary.get("groups", []):
|
||||
print(f" {g['target_id']}: {g['total_commands']} команд")
|
||||
else:
|
||||
print("Гостевой ключ — расписания и статистика недоступны.")
|
||||
|
||||
|
||||
def cron_example():
|
||||
"""Создание cron-расписания для группы."""
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
req = ScheduleCronRequest(
|
||||
target_id="bedroom",
|
||||
hour="7",
|
||||
minute="30",
|
||||
day_of_week="mon-fri",
|
||||
state=True,
|
||||
brightness=60,
|
||||
scene="wake_up",
|
||||
)
|
||||
result = ignis.create_cron_schedule(req)
|
||||
print(f"Расписание создано: job_id={result.job_id}")
|
||||
|
||||
|
||||
def scene_example():
|
||||
"""Включение сцены и смена температуры."""
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
ignis.control_group(
|
||||
"livingroom", CommandRequest(scene="fireplace", brightness=50)
|
||||
)
|
||||
|
||||
ignis.control_device("aa:bb:cc:dd:ee:ff", CommandRequest(temp=3200))
|
||||
|
||||
ignis.control_group("bedroom", CommandRequest(r=255, g=120, b=60))
|
||||
|
||||
|
||||
def blink_example():
|
||||
"""Мигание лампой (поиск устройства в комнате)."""
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
result = ignis.blink_device("aa:bb:cc:dd:ee:ff")
|
||||
print(f"Исходное состояние: {'вкл' if result.original else 'выкл'}")
|
||||
|
||||
|
||||
def schedule_example():
|
||||
"""Одноразовое расписание."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
run_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
req = ScheduleOnceRequest(
|
||||
target_id="livingroom",
|
||||
run_at=run_at,
|
||||
is_group=True,
|
||||
state=False,
|
||||
)
|
||||
result = ignis.create_once_schedule(req)
|
||||
print(f"Отложенное выключение: {result.job_id} в {result.run_at}")
|
||||
|
||||
|
||||
def rescan_example():
|
||||
"""Ручной рескан сети."""
|
||||
|
||||
with IgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
result = ignis.rescan()
|
||||
print(
|
||||
f"Найдено: {result.found}, новых: {result.added}, онлайн: {result.online}"
|
||||
)
|
||||
|
||||
|
||||
async def async_example():
|
||||
"""Асинхронное использование — для ботов на asyncio."""
|
||||
|
||||
async with AsyncIgnisClient(SERVER_URL, API_KEY) as ignis:
|
||||
me = await ignis.auth_me()
|
||||
print(f"Авторизован как: {me['name']}")
|
||||
|
||||
devices = await ignis.list_devices()
|
||||
if devices:
|
||||
mac = next(iter(devices))
|
||||
await ignis.control_device(mac, CommandRequest(state=True, brightness=100))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "sync"
|
||||
|
||||
if mode == "sync":
|
||||
sync_example()
|
||||
elif mode == "cron":
|
||||
cron_example()
|
||||
elif mode == "async":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(async_example())
|
||||
else:
|
||||
print(f"Использование: python example.py [sync|cron|async]")
|
||||
print(f" sync — показать устройства и включить группы")
|
||||
print(f" cron — создать утреннее расписание")
|
||||
print(f" async — асинхронный пример")
|
||||
278
ignis_client/models.py
Normal file
278
ignis_client/models.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class IgnisError(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
super().__init__(f"[{status_code}] {detail}")
|
||||
|
||||
|
||||
SCENES: dict[str, int] = {
|
||||
"ocean": 1,
|
||||
"romance": 2,
|
||||
"party": 3,
|
||||
"fireplace": 5,
|
||||
"cozy": 6,
|
||||
"forest": 10,
|
||||
"pastel_colors": 11,
|
||||
"wake_up": 12,
|
||||
"bedtime": 13,
|
||||
"warm_white": 14,
|
||||
"daylight": 15,
|
||||
"cool_white": 16,
|
||||
"night_light": 17,
|
||||
"focus": 18,
|
||||
"relax": 19,
|
||||
"true_colors": 20,
|
||||
"tv_time": 21,
|
||||
"plant_growth": 22,
|
||||
"spring": 23,
|
||||
"summer": 24,
|
||||
"fall": 25,
|
||||
"deep_dive": 26,
|
||||
"jungle": 27,
|
||||
"mojito": 28,
|
||||
"club": 29,
|
||||
"christmas": 30,
|
||||
"halloween": 31,
|
||||
"candlelight": 32,
|
||||
"golden_white": 33,
|
||||
"pulse": 34,
|
||||
"steampunk": 35,
|
||||
}
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
state: bool | None = None
|
||||
brightness: int | None = Field(default=None, ge=10, le=100)
|
||||
scene: str | None = None
|
||||
temp: int | None = Field(default=None, ge=2200, le=6500)
|
||||
r: int | None = Field(default=None, ge=0, le=255)
|
||||
g: int | None = Field(default=None, ge=0, le=255)
|
||||
b: int | None = Field(default=None, ge=0, le=255)
|
||||
|
||||
@property
|
||||
def has_rgb(self) -> bool:
|
||||
return all(channel is not None for channel in (self.r, self.g, self.b))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload(self):
|
||||
if all(
|
||||
value is None
|
||||
for value in (
|
||||
self.state,
|
||||
self.brightness,
|
||||
self.scene,
|
||||
self.temp,
|
||||
self.r,
|
||||
self.g,
|
||||
self.b,
|
||||
)
|
||||
):
|
||||
raise ValueError("Никаких команд не передано")
|
||||
|
||||
rgb_values = (self.r, self.g, self.b)
|
||||
if any(value is not None for value in rgb_values) and not self.has_rgb:
|
||||
raise ValueError("Поля r, g и b нужно передавать вместе")
|
||||
|
||||
exclusive_modes = (
|
||||
self.scene is not None,
|
||||
self.temp is not None,
|
||||
self.has_rgb,
|
||||
)
|
||||
if sum(1 for enabled in exclusive_modes if enabled) > 1:
|
||||
raise ValueError("Можно передать только один режим из scene, temp или rgb")
|
||||
|
||||
return self
|
||||
|
||||
def to_wiz_params(self) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if self.state is not None:
|
||||
params["state"] = self.state
|
||||
if self.brightness is not None:
|
||||
params["dimming"] = self.brightness
|
||||
if self.scene is not None:
|
||||
if self.scene not in SCENES:
|
||||
raise ValueError("Неизвестная сцена")
|
||||
params["sceneId"] = SCENES[self.scene]
|
||||
elif self.temp is not None:
|
||||
params["temp"] = self.temp
|
||||
elif self.has_rgb:
|
||||
params["r"] = self.r
|
||||
params["g"] = self.g
|
||||
params["b"] = self.b
|
||||
return params
|
||||
|
||||
|
||||
class ScheduleOnceRequest(CommandRequest):
|
||||
target_id: str = Field(min_length=1)
|
||||
run_at: datetime | None = None
|
||||
hours_from_now: int | None = Field(default=None, ge=0)
|
||||
is_group: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_schedule_target(self):
|
||||
has_run_at = self.run_at is not None
|
||||
has_hours_from_now = self.hours_from_now is not None
|
||||
if has_run_at == has_hours_from_now:
|
||||
raise ValueError("Передайте ровно одно из полей run_at или hours_from_now")
|
||||
return self
|
||||
|
||||
|
||||
class ScheduleCronRequest(CommandRequest):
|
||||
target_id: str = Field(min_length=1)
|
||||
hour: str = Field(min_length=1)
|
||||
minute: str = Field(min_length=1)
|
||||
day_of_week: str = "*"
|
||||
is_group: bool = True
|
||||
|
||||
|
||||
class DeviceControlResponse(BaseModel):
|
||||
device_id: str
|
||||
applied: dict[str, Any]
|
||||
result: dict[str, Any] | None = None
|
||||
status: Literal["ok"]
|
||||
|
||||
|
||||
class GroupCommandResult(BaseModel):
|
||||
ip: str
|
||||
ok: bool
|
||||
kind: str
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class GroupControlResponse(BaseModel):
|
||||
status: Literal["ok", "partial"]
|
||||
applied: dict[str, Any]
|
||||
sent_to: list[str]
|
||||
success_count: int
|
||||
failure_count: int
|
||||
results: list[GroupCommandResult]
|
||||
|
||||
|
||||
class BlinkResponse(BaseModel):
|
||||
status: Literal["blink_done"]
|
||||
original: bool
|
||||
|
||||
|
||||
class DeviceStatusResponse(BaseModel):
|
||||
device_id: str
|
||||
status: dict[str, Any]
|
||||
|
||||
|
||||
class GroupStatusItem(BaseModel):
|
||||
ip: str
|
||||
status: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
class GroupStatusResponse(BaseModel):
|
||||
group_id: str
|
||||
results: list[GroupStatusItem]
|
||||
|
||||
|
||||
class ScheduleCreateResponse(BaseModel):
|
||||
status: str
|
||||
job_id: str
|
||||
run_at: str | None = None
|
||||
|
||||
|
||||
class ScheduleTaskItem(BaseModel):
|
||||
id: str
|
||||
target_id: str
|
||||
is_group: bool
|
||||
state: bool | None = None
|
||||
action_params: dict[str, Any]
|
||||
trigger_type: str
|
||||
next_run: str | None = None
|
||||
hour: str | None = None
|
||||
minute: str | None = None
|
||||
day_of_week: str | None = None
|
||||
job_present: bool
|
||||
|
||||
|
||||
class ScheduleTasksResponse(BaseModel):
|
||||
tasks: list[ScheduleTaskItem]
|
||||
|
||||
|
||||
class DeleteStatusResponse(BaseModel):
|
||||
status: Literal["deleted"]
|
||||
|
||||
|
||||
class RescanResponse(BaseModel):
|
||||
status: Literal["ok"]
|
||||
found: int
|
||||
added: int
|
||||
updated: int
|
||||
removed_offline: int
|
||||
pending_removal: int
|
||||
online: int
|
||||
|
||||
|
||||
class ServerBuildInfoResponse(BaseModel):
|
||||
version: str | None = None
|
||||
git_sha: str | None = None
|
||||
build_date: str | None = None
|
||||
|
||||
|
||||
class ServerUrlInfoResponse(BaseModel):
|
||||
observed_base_url: str | None = None
|
||||
configured_public_base_url: str | None = None
|
||||
effective_public_base_url: str | None = None
|
||||
|
||||
|
||||
class ServerConfigurationStatusResponse(BaseModel):
|
||||
configured: bool
|
||||
master_key_configured: bool
|
||||
scan_network_configured: bool
|
||||
public_base_url_configured: bool
|
||||
build_metadata_complete: bool
|
||||
|
||||
|
||||
class ServerDiscoveryInfoResponse(BaseModel):
|
||||
last_scan_at: str | None = None
|
||||
last_scan_mode: str | None = None
|
||||
online: int | None = None
|
||||
found: int | None = None
|
||||
added: int | None = None
|
||||
updated: int | None = None
|
||||
removed_offline: int | None = None
|
||||
pending_removal: int | None = None
|
||||
|
||||
|
||||
class ServerInfoResponse(BaseModel):
|
||||
app_name: str
|
||||
instance_name: str | None = None
|
||||
timezone: str | None = None
|
||||
uptime_seconds: int
|
||||
diagnostics_visible: bool
|
||||
started_at: str | None = None
|
||||
build: ServerBuildInfoResponse | None = None
|
||||
urls: ServerUrlInfoResponse | None = None
|
||||
configuration: ServerConfigurationStatusResponse | None = None
|
||||
discovery: ServerDiscoveryInfoResponse | None = None
|
||||
|
||||
|
||||
class KeyActionRequest(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class DeviceSchema(BaseModel):
|
||||
id: str
|
||||
ip: str
|
||||
name: str
|
||||
room: str
|
||||
|
||||
|
||||
class GroupCreateSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
macs: List[str]
|
||||
184
ignis_client/sync.py
Normal file
184
ignis_client/sync.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import (
|
||||
SCENES,
|
||||
BlinkResponse,
|
||||
CommandRequest,
|
||||
DeleteStatusResponse,
|
||||
DeviceControlResponse,
|
||||
DeviceStatusResponse,
|
||||
GroupControlResponse,
|
||||
GroupStatusResponse,
|
||||
IgnisError,
|
||||
KeyActionRequest,
|
||||
RescanResponse,
|
||||
ScheduleCreateResponse,
|
||||
ScheduleCronRequest,
|
||||
ScheduleOnceRequest,
|
||||
ScheduleTasksResponse,
|
||||
ServerInfoResponse,
|
||||
)
|
||||
|
||||
|
||||
class IgnisClient:
|
||||
def __init__(self, base_url: str, api_key: str, *, timeout: float = 10.0):
|
||||
self._base = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base,
|
||||
headers={"X-API-Key": self._api_key},
|
||||
timeout=httpx.Timeout(timeout),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs) -> Any:
|
||||
resp = self._client.request(method, path, **kwargs)
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text or f"HTTP {resp.status_code}"
|
||||
raise IgnisError(resp.status_code, detail)
|
||||
return resp.json()
|
||||
|
||||
def _get(self, path: str, **kwargs) -> Any:
|
||||
return self._request("GET", path, **kwargs)
|
||||
|
||||
def _post(self, path: str, **kwargs) -> Any:
|
||||
return self._request("POST", path, **kwargs)
|
||||
|
||||
def _delete(self, path: str, **kwargs) -> Any:
|
||||
return self._request("DELETE", path, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------ Auth
|
||||
|
||||
def auth_me(self) -> dict[str, Any]:
|
||||
return self._get("/auth/me")
|
||||
|
||||
# ------------------------------------------------------------------ System
|
||||
|
||||
def system_info(self) -> ServerInfoResponse:
|
||||
data = self._get("/system/info")
|
||||
return ServerInfoResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------ Devices
|
||||
|
||||
def list_devices(self) -> dict[str, dict[str, str]]:
|
||||
return self._get("/devices")
|
||||
|
||||
def list_groups(self) -> dict[str, dict[str, Any]]:
|
||||
return self._get("/devices/groups")
|
||||
|
||||
def list_scenes(self) -> dict[str, int]:
|
||||
return dict(SCENES)
|
||||
|
||||
def create_group(self, group_id: str, name: str, macs: list[str]) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/devices/groups", json={"id": group_id, "name": name, "macs": macs}
|
||||
)
|
||||
|
||||
def delete_group(self, group_id: str) -> dict[str, Any]:
|
||||
return self._delete(f"/devices/groups/{group_id}")
|
||||
|
||||
def rescan(self) -> RescanResponse:
|
||||
data = self._post("/devices/rescan")
|
||||
return RescanResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------ Control
|
||||
|
||||
def control_device(
|
||||
self, device_id: str, payload: CommandRequest
|
||||
) -> DeviceControlResponse:
|
||||
data = self._post(
|
||||
f"/control/device/{device_id}",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return DeviceControlResponse(**data)
|
||||
|
||||
def control_group(
|
||||
self, group_id: str, payload: CommandRequest
|
||||
) -> GroupControlResponse:
|
||||
data = self._post(
|
||||
f"/control/group/{group_id}",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return GroupControlResponse(**data)
|
||||
|
||||
def blink_device(self, device_id: str) -> BlinkResponse:
|
||||
data = self._post(f"/control/device/{device_id}/blink")
|
||||
return BlinkResponse(**data)
|
||||
|
||||
def device_status(self, device_id: str) -> DeviceStatusResponse:
|
||||
data = self._get(f"/control/device/{device_id}/status")
|
||||
return DeviceStatusResponse(**data)
|
||||
|
||||
def group_status(self, group_id: str) -> GroupStatusResponse:
|
||||
data = self._get(f"/control/group/{group_id}/status")
|
||||
return GroupStatusResponse(**data)
|
||||
|
||||
# --------------------------------------------------------------- Schedules
|
||||
|
||||
def create_once_schedule(
|
||||
self, payload: ScheduleOnceRequest
|
||||
) -> ScheduleCreateResponse:
|
||||
data = self._post(
|
||||
"/schedules/once",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return ScheduleCreateResponse(**data)
|
||||
|
||||
def create_cron_schedule(
|
||||
self, payload: ScheduleCronRequest
|
||||
) -> ScheduleCreateResponse:
|
||||
data = self._post(
|
||||
"/schedules/cron",
|
||||
json=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
return ScheduleCreateResponse(**data)
|
||||
|
||||
def list_schedules(self) -> ScheduleTasksResponse:
|
||||
data = self._get("/schedules/tasks")
|
||||
return ScheduleTasksResponse(**data)
|
||||
|
||||
def delete_schedule(self, job_id: str) -> DeleteStatusResponse:
|
||||
data = self._delete(f"/schedules/{job_id}")
|
||||
return DeleteStatusResponse(**data)
|
||||
|
||||
# ------------------------------------------------------------------- Stats
|
||||
|
||||
def stats_summary(self, days: int = 7) -> dict[str, Any]:
|
||||
return self._get("/stats/summary", params={"days": days})
|
||||
|
||||
def stats_log(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
return self._get("/stats/log", params={"limit": limit})
|
||||
|
||||
# ---------------------------------------------------------------- API Keys
|
||||
|
||||
def list_api_keys(self) -> list[dict[str, Any]]:
|
||||
return self._get("/api-keys")
|
||||
|
||||
def create_api_key(self, name: str, *, is_admin: bool = False) -> dict[str, Any]:
|
||||
return self._post("/api-keys", params={"name": name, "is_admin": is_admin})
|
||||
|
||||
def revoke_api_key(self, key_or_id: str) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/api-keys/revoke", json=KeyActionRequest(key=key_or_id).model_dump()
|
||||
)
|
||||
|
||||
def activate_api_key(self, key_or_id: str) -> dict[str, Any]:
|
||||
return self._post(
|
||||
"/api-keys/activate", json=KeyActionRequest(key=key_or_id).model_dump()
|
||||
)
|
||||
Reference in New Issue
Block a user