Metadata-Version: 2.5
Name: rmind_yandex_smart_home
Version: 0.2.0
Summary: Yandex smart home cloud function engine
Project-URL: Homepage, https://gitverse.ru/rmind.dev/rmind-alice-yandex-cloud-library
Project-URL: Issues, https://gitverse.ru/rmind.dev/rmind-alice-yandex-cloud-library/tasktracker
Author-email: "rMind.Dev" <rmind.dev@yandex.ru>, Korhog <grafinfector@hotmail.com>
License-File: LICENSE
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Requires-Dist: paho-mqtt==1.6.1
Requires-Dist: requests
Requires-Dist: yandexcloud
Requires-Dist: ydb
Description-Content-Type: text/markdown

# rmind.yandex.smart.home

Библиотека для реализации умного дома с Алисой в Yandex Cloud Function.
Покрыта вся часть 1 и 2 [spec.md](spec.md): шесть задокументированных умений со
всеми инстансами, свойства `float`/`event` и реестр типов устройств.

**v0.2.0 — релиз с поломкой совместимости.** Ниже раздел «Миграция с 0.1.9».

```
pip install rmind-yandex-smart-home
```

### Необходимая облачная инфраструктура

#### Managed Service for YDB
хранение состояния устройств

| Переменные окружения | Значение | По умолчанию | Обязательный |
|----------------------|----------|--------------|--------------|
| YDB_ENDPOINT | grpcs | grpcs://ydb.serverless.yandexcloud.net:2135 | Нет |
| YDB_DATABASE | адресс БД  |- | Да |

#### IoT Core/Брокер
общение с устройствами пользователя через MQTT протокол

| Переменные окружения | Значение      | По умолчанию          | Обязательный |
|----------------------|---------------|-----------------------|--------------|
| MQTT_URL             | адрес брокера | mqtt.cloud.yandex.net | Нет          |
| MQTT_PORT            | порт брокера  | 8883                  | Нет          |
| MQTT_USER            | пользователь  | -                     | Да           |
| MQTT_PASS            | пароль        | -                     | Да           |

YDB подключается лениво — при первом обращении к состоянию, а не на `import`.
Пока навык не трогает хранилище, без `YDB_DATABASE` библиотека импортируется.

## Быстрый старт

```python
from rmind_yandex_smart_home import (Engine, FloatProperty, OnOff, Range,
                                     YandexIoTDevice)

class Strip(YandexIoTDevice):
  def __init__(self, id):
    super().__init__(id, "Ледовая лента", "devices.types.light.strip")

  @OnOff()
  def on_off(self, params):
    return 'on:1' if params['value'] else 'on:0'

  @Range('brightness')
  def brightness(self, params):
    return f"bright:{params['value']}"

  @FloatProperty('power')
  def power(self):
    return 12.5          # собственное значение свойства

engine = Engine()
engine.register_device(Strip("my-device-unique-id"))

def handler(event, context):
  return engine.handle(event, context)
```

Метод умения возвращает текст сообщения устройству, которое Engine отправит в
MQTT-топик `/yandex-iot-core/{device_id}/commands`. Возврат `None` — команда не
нужна, действие всё равно считается выполненным (например, состояние изменено
только на стороне функции). Словарь или список сериализуются в JSON.

Метод свойства возвращает текущее значение либо `None`.

## Capabilities (умения)

Все умения — классы-декораторы. Общие параметры каждого: `retrievable`
(по умолчанию `True`), `reportable` (по умолчанию `False`), `default` (значение
по умолчанию для `query`). `retrievable=False` исключает умение из ответа
`query` — провайдер не может знать состояние.

### OnOff — `devices.capabilities.on_off`

```python
@OnOff(split=False)                 # split=True: разные команды на вкл и выкл
def on_off(self, params):            # params: {"instance": "on", "value": True}
  return 'on:1' if params['value'] else 'on:0'
```

### ColorSetting — `devices.capabilities.color_setting`

Нужен хотя бы один из `color_model`, `temperature_k`, `scenes`. Инстанс
(`hsv` / `rgb` / `temperature_k` / `scene`) приходит в запросе и не объявляется
отдельно — одно умение покрывает все объявленные представления цвета.

```python
@ColorSetting(color_model='rgb', temperature_k=[2700, 6500], scenes=['party', 'garland'])
def color(self, params):             # {"instance": "rgb", "value": 14210514}
  return get_rgb_message(params['value'])
```

| Аргумент | Значение |
|---|---|
| `color_model` | `'rgb'` (по умолчанию) или `'hsv'`; `None` — цвет не объявлен |
| `temperature_k` | пара `[min, max]` в кельвинах |
| `scenes` | список id из `catalog.COLOR_SCENES` (16 сцен) |

`rgb` — целое `[0; 16777215]`, `hsv` — `{h: 0..360, s: 0..100, v: 0..100}`.
Состояние цвета хранится в одной строке: `rgb`, температура и сцена — это одно
и то же «какой сейчас свет», а не независимые параметры.

```python
def get_rgb_message(value):
  h = hex(value)[2:]
  if len(h) == 5:
    h = '0' + h
  r, g, b = tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
  return f"{r};{g};{b}"
```

### Mode — `devices.capabilities.mode`

```python
@Mode('thermostat')                                  # значения из каталога
def thermostat(self, params):                        # {"instance": "thermostat", "value": "cool"}
  return f"mode:{params['value']}"

@Mode('program', modes=['express', 'auto', 'one'])   # свой порядок и подмножество
def program(self, params):
  ...
```

`instance` обязателен. Доступные инстансы (полные списки значений —
`catalog.MODELS`, рекомендации по типам — `catalog.PROGRAM_MODES`):
`cleanup_mode`, `coffee_mode`, `dishwashing`, `fan_speed`, `heat`,
`input_source`, `program`, `swing`, `tea_mode`, `thermostat`,
`ventilation_mode`, `work_speed`.

Значение `default` при пропуске — первый объявленный режим.

### Range — `devices.capabilities.range`

```python
@Range('brightness')                                          # 0..100, unit.percent
@Range('temperature', minimum=40, maximum=100, precision=5)    # unit.temperature.celsius
@Range('open', precision=10)                                   # шторы: 0..100, шаг 10
@Range('volume', unit=None)                                    # единицы не объявлены
```

| Инстанс | unit по умолчанию | Диапазон по умолчанию |
|---|---|---|
| `brightness` | `unit.percent` | 0–100, шаг 1 |
| `channel` | — | 0–999, шаг 1 |
| `humidity` | `unit.percent` | 0–100, шаг 1 |
| `open` | `unit.percent` | 0–100, шаг 1 |
| `temperature` | `unit.temperature.celsius` | 18–40, шаг 1 |
| `volume` | `unit.percent` | 0–100, шаг 1 |

`minimum`, `maximum`, `precision`, `unit`, `random_access` переопределяются в
декораторе. Относительные команды (`relative: true`) Engine разворачивает в
абсолютное значение по сохранённому состоянию и приводит к шагу и границам —
обработчик всегда получает готовое число.

### Toggle — `devices.capabilities.toggle`

```python
@Toggle('ionization')
def ionization(self, params):        # {"instance": "ionization", "value": True}
  return 'ion:1' if params['value'] else 'ion:0'
```

Инстансы: `backlight`, `controls_locked`, `ionization`, `keep_warm`, `mute`,
`oscillation`, `pause`.

## Properties (свойства)

Свойства только читаются: Алиса запрашивает их в `query`. Источник значения —
по порядку приоритета:

1. **геттер** — метод под декоратором, если вернул не `None`;
2. **хранилище** — строка YDB, куда записал `action` или `handle_device_report`;
3. **`default=`** — если объявлен явно.

Если ничего не дало значение, свойство просто отсутствует в ответе. Для
событийных свойств это принципиально: выдуманное `motion: detected` заставило бы
Алису докладывать о движении, которого не было.

```python
class Humidifier(YandexIoTDevice):
  @FloatProperty('humidity')          # unit.percent подставится из каталога
  def humidity(self):
    return None                       # своё значение неизвестно — бери из хранилища

  @FloatProperty('temperature', unit='unit.temperature.kelvin')
  def temperature(self):
    return read_sensor()              # собственный источник значения

  @EventProperty('water_level')
  def water(self):
    return None
```

Событийное свойство забирается из хранилища один раз: после `query` строка
удаляется, чтобы следующее показание не было повтором того же события.

### `devices.properties.float`

| Инстанс | Единица по умолчанию |
|---|---|
| `amperage` | `unit.ampere` |
| `battery_level` | `unit.percent` |
| `co2_level` | `unit.ppm` |
| `electricity_meter` | `unit.kilowatt_hour` |
| `food_level` | `unit.percent` |
| `gas_meter` | `unit.cubic_meter` |
| `heat_meter` | `unit.gigacalorie` |
| `humidity` | `unit.percent` |
| `illumination` | `unit.illumination.lux` |
| `meter` | — (единицы не требуются) |
| `pm1_density`, `pm2.5_density`, `pm10_density`, `tvoc` | `unit.density.mcg_m3` |
| `power` | `unit.watt` |
| `pressure` | `unit.pressure.mmhg` (допустимы также `atm`, `pascal`, `bar`) |
| `temperature` | `unit.temperature.celsius` (допустим `kelvin`) |
| `voltage` | `unit.volt` |
| `water_level` | `unit.percent` |
| `water_meter` | `unit.cubic_meter` |

### `devices.properties.event`

| Инстанс | Значения |
|---|---|
| `battery_level` | `low`, `normal` |
| `button` | `click`, `double_click`, `long_press` |
| `food_level` | `empty`, `low`, `normal` |
| `gas` | `detected`, `not_detected`, `high` |
| `motion` | `detected`, `not_detected` |
| `open` | `opened`, `closed` |
| `smoke` | `detected`, `not_detected`, `high` |
| `vibration` | `tilt`, `fall`, `vibration` |
| `water_leak` | `dry`, `leak` |
| `water_level` | `empty`, `low`, `normal` |

В `spec.md` значения `smoke` и `gas` не детализированы — список выше взят из
документации Yandex. subset объявляется аргументом `events=['click']`.

## Типы устройств

`device_types` — таблица «устройство → рекомендованные умения/свойства» из
части 3 `spec.md`. При `register_device` библиотека сверяет декларации с
рекомендациями и печатает предупреждения; отказывать в регистрации она не
правомочна — платформа эти списки не навязывает.

```python
from rmind_yandex_smart_home import device_types

device_types.get('devices.types.thermostat.ac').allows_capability('mode', 'swing')  # True
device_types.get('devices.types.light').allows_property('float', 'temperature')      # False
```

Неизвестный тип устройства (`devices.types.unknown`) тоже регистрируется, но с
предупреждением: для реальных устройств используйте конкретный тип, иначе навык
могут отклонить на модерации.

## Ошибки: `DeviceError` и `error_code`

Ответ `action` при `ERROR` обязан нести код из фиксированного списка платформы.

```python
from rmind_yandex_smart_home import DeviceError

@OnOff()
def on_off(self, params):
  if params['value'] and self.water_level < 10:
    raise DeviceError('NOT_ENOUGH_WATER', 'Долейте воду')
  return 'kettle:on'
```

| Что произошло | Что вернёт Engine |
|---|---|
| `raise DeviceError(code, message)` | `ERROR`, ваш `code` и `message` |
| любое другое исключение в обработчике | `ERROR`, `INTERNAL_ERROR` |
| команда не доставлена в MQTT | `ERROR`, `DEVICE_UNREACHABLE` |
| умение/инстанс не объявлен на устройстве | `ERROR`, `INVALID_ACTION` |
| значение вне диапазона или не того типа | `ERROR`, `INVALID_VALUE` |
| устройство не зарегистрировано | `ERROR`, `DEVICE_NOT_FOUND` |

Коды: `DOOR_OPEN`, `LID_OPEN`, `REMOTE_CONTROL_DISABLED`, `NOT_ENOUGH_WATER`,
`LOW_CHARGE_LEVEL`, `CONTAINER_FULL`, `CONTAINER_EMPTY`, `DRIP_TRAY_FULL`,
`DEVICE_STUCK`, `DEVICE_OFF`, `FIRMWARE_OUT_OF_DATE`, `NOT_ENOUGH_DETERGENT`,
`HUMAN_INVOLVEMENT_NEEDED`, `DEVICE_UNREACHABLE`, `DEVICE_BUSY`,
`INTERNAL_ERROR`, `INVALID_ACTION`, `INVALID_VALUE`,
`NOT_SUPPORTED_IN_CURRENT_MODE`, `ACCOUNT_LINKING_ERROR`, `DEVICE_NOT_FOUND`
(`catalog.ERROR_CODES`).

## Приём показаний с устройства: `handle_device_report`

Значения свойств приходят с самого устройства, а не от Алисы. Устройство
публикует JSON в топик `device.get_state_topic()`
(`/yandex-iot-core/{id}/states`), триггер Cloud Function вызывает:

```python
def states_handler(event, context):
  return engine.handle_device_report(event)
```

```json
{
  "device_id": "sensor-01",
  "user_id": 12345,
  "properties": [
    {"type": "devices.properties.float", "instance": "temperature", "value": 23.5},
    {"type": "devices.properties.event", "instance": "motion", "value": "detected"}
  ],
  "capabilities": [
    {"type": "devices.capabilities.range", "instance": "brightness", "value": 40}
  ]
}
```

Принимаются только объявленные на устройстве умения и свойства с корректными
значениями: устройство не может записать то, чего у него нет. Ответ содержит
`reported` и `rejected` с причинами.

`user_id` в отчёте обязателен на практике: состояние ищется по паре
`(user_id, key)`. Отчёт устройства не содержит токен Алисы, поэтому id берётся
из `user_id` отчёта либо из `Engine(state_user_id=...)`. Без него значение
пишется в бакет `0` и `query` его не увидит — библиотека печатает предупреждение.

## Пользовательское умение

Своё умение — наследник `Capability`; механизм тот же, что у встроенных.

```python
from rmind_yandex_smart_home import Capability

class WorkSpeed(Capability):
  type = 'devices.capabilities.mode'
  keyed_by_instance = True          # состояние различается по инстансу

  def __init__(self, **kwargs):
    super().__init__('work_speed', **kwargs)
    self.parameters['instance'] = 'work_speed'
    self.parameters['modes'] = [{'value': x} for x in ('min', 'turbo')]

  def default_state(self, instance=None):
    return {'instance': 'work_speed', 'value': 'min'}

  def validate(self, state):
    ok, code, message = super().validate(state)
    if not ok:
      return ok, code, message
    if state.get('value') not in ('min', 'turbo'):
      return False, 'INVALID_VALUE', 'speed: min или turbo'
    return True, None, None
```

```python
@WorkSpeed()
def work_speed(self, params):
  return f'speed:{params["value"]}'
```

## Хранение состояния

Таблица YDB `device_capability_state` с прошлых версий не меняется —
`user_id, capability_key, device_id, capability_state`. Изменился только формат
ключа, потому что прежний (`cp_{device}_{тип}`) давал двум range-умениям одного
устройства писать в одну строку:

| Что | Ключ |
|---|---|
| умение с инстансами (range, mode, toggle) | `cp_{device_id}_{тип}_{instance}` |
| умение с одним состоянием (on_off, color_setting) | `cp_{device_id}_{тип}` |
| свойство | `pr_{device_id}_{float\|event}_{instance}` |

Старые записи под прежними ключами становятся нечитаемыми и будут перезаписаны
при первых командах. Если терять текущее состояние нельзя — перепишите ключи
отдельным скриптом перед выкаткой.

`user_id` для ключей — реальный id пользователя из токена запроса
(`login.yandex.ru/info`). В 0.1.9 discovery отдавал захардкоженный id, из-за чего
все пользователи делили одно состояние.

## Миграция с 0.1.9

| 0.1.9 | 0.2.0 |
|---|---|
| `@on_off()` | `@OnOff()` |
| `@color_setting(temperature_k=[2700, 5100], scenes=[...])` | `@ColorSetting(...)` — аргументы те же |
| `@brightness()` | `@Range('brightness')` |
| `@mode(modes=[...])` (всегда `program`) | `@Mode('program', modes=[...])` — `instance` обязателен |
| — | `@Toggle(instance)`, `@FloatProperty(instance)`, `@EventProperty(instance)` |
| `Engine()` | `Engine(state_user_id=<id владельца>)` — если есть отчёты устройств |
| обработчик вернул `None` → `ERROR` | обработчик вернул `None` → `DONE` без команды |
| `action_result` без `error_code` | всегда валидный `error_code` |
| `device.result(params, success)` | удалён: состояние ответа собирает Engine |
| состояние в YDB поднималось на `import` | лениво, при первом обращении |

`rgb` остаётся моделью цвета по умолчанию, так что существующие световые
устройства после переименования декораторов работают как раньше.

## Что не реализовано

- `devices.capabilities.video_stream` — требует `stream_url` в ответе action и
  заголовков отдачи HLS.
- `reportable`-умения: параметр принимается и уходит в discovery, но push в
  `https://api.dialogs.yandex.ru/api/devices/state` не отправляется — для него
  нужен OAuth-токен навыка, которого в библиотеке нет. Состояние читается по
  `query`.
- `custom` — в `spec.md` помечен как недокументированный (404), не реализован.

## Разработка

```
python -m unittest discover -s tests -t .
```

Тесты не требуют YDB, брокера и доступа к Яндексу: внешние модули подменяются,
хранилище и MQTT-клиент инжектятся в `Engine`. Публика пакета — workflow
`.gitverse/workflows/artifactory.yaml` по тегу `v*`.
