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:
Artem Kokos
2026-05-27 22:26:51 +07:00
commit b934600380
11 changed files with 1942 additions and 0 deletions

59
ignis_client/__init__.py Normal file
View 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",
]

View 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
View 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
View 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
View 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()
)