Никакого Home Assistant. Свой API, свои контроллеры, свой Джарвис. Собираем голосового ассистента, который говорит с вашей PHP-системой напрямую.
У вас уже есть самописный умный дом на PHP: свои эндпоинты, свои контроллеры, своя БД с устройствами, сценарии, возможно даже WebSocket-шина для событий. Не хватает одного — возможности управлять всем этим голосом, не тыкая пальцами в веб-морду. Именно эту задачу мы и решим: подружим ваш PHP-бэкенд с Hermes Agent и получим полноценного Джарвиса.
В отличие от связки с Home Assistant, здесь у нас нет готовых инструментов и универсальных API. Зато есть полный контроль. Мы будем использовать Hermes Agent как «мозг» и tool-runner, а ваш PHP-бэкенд — как «руки».
Ядро системы: интерпретирует речь, держит контекст, вызывает инструменты.
Ваш умный дом: REST API, авторизация, устройства, сценарии, WebSocket.
Python-мост между агентом и вашим API. Пишется за вечер, живёт годами.
Whisper и Piper локально. Никаких облаков, никакой телеметрии.
Прежде чем подключать Hermes, убедимся, что ваш PHP-бэкенд умеет то, что нужно агенту. Ему потребуются четыре базовых операции: список устройств, чтение состояния, список действий и выполнение действия.
| Метод | Эндпоинт | Назначение |
|---|---|---|
GET | /api/devices | Список всех устройств с фильтрами по типу и комнате |
GET | /api/device/{id} | Текущее состояние устройства |
GET | /api/device/{id}/actions | Доступные действия для устройства |
POST | /api/device/{id}/action | Выполнить действие: включить, выключить, задать параметр |
POST | /api/scene/{id}/run | Запустить сценарий |
<?php
// index.php — точка входа API умного дома
require 'vendor/autoload.php';
use App\Auth;
use App\DeviceController;
use App\SceneController;
$auth = new Auth();
$token = $_SERVER['HTTP_X_API_TOKEN'] ?? null;
if (!$auth->verify($token)) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true);
header('Content-Type: application/json');
match (true) {
$path === '/api/devices' && $method === 'GET'
=> (new DeviceController())->index($_GET),
preg_match('#^/api/device/([\w\-]+)$#', $path, $m) && $method === 'GET'
=> (new DeviceController())->show($m[1]),
preg_match('#^/api/device/([\w\-]+)/actions$#', $path, $m) && $method === 'GET'
=> (new DeviceController())->actions($m[1]),
preg_match('#^/api/device/([\w\-]+)/action$#', $path, $m) && $method === 'POST'
=> (new DeviceController())->act($m[1], $input),
preg_match('#^/api/scene/([\w\-]+)/run$#', $path, $m) && $method === 'POST'
=> (new SceneController())->run($m[1]),
default => (function () {
http_response_code(404);
echo json_encode(['error' => 'not found']);
})(),
};
<?php
// src/DeviceController.php
namespace App;
class DeviceController
{
public function index(array $query): void
{
$type = $query['type'] ?? null;
$room = $query['room'] ?? null;
$sql = 'SELECT id, name, type, room, state FROM devices WHERE 1=1';
$params = [];
if ($type) { $sql .= ' AND type = ?'; $params[] = $type; }
if ($room) { $sql .= ' AND room = ?'; $params[] = $room; }
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
echo json_encode(['devices' => $stmt->fetchAll()]);
}
public function show(string $id): void
{
$device = Device::find($id);
if (!$device) { http_response_code(404); echo json_encode(['error' => 'not found']); return; }
echo json_encode($device->toArray());
}
public function actions(string $id): void
{
$device = Device::find($id);
if (!$device) { http_response_code(404); return; }
echo json_encode(['actions' => $device->availableActions()]);
}
public function act(string $id, array $input): void
{
$device = Device::find($id);
if (!$device) { http_response_code(404); return; }
$action = $input['action'] ?? null;
$params = $input['params'] ?? [];
if (!$action || !$device->can($action)) {
http_response_code(400);
echo json_encode(['error' => 'invalid action']);
return;
}
$result = $device->execute($action, $params);
// Публикуем событие в WebSocket-шину для агента
EventBus::publish('device.state_changed', [
'device_id' => $id,
'action' => $action,
'state' => $device->state(),
]);
echo json_encode(['ok' => true, 'state' => $result]);
}
}
Не используйте cookie-сессии — агенту нужен статический токен. Сгенерируйте
его в БД и передавайте в заголовке X-API-Token:
// Храните токен в отдельной таблице api_tokens
// с полями: token_hash, name, scopes, created_at, last_used_at
class Auth
{
public function verify(?string $token): bool
{
if (!$token) return false;
$hash = hash('sha256', $token);
$stmt = Database::pdo()->prepare(
'SELECT id FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL'
);
$stmt->execute([$hash]);
return (bool) $stmt->fetchColumn();
}
}
Ставим агента на ту же машину, где живёт PHP (или рядом — на Raspberry Pi, мини-ПК, домашний сервер). Hermes — Python-фреймворк, поэтому установка тривиальна:
# Стандартная установка
pip install hermes-agent
# Для ARM-плат (Raspberry Pi 3/4/5, Orange Pi)
pip install 'hermes-agent-iot[iot]==0.21.3.post1'
hermes-iot setup --profile iot
Профиль iot включает MQTT и снижает потребление памяти,
что важно для домашнего железа.
Самая интересная часть. Раз у нас нет готового плагина под кастомный PHP-бэкенд — напишем его сами. Hermes-плагин это Python-пакет, который регистрирует инструменты (функции), доступные LLM.
myhome-plugin/
├── pyproject.toml
├── README.md
└── myhome/
├── __init__.py
├── plugin.py
└── client.py
# myhome/client.py
import os
import httpx
from typing import Any
class MyHomeClient:
def __init__(self):
self.base = os.environ["MYHOME_URL"].rstrip("/")
self.token = os.environ["MYHOME_TOKEN"]
self.http = httpx.Client(
timeout=10.0,
headers={"X-API-Token": self.token},
)
def list_devices(self, type_: str | None = None, room: str | None = None) -> dict:
params: dict[str, Any] = {}
if type_: params["type"] = type_
if room: params["room"] = room
r = self.http.get(f"{self.base}/api/devices", params=params)
r.raise_for_status()
return r.json()
def get_state(self, device_id: str) -> dict:
r = self.http.get(f"{self.base}/api/device/{device_id}")
r.raise_for_status()
return r.json()
def list_actions(self, device_id: str) -> dict:
r = self.http.get(f"{self.base}/api/device/{device_id}/actions")
r.raise_for_status()
return r.json()
def call_action(self, device_id: str, action: str, params: dict | None = None) -> dict:
r = self.http.post(
f"{self.base}/api/device/{device_id}/action",
json={"action": action, "params": params or {}},
)
r.raise_for_status()
return r.json()
def run_scene(self, scene_id: str) -> dict:
r = self.http.post(f"{self.base}/api/scene/{scene_id}/run")
r.raise_for_status()
return r.json()
# myhome/plugin.py
from hermes.plugins import Plugin, tool
from .client import MyHomeClient
client = MyHomeClient()
class MyHomePlugin(Plugin):
name = "myhome"
version = "0.1.0"
@tool
async def home_list_devices(self, type_: str = "", room: str = "") -> dict:
"""Список устройств умного дома. Можно фильтровать по типу (light, climate, lock) и комнате."""
return client.list_devices(type_ or None, room or None)
@tool
async def home_get_state(self, device_id: str) -> dict:
"""Получить текущее состояние устройства по его id."""
return client.get_state(device_id)
@tool
async def home_list_actions(self, device_id: str) -> dict:
"""Список действий, доступных для устройства."""
return client.list_actions(device_id)
@tool
async def home_call_action(
self, device_id: str, action: str, params: dict | None = None
) -> dict:
"""Выполнить действие над устройством: turn_on, turn_off, set_brightness, set_temperature и т.д."""
return client.call_action(device_id, action, params)
@tool
async def home_run_scene(self, scene_id: str) -> dict:
"""Запустить сценарий умного дома по его id."""
return client.run_scene(scene_id)
pip install -e ./myhome-plugin
hermes plugins enable myhome
Переменные окружения — в ~/.hermes/.env:
MYHOME_URL=http://127.0.0.1:8080
MYHOME_TOKEN=сгенерированный-токен-из-php-админки
Hermes не занимается звуком — это ответственность обвязки. Для приватного локального Джарвиса используем Whisper и Piper.
import sounddevice as sd
import numpy as np
from faster_whisper import WhisperModel
model = WhisperModel("small", device="cpu", compute_type="int8")
def listen_once(duration: float = 5.0, samplerate: int = 16000) -> str:
audio = sd.rec(int(duration * samplerate), samplerate=samplerate, channels=1)
audio = audio.flatten().astype(np.float32) / 32768.0
segments, _ = model.transcribe(audio, language="ru")
return " ".join(s.text.strip() for s in segments).strip()
import subprocess
import tempfile, os
def speak(text: str) -> None:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav = f.name
subprocess.run([
"piper",
"--model", "ru_RU-irina-medium.onnx",
"--output_file", wav,
], input=text.encode("utf-8"), check=True)
subprocess.run(["aplay", "-q", wav], check=True)
os.unlink(wav)
import asyncio
from hermes import Agent
async def main():
agent = Agent(
system_prompt=(
"Ты — Джарвис, голосовой ассистент умного дома. "
"Отвечай кратко, по-деловому, с легкой иронией. "
"Для управления домом используй инструменты home_*."
),
plugins=["myhome"],
)
while True:
text = listen_once()
if not text or text.lower() in {"стоп", "выход"}:
continue
reply = await agent.chat(text)
speak(reply)
if __name__ == "__main__":
asyncio.run(main())
Настоящий Джарвис не ждет, пока его спросят. Он сам сообщает, что датчик двери сработал или что стиралка закончила цикл. Для этого ваш PHP-бэкенд должен уметь пушить события в Hermes через WebSocket-шину.
<?php
// src/EventBus.php — тонкая обёртка над Redis Pub/Sub или собственным WS
namespace App;
class EventBus
{
public static function publish(string $channel, array $payload): void
{
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$redis->publish('home.events', json_encode([
'channel' => $channel,
'payload' => $payload,
'ts' => time(),
]));
}
}
# myhome/events.py — слушает Redis и перекладывает события в Hermes
import asyncio, json, redis.asyncio as redis
from hermes import Agent
async def event_listener(agent: Agent, on_speak):
r = redis.Redis(host="127.0.0.1", port=6379)
pubsub = r.pubsub()
await pubsub.subscribe("home.events")
async for msg in pubsub.listen():
if msg["type"] != "message":
continue
event = json.loads(msg["data"])
prompt = (
f"Произошло событие в доме: {event['channel']}, "
f"данные: {json.dumps(event['payload'], ensure_ascii=False)}. "
"Реши, нужно ли что-то сделать или сообщить пользователю. Ответь одной фразой."
)
reply = await agent.chat(prompt)
if reply and not reply.startswith("IGNORE"):
await on_speak(reply)
Запускаем ассистента:
python assistant.py
Теперь можно говорить. Примеры фраз и то, что произойдет под капотом:
| Фраза | Что делает агент |
|---|---|
| «Джарвис, выключи свет в гостиной» | Зовет home_list_devices(room="living"), находит лампы, вызывает home_call_action(device_id, "turn_off") |
| «Какая температура в спальне?» | Зовет home_get_state("climate.bedroom") и озвучивает результат |
| «Сделай потемнее в кабинете» | Читает состояние, вычисляет новую яркость и вызывает set_brightness |
| «Запусти сценарий Кино» | Зовет home_run_scene("cinema") — дальше уже ваш PHP решает, что гасить и что включать |
| «Закрой входную дверь» | Проверяет состояние замка и вызывает home_call_action("lock.front", "lock") |
«Джарвис, выключи свет в гостиной» — и через 800 миллисекунд лампы гаснут.
Ваш PHP-бэкенд даже не знает, что команда пришла из голоса: для него это
обычный POST на /api/device/light.living_room/action.
Hermes Agent + ваш собственный PHP-бэкенд = Джарвис, полностью под вашим контролем. Что вы получаете по сравнению с Home Assistant-связкой:
Свои токены, свои scopes, своя логика авторизации. Никакой чужой модели прав.
Никаких прослоек вроде MQTT-мостов. Агент бьёт прямо в ваш API.
Термины предметной области: ваши сценарии, комнаты, типы устройств.
Локальный Whisper, локальный Piper, локальная LLM. Данные не покидают дом.
Свой дом, свой API, свой Джарвис. Как и должно быть у инженера.