Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# secrets
**/.env
**/secrets.yaml
**/*secret*
**/*.pem
**/*.key
**/*.crt
# runtime data
**/data/
**/logs/
**/log/
**/cache/
**/tmp/
**/backups/
**/.storage/
**/*.db
**/*.db-shm
**/*.db-wal
**/*.log
**/*.log.*
**/tts/
**/__pycache__/
**/*.pyc
# service-specific caches
homeassistant/config/custom_components/hacs/hacs_frontend/
homeassistant/config/home-assistant*.log*
homeassistant/config/home-assistant_v2.db*
homeassistant/config/tts/
immich/immich/db/
immich/immich/model-cache/
immich/immich/redis/
voice/piper-data/
voice/whisper-data/
# archives
_archive/
# gitlab specific
**/gitlab/config/
**/gitlab/config/gitlab-secrets.json

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# /srv/docker
Единый репозиторий dockerстеков сервера.
## Содержимое
- `bitwarden/` — vaultwarden
- `gitlab/` — GitLab CE (selfhosted)
- `homeassistant/` — HA
- `immich/` — Immich
- `minio/` — MinIO
- `voice/` — Wyoming (Piper/Whisper)
- `_legacy/` — старые/временные compose
- `_archive/` — архивы (в git не попадают)
## Примечания
- Файлы `.env`, `data/`, `logs/`, `backups/` и прочие runtimeданные исключены из git.
- Для добавления новых сервисов — создаём папку и `docker-compose.yml`.

View File

@@ -0,0 +1,241 @@
version: "3.8"
services:
minio:
image: quay.io/minio/minio:latest
command: server /data1 /data2 --console-address ":9001"
environment:
MINIO_ROOT_USER: *
MINIO_ROOT_PASSWORD: *
MINIO_PROMETHEUS_AUTH_TYPE: public
MINIO_REGION: ru-1
ports:
- "9000:9000"
- "9110:9001"
volumes:
- /mnt/disk2/minio1:/data1
- /mnt/disk1/minio2:/data2
restart: unless-stopped
minio-init:
image: quay.io/minio/mc:latest
depends_on: [minio]
environment:
MC_HOST_minio: "http://minio-root:minio-root-pass@minio:9000"
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:9000 minio-root minio-root-pass &&
mc mb -p minio/nextcloud || true &&
mc admin user add minio nextcloud nextcloud-secret &&
mc admin policy attach minio readwrite --user nextcloud || true &&
exit 0
"
restart: "no"
postgres:
image: postgres:16
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: *
POSTGRES_PASSWORD: *
volumes:
- /data/postgres:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server", "--save", "", "--appendonly", "no"]
restart: unless-stopped
nextcloud:
build: ./nextcloud
image: nextcloud:30-apache-ffmpeg
depends_on: [postgres, redis, minio, minio-init]
ports:
- "80:80"
dns:
- 1.1.1.1
- 8.8.8.8
dns_opt:
- use-vc
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: nextcloud
POSTGRES_USER: *
POSTGRES_PASSWORD: *
NEXTCLOUD_TRUSTED_DOMAINS: *
NEXTCLOUD_ADMIN_USER: *
NEXTCLOUD_ADMIN_PASSWORD: *
OVERWRITEHOST: *
OVERWRITEPROTOCOL: *
TRUSTED_PROXIES: *
MAGICK_THREAD_LIMIT: *
OMP_NUM_THREADS: *
volumes:
- /data/nextcloud/html:/var/www/html
nextcloud-cron:
image: nextcloud:30-apache
restart: unless-stopped
volumes:
- /data/nextcloud/html:/var/www/html
entrypoint: /cron.sh
depends_on:
- nextcloud
# --- IMMICH AND REQ-S ---
immich-server:
image: ghcr.io/immich-app/immich-server:release
container_name: immich-server
environment:
# URL, по которому клиенты будут подключаться
PUBLIC_URL: https://photos.g4st3r.ru
UPLOAD_LOCATION: /usr/src/app/upload
# база данных
DB_HOSTNAME: immich-db
DB_PORT: 5432
DB_USERNAME: *
DB_PASSWORD: *
DB_DATABASE_NAME: *
# Redis
REDIS_HOSTNAME: immich-redis
REDIS_PORT: 6379
# ML сервис
MACHINE_LEARNING_URL: http://immich-ml:3003
volumes:
- /mnt/disk3/immich/uploads:/usr/src/app/upload
depends_on:
- immich-db
- immich-redis
- immich-ml
ports:
- 2283:2283
restart: unless-stopped
immich-ml:
image: ghcr.io/immich-app/immich-machine-learning:release
container_name: immich-ml
ports:
- "3003:3003"
environment:
- TRANSFORMERS_CACHE=/cache
volumes:
- /mnt/disk2/immich/ml-cache:/cache
restart: unless-stopped
immich-db:
image: pgvector/pgvector:pg16
container_name: immich-db
environment:
- POSTGRES_USER=${DB_USERNAME:-immich}
- POSTGRES_PASSWORD=${DB_PASSWORD:-immich_strong_pass}
- POSTGRES_DB=${DB_DATABASE_NAME:-immich}
volumes:
- /mnt/disk2/immich/db:/var/lib/postgresql/data
restart: unless-stopped
immich-redis:
image: redis:7-alpine
container_name: immich-redis
command: ["redis-server", "--save", "60", "1"]
volumes:
- /mnt/disk2/immich/redis:/data
restart: unless-stopped
# --- EXPORTERS & MONITORING ---
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
ports:
- "9100:9100"
command:
- '--path.rootfs=/host'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)'
volumes:
- '/:/host:ro,rslave'
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:latest
container_name: postgres-exporter
restart: unless-stopped
environment:
# у тебя Postgres с юзером nc и БД nextcloud
DATA_SOURCE_NAME: "postgresql://nc:*@postgres:5432/nextcloud?sslmode=disable"
ports:
- "9187:9187"
depends_on:
- postgres
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
volumes:
- /srv/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
depends_on:
- node-exporter
- cadvisor
- postgres-exporter
- minio
smartctl-exporter:
image: prometheuscommunity/smartctl-exporter:v0.9.1
container_name: smartctl-exporter
privileged: true # нужно, чтобы читать SMART с устройств
volumes:
- /dev:/dev:ro
- /run/udev:/run/udev:ro
environment:
- SMARTCTL_INTERVAL=300 # каждые 5 минут
ports:
- "9633:9633"
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=*
- GF_SERVER_DOMAIN=*
- GF_SERVER_ROOT_URL=*
volumes:
- /srv/monitoring/grafana:/var/lib/grafana
- /srv/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
ports:
- "3000:3000"
depends_on:
- prometheus
alertmanager:
image: prom/alertmanager:latest
container_name: alertmanager
command:
- --config.file=/etc/alertmanager/alertmanager.yml
- --web.external-url=https://alert.g4st3r.ru
volumes:
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ./alertmanager/templates:/etc/alertmanager/templates:ro
ports:
- "9093:9093"
restart: unless-stopped
environment:
- TELEGRAM_BOT_TOKEN=*
- TELEGRAM_CHAT_ID=*
volumes:
prometheus-data:

View File

@@ -0,0 +1,17 @@
version: "3.8"
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://bw.g4st3r.ru"
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "false"
WEBSOCKET_ENABLED: "true"
ADMIN_TOKEN: "${VW_ADMIN_TOKEN}"
volumes:
- ./data:/data
ports:
- "8087:80"

19
gitlab/docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: unless-stopped
hostname: gitlab
ports:
- "2224:22"
- "9080:80"
- "9443:443"
volumes:
- ./config:/etc/gitlab
- ./logs:/var/log/gitlab
- ./data:/var/opt/gitlab
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.g4st3r.ru'
gitlab_rails['gitlab_shell_ssh_port'] = 2224

View File

@@ -0,0 +1 @@
2025.11.1

View File

@@ -0,0 +1 @@
{"pid": 67, "version": 1, "ha_version": "2025.11.1", "start_ts": 1771675562.0109854}

View File

@@ -0,0 +1,395 @@
- id: '1762991834560'
alias: Утро - свет
description: ''
triggers:
- trigger: time
at: '9:30:00'
weekday:
- mon
- tue
- wed
- thu
- fri
conditions:
- condition: device
device_id: 51bac6c99c3cc7a5c2640ce4cd55cca8
domain: device_tracker
entity_id: 842d4a75a61896ca8d3930b322c57764
type: is_home
actions:
- action: light.turn_on
metadata: {}
data:
color_temp_kelvin: 6500
brightness_pct: 100
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
mode: single
- id: '1762992032227'
alias: Утро - свет моргание
description: ''
triggers:
- trigger: time
at: 09:10:00
weekday:
- mon
- wed
- tue
- thu
- fri
- sat
- sun
conditions:
- condition: device
device_id: 51bac6c99c3cc7a5c2640ce4cd55cca8
domain: device_tracker
entity_id: 842d4a75a61896ca8d3930b322c57764
type: is_home
actions:
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_on
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_on
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_on
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
- delay:
hours: 0
minutes: 0
seconds: 2
milliseconds: 0
- action: light.turn_on
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
mode: single
- id: '1762992378204'
alias: Ночь - свет
description: ''
triggers:
- trigger: time
at: 00:00:00
weekday:
- mon
- wed
- tue
- thu
- fri
- sat
- sun
conditions: []
actions:
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
mode: single
- id: '1762992684588'
alias: Вечер - свет
description: ''
triggers:
- trigger: sun
event: sunset
offset: 0
conditions:
- condition: device
device_id: 51bac6c99c3cc7a5c2640ce4cd55cca8
domain: device_tracker
entity_id: 842d4a75a61896ca8d3930b322c57764
type: is_home
actions:
- action: light.turn_on
metadata: {}
data:
color_temp_kelvin: 2000
brightness_pct: 50
target:
entity_id:
- light.iot_led_classic
- light.iot_a61_rgb
mode: single
- id: '1762994506315'
alias: Утренний дашборд в Telegram
description: Подробный утренний отчёт о доме и системе
triggers:
- at: 09:30:00
trigger: time
conditions: []
actions:
- action: telegram_bot.send_message
data:
message: 'Доброе утро!
Это тестовое сообщение
Свет уже должен гореть в спальне'
mode: single
- id: "1762999000001"
alias: "Алерты - ресурсы системы"
description: "Диск/память/CPU: уведомления в Telegram"
triggers:
- trigger: numeric_state
entity_id: sensor.localhost_disk_free
below: 20
for: "00:10:00"
- trigger: numeric_state
entity_id: sensor.localhost_mnt_disk2_disk_usage
above: 80
for: "00:10:00"
- trigger: numeric_state
entity_id: sensor.localhost_mnt_disk3_disk_usage
above: 80
for: "00:10:00"
- trigger: numeric_state
entity_id: sensor.localhost_memory_usage
above: 90
for: "00:10:00"
- trigger: numeric_state
entity_id: sensor.localhost_swap_usage
above: 80
for: "00:10:00"
- trigger: numeric_state
entity_id: sensor.localhost_cpu_usage
above: 80
for: "00:05:00"
actions:
- choose:
- conditions: "{{ trigger.entity_id == sensor.localhost_disk_free and (trigger.to_state.state|float(0)) < 10 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🔴 CRIT: Мало места на root — {{ trigger.to_state.state }} {{ trigger.to_state.attributes.unit_of_measurement }} свободно"
- conditions: "{{ trigger.entity_id == sensor.localhost_disk_free and (trigger.to_state.state|float(0)) < 20 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🟡 WARN: Мало места на root — {{ trigger.to_state.state }} {{ trigger.to_state.attributes.unit_of_measurement }} свободно"
- conditions: "{{ trigger.entity_id in [sensor.localhost_mnt_disk2_disk_usage,sensor.localhost_mnt_disk3_disk_usage] and (trigger.to_state.state|float(0)) >= 90 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🔴 CRIT: Диск {{ trigger.entity_id }} заполнен на {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id in [sensor.localhost_mnt_disk2_disk_usage,sensor.localhost_mnt_disk3_disk_usage] and (trigger.to_state.state|float(0)) >= 80 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🟡 WARN: Диск {{ trigger.entity_id }} заполнен на {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id == sensor.localhost_memory_usage and (trigger.to_state.state|float(0)) >= 95 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🔴 CRIT: RAM {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id == sensor.localhost_memory_usage and (trigger.to_state.state|float(0)) >= 90 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🟡 WARN: RAM {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id == sensor.localhost_swap_usage and (trigger.to_state.state|float(0)) >= 90 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🔴 CRIT: Swap {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id == sensor.localhost_swap_usage and (trigger.to_state.state|float(0)) >= 80 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🟡 WARN: Swap {{ trigger.to_state.state }}%"
- conditions: "{{ trigger.entity_id == sensor.localhost_cpu_usage and (trigger.to_state.state|float(0)) >= 90 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🔴 CRIT: CPU {{ trigger.to_state.state }}% (5+ мин)"
- conditions: "{{ trigger.entity_id == sensor.localhost_cpu_usage and (trigger.to_state.state|float(0)) >= 80 }}"
sequence:
- action: telegram_bot.send_message
data:
message: "🟡 WARN: CPU {{ trigger.to_state.state }}% (5+ мин)"
mode: single
- id: "1763004000001"
alias: "Контекст: выход из дома — лампочки"
description: "Напоминание о покупке лампочек при выходе из дома"
triggers:
- trigger: state
entity_id: person.aleksandr
to: "not_home"
for: "00:02:00"
conditions:
- condition: template
value_template: "{{ this.attributes.last_triggered is none or (as_timestamp(now()) - as_timestamp(this.attributes.last_triggered)) > 21600 }}"
actions:
- action: telegram_bot.send_message
data:
message: "🛒 Не забудь: купить лампочки на кухню и коридор."
mode: single
- id: "1763004000002"
alias: "Контекст: возвращение домой — барабаны"
description: "Вечернее напоминание обновить заметку про барабаны"
triggers:
- trigger: state
entity_id: person.aleksandr
to: "home"
for: "00:02:00"
conditions:
- condition: time
after: "18:00:00"
before: "23:30:00"
- condition: template
value_template: "{{ this.attributes.last_triggered is none or (as_timestamp(now()) - as_timestamp(this.attributes.last_triggered)) > 21600 }}"
actions:
- action: telegram_bot.send_message
data:
message: "🥁 Если есть силы: обнови заметку по барабанам (цели/план/что практиковать)."
mode: single
- id: "1763004000003"
alias: "Контекст: утро дома — 3 приоритета"
description: "Утреннее напоминание выбрать 3 приоритета дня"
triggers:
- trigger: state
entity_id: person.aleksandr
to: "home"
for: "00:05:00"
conditions:
- condition: time
after: "08:30:00"
before: "12:00:00"
- condition: template
value_template: "{{ this.attributes.last_triggered is none or (as_timestamp(now()) - as_timestamp(this.attributes.last_triggered)) > 21600 }}"
actions:
- action: telegram_bot.send_message
data:
message: "☀️ Выбери 3 главных приоритета на сегодня (1 важное, 1 среднее, 1 мелочь)."
mode: single
- id: "1763004000004"
alias: "Контекст: ночь — проверить дом"
description: "Ночное напоминание проверить свет и утренние дела"
triggers:
- trigger: time
at: "23:30:00"
conditions:
- condition: state
entity_id: person.aleksandr
state: "home"
actions:
- action: telegram_bot.send_message
data:
message: "🌙 На ночь: проверь, что свет/приборы выключены, и отметь 1 утреннюю задачу."
mode: single
- id: "1763004000005"
alias: "Контекст: выезд с работы — Ozon"
description: "Напоминание зайти в пункт выдачи Ozon по дороге"
triggers:
- trigger: state
entity_id: person.aleksandr
to: "not_home"
for: "00:02:00"
conditions:
- condition: time
after: "17:00:00"
before: "21:30:00"
- condition: template
value_template: "{{ this.attributes.last_triggered is none or (as_timestamp(now()) - as_timestamp(this.attributes.last_triggered)) > 21600 }}"
actions:
- action: telegram_bot.send_message
data:
message: "📦 По дороге: зайди в пункт выдачи Ozon."
mode: single
- id: "1763004000006"
alias: "Напоминание: отпуск к 25 числу"
description: "Раз в пару дней после 1 марта напоминать про отпуск на 25 число"
triggers:
- trigger: time
at: "10:00:00"
conditions:
- condition: template
value_template: "{{ now().date() >= strptime(2026-03-01,%Y-%m-%d).date() }}"
- condition: template
value_template: "{{ (now().timetuple().tm_yday % 2) == 1 }}"
actions:
- action: telegram_bot.send_message
data:
message: "✈️ Напоминание: подумай об отпуске на 25е (и соседние даты)."
mode: single

View File

@@ -0,0 +1,58 @@
blueprint:
name: Motion-activated Light
description: Turn on a light when motion is detected.
domain: automation
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml
author: Home Assistant
input:
motion_entity:
name: Motion Sensor
selector:
entity:
filter:
- device_class: occupancy
domain: binary_sensor
- device_class: motion
domain: binary_sensor
light_target:
name: Light
selector:
target:
entity:
domain: light
no_motion_wait:
name: Wait time
description: Time to leave the light on after last motion is detected.
default: 120
selector:
number:
min: 0
max: 3600
unit_of_measurement: seconds
# If motion is detected within the delay,
# we restart the script.
mode: restart
max_exceeded: silent
triggers:
trigger: state
entity_id: !input motion_entity
from: "off"
to: "on"
actions:
- alias: "Turn on the light"
action: light.turn_on
target: !input light_target
- alias: "Wait until there is no motion from device"
wait_for_trigger:
trigger: state
entity_id: !input motion_entity
from: "on"
to: "off"
- alias: "Wait the number of seconds that has been set"
delay: !input no_motion_wait
- alias: "Turn off the light"
action: light.turn_off
target: !input light_target

View File

@@ -0,0 +1,50 @@
blueprint:
name: Zone Notification
description: Send a notification to a device when a person leaves a specific zone.
domain: automation
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml
author: Home Assistant
input:
person_entity:
name: Person
selector:
entity:
filter:
domain: person
zone_entity:
name: Zone
selector:
entity:
filter:
domain: zone
notify_device:
name: Device to notify
description: Device needs to run the official Home Assistant app to receive notifications.
selector:
device:
filter:
integration: mobile_app
triggers:
trigger: state
entity_id: !input person_entity
variables:
zone_entity: !input zone_entity
# This is the state of the person when it's in this zone.
zone_state: "{{ states[zone_entity].name }}"
person_entity: !input person_entity
person_name: "{{ states[person_entity].name }}"
conditions:
condition: template
# The first case handles leaving the Home zone which has a special state when zoning called 'home'.
# The second case handles leaving all other zones.
value_template: "{{ zone_entity == 'zone.home' and trigger.from_state.state == 'home' and trigger.to_state.state != 'home' or trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}"
actions:
- alias: "Notify that a person has left the zone"
domain: mobile_app
type: notify
device_id: !input notify_device
message: "{{ person_name }} has left {{ zone_state }}"

View File

@@ -0,0 +1,86 @@
blueprint:
name: Confirmable Notification
description: >-
A script that sends an actionable notification with a confirmation before
running the specified action.
domain: script
source_url: https://github.com/home-assistant/core/blob/master/homeassistant/components/script/blueprints/confirmable_notification.yaml
author: Home Assistant
input:
notify_device:
name: Device to notify
description: Device needs to run the official Home Assistant app to receive notifications.
selector:
device:
filter:
integration: mobile_app
title:
name: "Title"
description: "The title of the button shown in the notification."
default: ""
selector:
text:
message:
name: "Message"
description: "The message body"
selector:
text:
confirm_text:
name: "Confirmation Text"
description: "Text to show on the confirmation button"
default: "Confirm"
selector:
text:
confirm_action:
name: "Confirmation Action"
description: "Action to run when notification is confirmed"
default: []
selector:
action:
dismiss_text:
name: "Dismiss Text"
description: "Text to show on the dismiss button"
default: "Dismiss"
selector:
text:
dismiss_action:
name: "Dismiss Action"
description: "Action to run when notification is dismissed"
default: []
selector:
action:
mode: restart
sequence:
- alias: "Set up variables"
variables:
action_confirm: "{{ 'CONFIRM_' ~ context.id }}"
action_dismiss: "{{ 'DISMISS_' ~ context.id }}"
- alias: "Send notification"
domain: mobile_app
type: notify
device_id: !input notify_device
title: !input title
message: !input message
data:
actions:
- action: "{{ action_confirm }}"
title: !input confirm_text
- action: "{{ action_dismiss }}"
title: !input dismiss_text
- alias: "Awaiting response"
wait_for_trigger:
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_confirm }}"
- trigger: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_dismiss }}"
- choose:
- conditions: "{{ wait.trigger.event.data.action == action_confirm }}"
sequence: !input confirm_action
- conditions: "{{ wait.trigger.event.data.action == action_dismiss }}"
sequence: !input dismiss_action

View File

@@ -0,0 +1,27 @@
blueprint:
name: Invert a binary sensor
description: Creates a binary_sensor which holds the inverted value of a reference binary_sensor
domain: template
source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/inverted_binary_sensor.yaml
input:
reference_entity:
name: Binary sensor to be inverted
description: The binary_sensor which needs to have its value inverted
selector:
entity:
domain: binary_sensor
variables:
reference_entity: !input reference_entity
binary_sensor:
state: >
{% if states(reference_entity) == 'on' %}
off
{% elif states(reference_entity) == 'off' %}
on
{% else %}
{{ states(reference_entity) }}
{% endif %}
# delay_on: not_used in this example
# delay_off: not_used in this example
# auto_off: not_used in this example
availability: "{{ states(reference_entity) not in ('unknown', 'unavailable') }}"

View File

@@ -0,0 +1,63 @@
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
- ::1
- 10.8.0.0/24
template:
- sensor:
- name: "Network RX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states(sensor.localhost_wlp2s0_rx) | float * 8 / 1_000_000 ) | round(2) }}
- name: "Network TX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states(sensor.localhost_wlp2s0_tx) | float * 8 / 1_000_000 ) | round(2) }}
command_line:
- sensor:
name: "WireGuard wg0 status"
command: >-
ip link show wg0 | grep -q "LOWER_UP" && echo UP || echo DOWN
scan_interval: 30
- sensor:
name: "Docker containers summary"
command: "/config/docker_containers_summary.py"
scan_interval: 60
value_template: "{{ value_json.count }}"
json_attributes:
- list
rest:
- resource: http://127.0.0.1:8099/status
scan_interval: 60
sensor:
- name: "OpenClaw Gateway Reachable"
value_template: "{{ value_json.gateway.reachable }}"
- name: "OpenClaw Gateway Latency"
unit_of_measurement: "ms"
value_template: "{{ value_json.gateway.connectLatencyMs }}"
- name: "OpenClaw Sessions"
value_template: "{{ value_json.agents.totalSessions }}"
- name: "OpenClaw Bootstrap Pending"
value_template: "{{ value_json.agents.bootstrapPendingCount }}"
- name: "OpenClaw Last Active Age"
unit_of_measurement: "ms"
value_template: "{{ value_json.agents.agents[0].lastActiveAgeMs }}"
shell_command:
docker_restart: "curl --unix-socket /var/run/docker.sock -X POST http://localhost/containers/{{ container }}/restart"

View File

@@ -0,0 +1,38 @@
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
- ::1
- 10.8.0.0/24
template:
- sensor:
- name: "Network RX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_rx') | float * 8 / 1_000_000 ) | round(2) }}
- name: "Network TX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_tx') | float * 8 / 1_000_000 ) | round(2) }}
sensor:
- platform: command_line
name: "WireGuard wg0 status"
command: >-
ip link show wg0 | grep -q "LOWER_UP" && echo UP || echo DOWN
scan_interval: 30

View File

@@ -0,0 +1,37 @@
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
- ::1
- 10.8.0.0/24
template:
- sensor:
- name: "Network RX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_rx') | float * 8 / 1_000_000 ) | round(2) }}
- name: "Network TX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_tx') | float * 8 / 1_000_000 ) | round(2) }}
sensor:
- platform: command_line
name: "WireGuard wg0 status"
command: >-
ip link show wg0 | grep -q "LOWER_UP" && echo UP || echo DOWN
scan_interval: 30

View File

@@ -0,0 +1,41 @@
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
- ::1
- 10.8.0.0/24
template:
- sensor:
- name: "Network RX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_rx') | float * 8 / 1_000_000 ) | round(2) }}
- name: "Network TX Mbit (wlp2s0)"
unit_of_measurement: "Mbit/s"
state: >
{{ (states('sensor.localhost_wlp2s0_tx') | float * 8 / 1_000_000 ) | round(2) }}
sensor:
- platform: command_line
name: "WireGuard wg0 status"
command: >-
ip link show wg0 | grep -q "LOWER_UP" && echo UP || echo DOWN
scan_interval: 30
mqtt:
broker: 127.0.0.1
port: 1883

View File

@@ -0,0 +1,229 @@
"""HACS gives you a powerful UI to handle downloads of all your custom needs.
For more details about this integration, please refer to the documentation at
https://hacs.xyz/
"""
from __future__ import annotations
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion
from homeassistant.components.frontend import async_remove_panel
from homeassistant.components.lovelace.system_health import system_health_info
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, __version__ as HAVERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
from homeassistant.loader import async_get_integration
from .base import HacsBase
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
from .data_client import HacsDataClient
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
from .frontend import async_register_frontend
from .utils.data import HacsData
from .utils.queue_manager import QueueManager
from .utils.version import version_left_higher_or_equal_then_right
from .websocket import async_register_websocket_commands
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
async def _async_initialize_integration(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> bool:
"""Initialize the integration"""
hass.data[DOMAIN] = hacs = HacsBase()
hacs.enable_hacs()
if config_entry.source == SOURCE_IMPORT:
# Import is not supported
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
return False
hacs.configuration.update_from_dict(
{
"config_entry": config_entry,
**config_entry.data,
**config_entry.options,
},
)
integration = await async_get_integration(hass, DOMAIN)
hacs.set_stage(None)
hacs.log.info(STARTUP, integration.version)
clientsession = async_get_clientsession(hass)
hacs.integration = integration
hacs.version = integration.version
hacs.configuration.dev = integration.version == "0.0.0"
hacs.hass = hass
hacs.queue = QueueManager(hass=hass)
hacs.data = HacsData(hacs=hacs)
hacs.data_client = HacsDataClient(
session=clientsession,
client_name=f"HACS/{integration.version}",
)
hacs.system.running = True
hacs.session = clientsession
hacs.core.lovelace_mode = LovelaceMode.YAML
try:
lovelace_info = await system_health_info(hacs.hass)
hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
# If this happens, the users YAML is not valid, we assume YAML mode
pass
hacs.core.config_path = hacs.hass.config.path()
if hacs.core.ha_version is None:
hacs.core.ha_version = AwesomeVersion(HAVERSION)
## Legacy GitHub client
hacs.github = GitHub(
hacs.configuration.token,
clientsession,
headers={
"User-Agent": f"HACS/{hacs.version}",
"Accept": ACCEPT_HEADERS["preview"],
},
)
## New GitHub client
hacs.githubapi = GitHubAPI(
token=hacs.configuration.token,
session=clientsession,
**{"client_name": f"HACS/{hacs.version}"},
)
async def async_startup():
"""HACS startup tasks."""
hacs.enable_hacs()
try:
import custom_components.custom_updater
except ImportError:
pass
else:
hacs.log.critical(
"HACS cannot be used with custom_updater. "
"To use HACS you need to remove custom_updater from `custom_components`",
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not version_left_higher_or_equal_then_right(
hacs.core.ha_version.string,
MINIMUM_HA_VERSION,
):
hacs.log.critical(
"You need HA version %s or newer to use this integration.",
MINIMUM_HA_VERSION,
)
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
return False
if not await hacs.data.restore():
hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
hacs.set_active_categories()
async_register_websocket_commands(hass)
await async_register_frontend(hass, hacs)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
hacs.set_stage(HacsStage.SETUP)
if hacs.system.disabled:
return False
hacs.set_stage(HacsStage.WAITING)
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
# Schedule startup tasks
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
return not hacs.system.disabled
async def async_try_startup(_=None):
"""Startup wrapper for yaml config."""
try:
startup_result = await async_startup()
except AIOGitHubAPIException:
startup_result = False
if not startup_result:
if hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN:
hacs.log.info("Could not setup HACS, trying again in 15 min")
async_call_later(hass, 900, async_try_startup)
return
hacs.enable_hacs()
await async_try_startup()
# Remove old (v0-v1) sensor if it exists, can be removed in v3
er = async_get_entity_registry(hass)
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
er.async_remove(old_sensor)
# Mischief managed!
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry)
hacs: HacsBase = hass.data[DOMAIN]
return setup_result and not hacs.system.disabled
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
hacs: HacsBase = hass.data[DOMAIN]
if hacs.queue.has_pending_tasks:
hacs.log.warning("Pending tasks, can not unload, try again later.")
return False
# Clear out pending queue
hacs.queue.clear()
for task in hacs.recurring_tasks:
# Cancel all pending tasks
task()
# Store data
await hacs.data.async_write(force=True)
try:
if hass.data.get("frontend_panels", {}).get("hacs"):
hacs.log.info("Removing sidepanel")
async_remove_panel(hass, "hacs")
except AttributeError:
pass
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
hacs.set_stage(None)
hacs.disable_hacs(HacsDisabledReason.REMOVED)
hass.data.pop(DOMAIN, None)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the HACS config entry."""
if not await async_unload_entry(hass, config_entry):
return
await async_setup_entry(hass, config_entry)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
"""Adds config flow for HACS."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from typing import TYPE_CHECKING
from aiogithubapi import (
GitHubDeviceAPI,
GitHubException,
GitHubLoginDeviceModel,
GitHubLoginOauthModel,
)
from aiogithubapi.common.const import OAUTH_USER_LOGIN
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigFlow, OptionsFlow
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.loader import async_get_integration
import voluptuous as vol
from .base import HacsBase
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
from .utils.configuration_schema import (
APPDAEMON,
COUNTRY,
SIDEPANEL_ICON,
SIDEPANEL_TITLE,
)
from .utils.logger import LOGGER
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for HACS."""
VERSION = 1
hass: HomeAssistant
activation_task: asyncio.Task | None = None
device: GitHubDeviceAPI | None = None
_registration: GitHubLoginDeviceModel | None = None
_activation: GitHubLoginOauthModel | None = None
_reauth: bool = False
def __init__(self) -> None:
"""Initialize."""
self._errors = {}
self._user_input = {}
async def async_step_user(self, user_input):
"""Handle a flow initialized by the user."""
self._errors = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if self.hass.data.get(DOMAIN):
return self.async_abort(reason="single_instance_allowed")
if user_input:
if [x for x in user_input if x.startswith("acc_") and not user_input[x]]:
self._errors["base"] = "acc"
return await self._show_config_form(user_input)
self._user_input = user_input
return await self.async_step_device(user_input)
# Initial form
return await self._show_config_form(user_input)
async def async_step_device(self, _user_input):
"""Handle device steps."""
async def _wait_for_activation() -> None:
try:
response = await self.device.activation(device_code=self._registration.device_code)
self._activation = response.data
finally:
async def _progress():
with suppress(UnknownFlow):
await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
if not self.device:
integration = await async_get_integration(self.hass, DOMAIN)
self.device = GitHubDeviceAPI(
client_id=CLIENT_ID,
session=aiohttp_client.async_get_clientsession(self.hass),
**{"client_name": f"HACS/{integration.version}"},
)
try:
response = await self.device.register()
self._registration = response.data
except GitHubException as exception:
LOGGER.exception(exception)
return self.async_abort(reason="could_not_register")
if self.activation_task is None:
self.activation_task = self.hass.async_create_task(_wait_for_activation())
if self.activation_task.done():
if (exception := self.activation_task.exception()) is not None:
LOGGER.exception(exception)
return self.async_show_progress_done(next_step_id="could_not_register")
return self.async_show_progress_done(next_step_id="device_done")
show_progress_kwargs = {
"step_id": "device",
"progress_action": "wait_for_device",
"description_placeholders": {
"url": OAUTH_USER_LOGIN,
"code": self._registration.user_code,
},
"progress_task": self.activation_task,
}
return self.async_show_progress(**show_progress_kwargs)
async def _show_config_form(self, user_input):
"""Show the configuration form to edit location data."""
if not user_input:
user_input = {}
if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION:
return self.async_abort(
reason="min_ha_version",
description_placeholders={"version": MINIMUM_HA_VERSION},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool,
vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool,
vol.Required(
"acc_untested", default=user_input.get("acc_untested", False)
): bool,
vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
}
),
errors=self._errors,
)
async def async_step_device_done(self, user_input: dict[str, bool] | None = None):
"""Handle device steps"""
if self._reauth:
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
self.hass.config_entries.async_update_entry(
existing_entry, data={**existing_entry.data, "token": self._activation.access_token}
)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(
title="",
data={
"token": self._activation.access_token,
},
options={
"experimental": True,
},
)
async def async_step_could_not_register(self, _user_input=None):
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
async def async_step_reauth(self, _user_input=None):
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None):
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
self._reauth = True
return await self.async_step_device(None)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return HacsOptionsFlowHandler(config_entry)
class HacsOptionsFlowHandler(OptionsFlow):
"""HACS config flow options handler."""
def __init__(self, config_entry):
"""Initialize HACS options flow."""
if AwesomeVersion(HAVERSION) < "2024.11.99":
self.config_entry = config_entry
async def async_step_init(self, _user_input=None):
"""Manage the options."""
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
hacs: HacsBase = self.hass.data.get(DOMAIN)
if user_input is not None:
return self.async_create_entry(title="", data={**user_input, "experimental": True})
if hacs is None or hacs.configuration is None:
return self.async_abort(reason="not_setup")
if hacs.queue.has_pending_tasks:
return self.async_abort(reason="pending_tasks")
schema = {
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
}
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))

View File

@@ -0,0 +1,294 @@
"""Constants for HACS"""
from typing import TypeVar
from aiogithubapi.common.const import ACCEPT_HEADERS
NAME_SHORT = "HACS"
DOMAIN = "hacs"
CLIENT_ID = "395a8e669c5de9f7c6e8"
MINIMUM_HA_VERSION = "2024.4.1"
URL_BASE = "/hacsfiles"
TV = TypeVar("TV")
PACKAGE_NAME = "custom_components.hacs"
DEFAULT_CONCURRENT_TASKS = 15
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
HACS_REPOSITORY_ID = "172733314"
HACS_ACTION_GITHUB_API_HEADERS = {
"User-Agent": "HACS/action",
"Accept": ACCEPT_HEADERS["preview"],
}
VERSION_STORAGE = "6"
STORENAME = "hacs"
HACS_SYSTEM_ID = "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"
STARTUP = """
-------------------------------------------------------------------
HACS (Home Assistant Community Store)
Version: %s
This is a custom integration
If you have any issues with this you need to open an issue here:
https://github.com/hacs/integration/issues
-------------------------------------------------------------------
"""
LOCALE = [
"ALL",
"AF",
"AL",
"DZ",
"AS",
"AD",
"AO",
"AI",
"AQ",
"AG",
"AR",
"AM",
"AW",
"AU",
"AT",
"AZ",
"BS",
"BH",
"BD",
"BB",
"BY",
"BE",
"BZ",
"BJ",
"BM",
"BT",
"BO",
"BQ",
"BA",
"BW",
"BV",
"BR",
"IO",
"BN",
"BG",
"BF",
"BI",
"KH",
"CM",
"CA",
"CV",
"KY",
"CF",
"TD",
"CL",
"CN",
"CX",
"CC",
"CO",
"KM",
"CG",
"CD",
"CK",
"CR",
"HR",
"CU",
"CW",
"CY",
"CZ",
"CI",
"DK",
"DJ",
"DM",
"DO",
"EC",
"EG",
"SV",
"GQ",
"ER",
"EE",
"ET",
"FK",
"FO",
"FJ",
"FI",
"FR",
"GF",
"PF",
"TF",
"GA",
"GM",
"GE",
"DE",
"GH",
"GI",
"GR",
"GL",
"GD",
"GP",
"GU",
"GT",
"GG",
"GN",
"GW",
"GY",
"HT",
"HM",
"VA",
"HN",
"HK",
"HU",
"IS",
"IN",
"ID",
"IR",
"IQ",
"IE",
"IM",
"IL",
"IT",
"JM",
"JP",
"JE",
"JO",
"KZ",
"KE",
"KI",
"KP",
"KR",
"KW",
"KG",
"LA",
"LV",
"LB",
"LS",
"LR",
"LY",
"LI",
"LT",
"LU",
"MO",
"MK",
"MG",
"MW",
"MY",
"MV",
"ML",
"MT",
"MH",
"MQ",
"MR",
"MU",
"YT",
"MX",
"FM",
"MD",
"MC",
"MN",
"ME",
"MS",
"MA",
"MZ",
"MM",
"NA",
"NR",
"NP",
"NL",
"NC",
"NZ",
"NI",
"NE",
"NG",
"NU",
"NF",
"MP",
"NO",
"OM",
"PK",
"PW",
"PS",
"PA",
"PG",
"PY",
"PE",
"PH",
"PN",
"PL",
"PT",
"PR",
"QA",
"RO",
"RU",
"RW",
"RE",
"BL",
"SH",
"KN",
"LC",
"MF",
"PM",
"VC",
"WS",
"SM",
"ST",
"SA",
"SN",
"RS",
"SC",
"SL",
"SG",
"SX",
"SK",
"SI",
"SB",
"SO",
"ZA",
"GS",
"SS",
"ES",
"LK",
"SD",
"SR",
"SJ",
"SZ",
"SE",
"CH",
"SY",
"TW",
"TJ",
"TZ",
"TH",
"TL",
"TG",
"TK",
"TO",
"TT",
"TN",
"TR",
"TM",
"TC",
"TV",
"UG",
"UA",
"AE",
"GB",
"US",
"UM",
"UY",
"UZ",
"VU",
"VE",
"VN",
"VG",
"VI",
"WF",
"EH",
"YE",
"ZM",
"ZW",
]

View File

@@ -0,0 +1,38 @@
"""Coordinator to trigger entity updates."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
class HacsUpdateCoordinator(BaseDataUpdateCoordinatorProtocol):
"""Dispatch updates to update entities."""
def __init__(self) -> None:
"""Initialize."""
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
) -> Callable[[], None]:
"""Listen for data updates."""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._listeners.pop(remove_listener)
self._listeners[remove_listener] = (update_callback, context)
return remove_listener
@callback
def async_update_listeners(self) -> None:
"""Update all registered listeners."""
for update_callback, _ in list(self._listeners.values()):
update_callback()

View File

@@ -0,0 +1,98 @@
"""HACS Data client."""
from __future__ import annotations
import asyncio
from typing import Any
from aiohttp import ClientSession, ClientTimeout
import voluptuous as vol
from .exceptions import HacsException, HacsNotModifiedException
from .utils.logger import LOGGER
from .utils.validate import (
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
VALIDATE_FETCHED_V2_REPO_DATA,
)
CRITICAL_REMOVED_VALIDATORS = {
"critical": VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
"removed": VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
}
class HacsDataClient:
"""HACS Data client."""
def __init__(self, session: ClientSession, client_name: str) -> None:
"""Initialize."""
self._client_name = client_name
self._etags = {}
self._session = session
async def _do_request(
self,
filename: str,
section: str | None = None,
) -> dict[str, dict[str, Any]] | list[str]:
"""Do request."""
endpoint = "/".join([v for v in [section, filename] if v is not None])
try:
response = await self._session.get(
f"https://data-v2.hacs.xyz/{endpoint}",
timeout=ClientTimeout(total=60),
headers={
"User-Agent": self._client_name,
"If-None-Match": self._etags.get(endpoint, ""),
},
)
if response.status == 304:
raise HacsNotModifiedException() from None
response.raise_for_status()
except HacsNotModifiedException:
raise
except TimeoutError:
raise HacsException("Timeout of 60s reached") from None
except Exception as exception:
raise HacsException(f"Error fetching data from HACS: {exception}") from exception
self._etags[endpoint] = response.headers.get("etag")
return await response.json()
async def get_data(self, section: str | None, *, validate: bool) -> dict[str, dict[str, Any]]:
"""Get data."""
data = await self._do_request(filename="data.json", section=section)
if not validate:
return data
if section in VALIDATE_FETCHED_V2_REPO_DATA:
validated = {}
for key, repo_data in data.items():
try:
validated[key] = VALIDATE_FETCHED_V2_REPO_DATA[section](repo_data)
except vol.Invalid as exception:
LOGGER.info(
"Got invalid data for %s (%s)", repo_data.get("full_name", key), exception
)
continue
return validated
if not (validator := CRITICAL_REMOVED_VALIDATORS.get(section)):
raise ValueError(f"Do not know how to validate {section}")
validated = []
for repo_data in data:
try:
validated.append(validator(repo_data))
except vol.Invalid as exception:
LOGGER.info("Got invalid data for %s (%s)", section, exception)
continue
return validated
async def get_repositories(self, section: str) -> list[str]:
"""Get repositories."""
return await self._do_request(filename="repositories.json", section=section)

View File

@@ -0,0 +1,80 @@
"""Diagnostics support for HACS."""
from __future__ import annotations
from typing import Any
from aiogithubapi import GitHubException
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .base import HacsBase
from .const import DOMAIN
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hacs: HacsBase = hass.data[DOMAIN]
data = {
"entry": entry.as_dict(),
"hacs": {
"stage": hacs.stage,
"version": hacs.version,
"disabled_reason": hacs.system.disabled_reason,
"new": hacs.status.new,
"startup": hacs.status.startup,
"categories": hacs.common.categories,
"renamed_repositories": hacs.common.renamed_repositories,
"archived_repositories": hacs.common.archived_repositories,
"ignored_repositories": hacs.common.ignored_repositories,
"lovelace_mode": hacs.core.lovelace_mode,
"configuration": {},
},
"custom_repositories": [
repo.data.full_name
for repo in hacs.repositories.list_all
if not hacs.repositories.is_default(str(repo.data.id))
],
"repositories": [],
}
for key in (
"appdaemon",
"country",
"debug",
"dev",
"python_script",
"release_limit",
"theme",
):
data["hacs"]["configuration"][key] = getattr(hacs.configuration, key, None)
for repository in hacs.repositories.list_downloaded:
data["repositories"].append(
{
"data": repository.data.to_json(),
"integration_manifest": repository.integration_manifest,
"repository_manifest": repository.repository_manifest.to_dict(),
"ref": repository.ref,
"paths": {
"localpath": repository.localpath.replace(hacs.core.config_path, "/config"),
"local": repository.content.path.local.replace(
hacs.core.config_path, "/config"
),
"remote": repository.content.path.remote,
},
}
)
try:
rate_limit_response = await hacs.githubapi.rate_limit()
data["rate_limit"] = rate_limit_response.data.as_dict
except GitHubException as exception:
data["rate_limit"] = str(exception)
return async_redact_data(data, ("token",))

View File

@@ -0,0 +1,143 @@
"""HACS Base entities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
from .coordinator import HacsUpdateCoordinator
from .enums import HacsDispatchEvent, HacsGitHubRepo
if TYPE_CHECKING:
from .base import HacsBase
from .repositories.base import HacsRepository
def system_info(hacs: HacsBase) -> dict:
"""Return system info."""
return {
"identifiers": {(DOMAIN, HACS_SYSTEM_ID)},
"name": NAME_SHORT,
"manufacturer": "hacs.xyz",
"model": "",
"sw_version": str(hacs.version),
"configuration_url": "homeassistant://hacs",
"entry_type": DeviceEntryType.SERVICE,
}
class HacsBaseEntity(Entity):
"""Base HACS entity."""
repository: HacsRepository | None = None
_attr_should_poll = False
def __init__(self, hacs: HacsBase) -> None:
"""Initialize."""
self.hacs = hacs
class HacsDispatcherEntity(HacsBaseEntity):
"""Base HACS entity listening to dispatcher signals."""
async def async_added_to_hass(self) -> None:
"""Register for status events."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
HacsDispatchEvent.REPOSITORY,
self._update_and_write_state,
)
)
@callback
def _update(self) -> None:
"""Update the sensor."""
async def async_update(self) -> None:
"""Manual updates of the sensor."""
self._update()
@callback
def _update_and_write_state(self, _: Any) -> None:
"""Update the entity and write state."""
self._update()
self.async_write_ha_state()
class HacsSystemEntity(HacsDispatcherEntity):
"""Base system entity."""
_attr_icon = "hacs:hacs"
_attr_unique_id = HACS_SYSTEM_ID
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
return system_info(self.hacs)
class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
"""Base repository entity."""
def __init__(
self,
hacs: HacsBase,
repository: HacsRepository,
) -> None:
"""Initialize."""
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
HacsBaseEntity.__init__(self, hacs=hacs)
self.repository = repository
self._attr_unique_id = str(repository.data.id)
self._repo_last_fetched = repository.data.last_fetched
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.hacs.repositories.is_downloaded(repository_id=str(self.repository.data.id))
@property
def device_info(self) -> dict[str, any]:
"""Return device information about HACS."""
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
return system_info(self.hacs)
def _manufacturer():
if authors := self.repository.data.authors:
return ", ".join(author.replace("@", "") for author in authors)
return self.repository.data.full_name.split("/")[0]
return {
"identifiers": {(DOMAIN, str(self.repository.data.id))},
"name": self.repository.display_name,
"model": self.repository.data.category,
"manufacturer": _manufacturer(),
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
"entry_type": DeviceEntryType.SERVICE,
}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
self._repo_last_fetched is not None
and self.repository.data.last_fetched is not None
and self._repo_last_fetched >= self.repository.data.last_fetched
):
return
self._repo_last_fetched = self.repository.data.last_fetched
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""

View File

@@ -0,0 +1,71 @@
"""Helper constants."""
# pylint: disable=missing-class-docstring
from enum import StrEnum
class HacsGitHubRepo(StrEnum):
"""HacsGitHubRepo."""
DEFAULT = "hacs/default"
INTEGRATION = "hacs/integration"
class HacsCategory(StrEnum):
APPDAEMON = "appdaemon"
INTEGRATION = "integration"
LOVELACE = "lovelace"
PLUGIN = "plugin" # Kept for legacy purposes
PYTHON_SCRIPT = "python_script"
TEMPLATE = "template"
THEME = "theme"
REMOVED = "removed"
def __str__(self):
return str(self.value)
class HacsDispatchEvent(StrEnum):
"""HacsDispatchEvent."""
CONFIG = "hacs_dispatch_config"
ERROR = "hacs_dispatch_error"
RELOAD = "hacs_dispatch_reload"
REPOSITORY = "hacs_dispatch_repository"
REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress"
STAGE = "hacs_dispatch_stage"
STARTUP = "hacs_dispatch_startup"
STATUS = "hacs_dispatch_status"
class RepositoryFile(StrEnum):
"""Repository file names."""
HACS_JSON = "hacs.json"
MAINIFEST_JSON = "manifest.json"
class LovelaceMode(StrEnum):
"""Lovelace Modes."""
STORAGE = "storage"
AUTO = "auto"
AUTO_GEN = "auto-gen"
YAML = "yaml"
class HacsStage(StrEnum):
SETUP = "setup"
STARTUP = "startup"
WAITING = "waiting"
RUNNING = "running"
BACKGROUND = "background"
class HacsDisabledReason(StrEnum):
RATE_LIMIT = "rate_limit"
REMOVED = "removed"
INVALID_TOKEN = "invalid_token"
CONSTRAINS = "constrains"
LOAD_HACS = "load_hacs"
RESTORE = "restore"

View File

@@ -0,0 +1,49 @@
"""Custom Exceptions for HACS."""
class HacsException(Exception):
"""Super basic."""
class HacsRepositoryArchivedException(HacsException):
"""For repositories that are archived."""
class HacsNotModifiedException(HacsException):
"""For responses that are not modified."""
class HacsExpectedException(HacsException):
"""For stuff that are expected."""
class HacsRepositoryExistException(HacsException):
"""For repositories that are already exist."""
class HacsExecutionStillInProgress(HacsException):
"""Exception to raise if execution is still in progress."""
class AddonRepositoryException(HacsException):
"""Exception to raise when user tries to add add-on repository."""
exception_message = (
"The repository does not seem to be a integration, "
"but an add-on repository. HACS does not manage add-ons."
)
def __init__(self) -> None:
super().__init__(self.exception_message)
class HomeAssistantCoreRepositoryException(HacsException):
"""Exception to raise when user tries to add the home-assistant/core repository."""
exception_message = (
"You can not add homeassistant/core, to use core integrations "
"check the Home Assistant documentation for how to add them."
)
def __init__(self) -> None:
super().__init__(self.exception_message)

View File

@@ -0,0 +1,67 @@
"""Starting setup task: Frontend."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from homeassistant.components.frontend import (
add_extra_js_url,
async_register_built_in_panel,
)
from .const import DOMAIN, URL_BASE
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
from .utils.workarounds import async_register_static_path
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .base import HacsBase
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
"""Register the frontend."""
# Register frontend
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
hacs.log.warning(
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
)
await async_register_static_path(
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
)
hacs.frontend_version = "dev"
else:
await async_register_static_path(
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
)
hacs.frontend_version = FE_VERSION
# Custom iconset
await async_register_static_path(
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
)
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title=hacs.configuration.sidepanel_title,
sidebar_icon=hacs.configuration.sidepanel_icon,
frontend_url_path=DOMAIN,
config={
"_panel_custom": {
"name": "hacs-frontend",
"embed_iframe": True,
"trust_external": False,
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={hacs.frontend_version}",
}
},
require_admin=True,
)
# Setup plugin endpoint if needed
await hacs.async_setup_frontend_endpoint_plugin()

View File

@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"pre-release": {
"state": {
"on": "mdi:test-tube",
"off": "mdi:test-tube-off"
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
const hacsIcons = {
hacs: {
path: "m 20.064849,22.306912 c -0.0319,0.369835 -0.280561,0.707789 -0.656773,0.918212 -0.280572,0.153036 -0.605773,0.229553 -0.950094,0.229553 -0.0765,0 -0.146661,-0.0064 -0.216801,-0.01275 -0.605774,-0.05739 -1.135016,-0.344329 -1.402827,-0.7588 l 0.784304,-0.516495 c 0.0893,0.146659 0.344331,0.312448 0.707793,0.34433 0.235931,0.02551 0.471852,-0.01913 0.637643,-0.108401 0.101998,-0.05101 0.172171,-0.127529 0.17854,-0.191295 0.0065,-0.08289 -0.0255,-0.369835 -0.733293,-0.439975 -1.013854,-0.09565 -1.645127,-0.688661 -1.568606,-1.460214 0.0319,-0.382589 0.280561,-0.714165 0.663153,-0.930965 0.331571,-0.172165 0.752423,-0.25506 1.166895,-0.210424 0.599382,0.05739 1.128635,0.344329 1.402816,0.7588 l -0.784304,0.510118 c -0.0893,-0.140282 -0.344331,-0.299694 -0.707782,-0.331576 -0.235932,-0.02551 -0.471863,0.01913 -0.637654,0.10202 -0.0956,0.05739 -0.165791,0.133906 -0.17216,0.191295 -0.0255,0.293317 0.465482,0.420847 0.726913,0.439976 v 0.0064 c 1.020234,0.09565 1.638757,0.66953 1.562237,1.460213 z m -7.466854,-0.988354 c 0,-1.192401 0.962855,-2.155249 2.15525,-2.155249 0.599393,0 1.179645,0.25506 1.594117,0.707789 l -0.695033,0.624895 c -0.235931,-0.25506 -0.561133,-0.401718 -0.899084,-0.401718 -0.675903,0 -1.217906,0.542 -1.217906,1.217906 0,0.66953 0.542003,1.217908 1.217906,1.217908 0.337951,0 0.663153,-0.140283 0.899084,-0.401718 l 0.695033,0.631271 c -0.414472,0.452729 -0.988355,0.707788 -1.594117,0.707788 -1.192395,0 -2.15525,-0.969224 -2.15525,-2.148872 z M 8.6573365,23.461054 10.353474,19.14418 h 0.624893 l 1.568618,4.316874 H 11.52037 L 11.265308,22.734136 H 9.964513 l -0.274192,0.726918 z m 1.6833885,-1.68339 h 0.580263 L 10.646796,21.012487 Z M 8.1089536,19.156932 v 4.297745 H 7.1461095 v -1.645131 h -1.606867 v 1.645131 H 4.5763876 v -4.297745 h 0.9628549 v 1.696143 h 1.606867 V 19.156932 Z M 20.115859,4.2997436 C 20.090359,4.159461 19.969198,4.0574375 19.822548,4.0574375 H 14.141102 10.506516 4.8250686 c -0.14665,0 -0.2678112,0.1020202 -0.2933108,0.2423061 L 3.690064,8.8461703 c -0.00651,0.01913 -0.00651,0.03826 -0.00651,0.057391 v 1.5239797 c 0,0.165789 0.133911,0.299694 0.2996911,0.299694 H 4.5762579 20.0711 20.664112 c 0.165781,0 0.299691,-0.133905 0.299691,-0.299694 V 8.8971848 c 0,-0.01913 0,-0.03826 -0.0065,-0.05739 z M 4.5763876,17.358767 c 0,0.184917 0.1466608,0.331577 0.3315819,0.331577 h 5.5985465 3.634586 0.924594 c 0.184911,0 0.331571,-0.14666 0.331571,-0.331577 v -4.744098 c 0,-0.184918 0.146661,-0.331577 0.331582,-0.331577 h 2.894913 c 0.184921,0 0.331582,0.146659 0.331582,0.331577 v 4.744098 c 0,0.184917 0.146661,0.331577 0.331571,0.331577 h 0.446363 c 0.18491,0 0.331571,-0.14666 0.331571,-0.331577 v -5.636804 c 0,-0.184918 -0.146661,-0.331577 -0.331571,-0.331577 H 4.9079695 c -0.1849211,0 -0.3315819,0.146659 -0.3315819,0.331577 z m 1.6578879,-4.852498 h 5.6495565 c 0.15303,0 0.280561,0.12753 0.280561,0.280564 v 3.513438 c 0,0.153036 -0.127531,0.280566 -0.280561,0.280566 H 6.2342755 c -0.1530412,0 -0.2805719,-0.12753 -0.2805719,-0.280566 v -3.513438 c 0,-0.159411 0.1275307,-0.280564 0.2805719,-0.280564 z M 19.790657,3.3879075 H 4.8569594 c -0.1530412,0 -0.2805718,-0.1275296 -0.2805718,-0.2805642 V 1.3665653 C 4.5763876,1.2135296 4.7039182,1.086 4.8569594,1.086 H 19.790657 c 0.153041,0 0.280572,0.1275296 0.280572,0.2805653 v 1.740778 c 0,0.1530346 -0.127531,0.2805642 -0.280572,0.2805642 z",
keywords: ["hacs", "home assistant community store"],
},
};
window.customIcons = window.customIcons || {};
window.customIconsets = window.customIconsets || {};
window.customIcons["hacs"] = {
getIcon: async (iconName) => (
{ path: hacsIcons[iconName]?.path }
),
getIconList: async () =>
Object.entries(hacsIcons).map(([icon, content]) => ({
name: icon,
keywords: content.keywords,
})
)
};

View File

@@ -0,0 +1,26 @@
{
"domain": "hacs",
"name": "HACS",
"after_dependencies": [
"python_script"
],
"codeowners": [
"@ludeeus"
],
"config_flow": true,
"dependencies": [
"http",
"websocket_api",
"frontend",
"persistent_notification",
"lovelace",
"repairs"
],
"documentation": "https://hacs.xyz/docs/use/",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/hacs/integration/issues",
"requirements": [
"aiogithubapi>=22.10.1"
],
"version": "2.0.5"
}

View File

@@ -0,0 +1,58 @@
"""Repairs platform for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
import voluptuous as vol
from custom_components.hacs.base import HacsBase
from .const import DOMAIN
class RestartRequiredFixFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
def __init__(self, issue_id: str) -> None:
self.issue_id = issue_id
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_restart()
async def async_step_confirm_restart(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
await self.hass.services.async_call("homeassistant", "restart")
return self.async_create_entry(title="", data={})
hacs: HacsBase = self.hass.data[DOMAIN]
integration = hacs.repositories.get_by_id(self.issue_id.split("_")[2])
return self.async_show_form(
step_id="confirm_restart",
data_schema=vol.Schema({}),
description_placeholders={"name": integration.display_name},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None = None,
*args: Any,
**kwargs: Any,
) -> RepairsFlow | None:
"""Create flow."""
if issue_id.startswith("restart_required"):
return RestartRequiredFixFlow(issue_id)
return None

View File

@@ -0,0 +1,21 @@
"""Initialize repositories."""
from __future__ import annotations
from ..enums import HacsCategory
from .appdaemon import HacsAppdaemonRepository
from .base import HacsRepository
from .integration import HacsIntegrationRepository
from .plugin import HacsPluginRepository
from .python_script import HacsPythonScriptRepository
from .template import HacsTemplateRepository
from .theme import HacsThemeRepository
REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
HacsCategory.THEME: HacsThemeRepository,
HacsCategory.INTEGRATION: HacsIntegrationRepository,
HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository,
HacsCategory.APPDAEMON: HacsAppdaemonRepository,
HacsCategory.PLUGIN: HacsPluginRepository,
HacsCategory.TEMPLATE: HacsTemplateRepository,
}

View File

@@ -0,0 +1,93 @@
"""Class for appdaemon apps in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aiogithubapi import AIOGitHubAPIException
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsAppdaemonRepository(HacsRepository):
"""Appdaemon apps in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.APPDAEMON
self.content.path.local = self.localpath
self.content.path.remote = "apps"
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/appdaemon/apps/{self.data.name}"
async def validate_repository(self):
"""Validate."""
await self.common_validate()
# Custom step 1: Validate content.
try:
addir = await self.repository_object.get_contents("apps", self.ref)
except AIOGitHubAPIException:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
) from None
if not isinstance(addir, list):
self.validate.errors.append(f"{self.string} Repository structure not compliant")
self.content.path.remote = addir[0].path
self.content.objects = await self.repository_object.get_contents(
self.content.path.remote, self.ref
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get appdaemon objects.
if self.repository_manifest:
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "apps":
addir = await self.repository_object.get_contents(self.content.path.remote, self.ref)
self.content.path.remote = addir[0].path
self.content.objects = await self.repository_object.get_contents(
self.content.path.remote, self.ref
)
# Set local path
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
"""Class for integrations in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.loader import async_get_custom_components
from ..const import DOMAIN
from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile
from ..exceptions import AddonRepositoryException, HacsException
from ..utils.decode import decode_content
from ..utils.decorator import concurrent
from ..utils.filters import get_first_directory_in_directory
from ..utils.json import json_loads
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsIntegrationRepository(HacsRepository):
"""Integrations in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.INTEGRATION
self.content.path.remote = "custom_components"
self.content.path.local = self.localpath
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/custom_components/{self.data.domain}"
async def async_post_installation(self):
"""Run post installation steps."""
self.pending_restart = True
if self.data.config_flow:
if self.data.full_name != HacsGitHubRepo.INTEGRATION:
await self.reload_custom_components()
if self.data.first_install:
self.pending_restart = False
if self.pending_restart:
self.logger.debug("%s Creating restart_required issue", self.string)
async_create_issue(
hass=self.hacs.hass,
domain=DOMAIN,
issue_id=f"restart_required_{self.data.id}_{self.ref}",
is_fixable=True,
issue_domain=self.data.domain or DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="restart_required",
translation_placeholders={
"name": self.display_name,
},
)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
if self.data.config_flow:
await self.reload_custom_components()
else:
self.pending_restart = True
async def validate_repository(self):
"""Validate."""
await self.common_validate()
# Custom step 1: Validate content.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "custom_components":
name = get_first_directory_in_directory(self.tree, "custom_components")
if name is None:
if (
"repository.json" in self.treefiles
or "repository.yaml" in self.treefiles
or "repository.yml" in self.treefiles
):
raise AddonRepositoryException()
raise HacsException(
f"{self.string} Repository structure for {
self.ref.replace('tags/', '')} is not compliant"
)
self.content.path.remote = f"custom_components/{name}"
# Get the content of manifest.json
if manifest := await self.async_get_integration_manifest():
try:
self.integration_manifest = manifest
self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"]
self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception:
self.validate.errors.append(
f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
)
self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
)
# Set local path
self.content.path.local = self.localpath
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
if self.content.path.remote == "custom_components":
name = get_first_directory_in_directory(self.tree, "custom_components")
self.content.path.remote = f"custom_components/{name}"
# Get the content of manifest.json
if manifest := await self.async_get_integration_manifest():
try:
self.integration_manifest = manifest
self.data.authors = manifest.get("codeowners", [])
self.data.domain = manifest["domain"]
self.data.manifest_name = manifest.get("name")
self.data.config_flow = manifest.get("config_flow", False)
except KeyError as exception:
self.validate.errors.append(
f"Missing expected key '{exception}' in {
RepositoryFile.MAINIFEST_JSON}"
)
self.hacs.log.error(
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
)
# Set local path
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
async def reload_custom_components(self):
"""Reload custom_components (and config flows)in HA."""
self.logger.info("Reloading custom_component cache")
del self.hacs.hass.data["custom_components"]
await async_get_custom_components(self.hacs.hass)
self.logger.info("Custom_component cache reloaded")
async def async_get_integration_manifest(self, ref: str = None) -> dict[str, Any] | None:
"""Get the content of the manifest.json file."""
manifest_path = (
"manifest.json"
if self.repository_manifest.content_in_root
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
)
if not manifest_path in (x.full_path for x in self.tree):
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
response = await self.hacs.async_github_api_method(
method=self.hacs.githubapi.repos.contents.get,
repository=self.data.full_name,
path=manifest_path,
**{"params": {"ref": ref or self.version_to_download()}},
)
if response:
return json_loads(decode_content(response.data.content))
async def get_integration_manifest(self, *, version: str, **kwargs) -> dict[str, Any] | None:
"""Get the content of the manifest.json file."""
manifest_path = (
"manifest.json"
if self.repository_manifest.content_in_root
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
)
if manifest_path not in (x.full_path for x in self.tree):
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
self.logger.debug("%s Getting manifest.json for version=%s", self.string, version)
try:
result = await self.hacs.async_download_file(
f"https://raw.githubusercontent.com/{
self.data.full_name}/{version}/{manifest_path}",
nolog=True,
)
if result is None:
return None
return json_loads(result)
except Exception: # pylint: disable=broad-except
return None

View File

@@ -0,0 +1,246 @@
"""Class for plugins in HACS."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from ..utils.json import json_loads
from .base import HacsRepository
HACSTAG_REPLACER = re.compile(r"\D+")
if TYPE_CHECKING:
from homeassistant.components.lovelace.resources import ResourceStorageCollection
from ..base import HacsBase
class HacsPluginRepository(HacsRepository):
"""Plugins in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.file_name = None
self.data.category = HacsCategory.PLUGIN
self.content.path.local = self.localpath
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/www/community/{self.data.full_name.split('/')[-1]}"
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
self.update_filenames()
if self.content.path.remote is None:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.content.path.remote == "release":
self.content.single = True
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_installation(self):
"""Run post installation steps."""
await self.hacs.async_setup_frontend_endpoint_plugin()
await self.update_dashboard_resources()
async def async_post_uninstall(self):
"""Run post uninstall steps."""
await self.remove_dashboard_resources()
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get plugin objects.
self.update_filenames()
if self.content.path.remote is None:
self.validate.errors.append(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.content.path.remote == "release":
self.content.single = True
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
async def get_package_content(self):
"""Get package content."""
try:
package = await self.repository_object.get_contents("package.json", self.ref)
package = json_loads(package.content)
if package:
self.data.authors = package["author"]
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
pass
def update_filenames(self) -> None:
"""Get the filename to target."""
content_in_root = self.repository_manifest.content_in_root
if specific_filename := self.repository_manifest.filename:
valid_filenames = (specific_filename,)
else:
valid_filenames = (
f"{self.data.name.replace('lovelace-', '')}.js",
f"{self.data.name}.js",
f"{self.data.name}.umd.js",
f"{self.data.name}-bundle.js",
)
if not content_in_root:
if self.releases.objects:
release = self.releases.objects[0]
if release.assets:
if assetnames := [
filename
for filename in valid_filenames
for asset in release.assets
if filename == asset.name
]:
self.data.file_name = assetnames[0]
self.content.path.remote = "release"
return
all_paths = {x.full_path for x in self.tree}
for filename in valid_filenames:
if filename in all_paths:
self.data.file_name = filename
self.content.path.remote = ""
return
if not content_in_root and f"dist/{filename}" in all_paths:
self.data.file_name = filename.split("/")[-1]
self.content.path.remote = "dist"
return
def generate_dashboard_resource_hacstag(self) -> str:
"""Get the HACS tag used by dashboard resources."""
version = (
self.display_installed_version
or self.data.selected_tag
or self.display_available_version
)
return f"{self.data.id}{HACSTAG_REPLACER.sub('', version)}"
def generate_dashboard_resource_namespace(self) -> str:
"""Get the dashboard resource namespace."""
return f"/hacsfiles/{self.data.full_name.split("/")[1]}"
def generate_dashboard_resource_url(self) -> str:
"""Get the dashboard resource namespace."""
filename = self.data.file_name
if "/" in filename:
self.logger.warning("%s have defined an invalid file name %s", self.string, filename)
filename = filename.split("/")[-1]
return (
f"{self.generate_dashboard_resource_namespace()}/{filename}"
f"?hacstag={self.generate_dashboard_resource_hacstag()}"
)
def _get_resource_handler(self) -> ResourceStorageCollection | None:
"""Get the resource handler."""
resources: ResourceStorageCollection | None
if not (hass_data := self.hacs.hass.data):
self.logger.error("%s Can not access the hass data", self.string)
return
if (lovelace_data := hass_data.get("lovelace")) is None:
self.logger.warning("%s Can not access the lovelace integration data", self.string)
return
if self.hacs.core.ha_version > "2025.1.99":
# Changed to 2025.2.0
# Changed in https://github.com/home-assistant/core/pull/136313
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
self.logger.warning("%s Can not access the dashboard resources", self.string)
return
if not hasattr(resources, "store") or resources.store is None:
self.logger.info("%s YAML mode detected, can not update resources", self.string)
return
if resources.store.key != "lovelace_resources" or resources.store.version != 1:
self.logger.warning("%s Can not use the dashboard resources", self.string)
return
return resources
async def update_dashboard_resources(self) -> None:
"""Update dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
url = self.generate_dashboard_resource_url()
for entry in resources.async_items():
if (entry_url := entry["url"]).startswith(namespace):
if entry_url != url:
self.logger.info(
"%s Updating existing dashboard resource from %s to %s",
self.string,
entry_url,
url,
)
await resources.async_update_item(entry["id"], {"url": url})
return
# Nothing was updated, add the resource
self.logger.info("%s Adding dashboard resource %s", self.string, url)
await resources.async_create_item({"res_type": "module", "url": url})
async def remove_dashboard_resources(self) -> None:
"""Remove dashboard resources."""
if not (resources := self._get_resource_handler()):
return
if not resources.loaded:
await resources.async_load()
namespace = self.generate_dashboard_resource_namespace()
for entry in resources.async_items():
if entry["url"].startswith(namespace):
self.logger.info("%s Removing dashboard resource %s", self.string, entry["url"])
await resources.async_delete_item(entry["id"])
return

View File

@@ -0,0 +1,111 @@
"""Class for python_scripts in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsPythonScriptRepository(HacsRepository):
"""python_scripts in HACS."""
category = "python_script"
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.PYTHON_SCRIPT
self.content.path.remote = "python_scripts"
self.content.path.local = self.localpath
self.content.single = True
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/python_scripts"
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
compliant = False
for treefile in self.treefiles:
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set name
self.update_filenames()
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get python_script objects.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
compliant = False
for treefile in self.treefiles:
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Update name
self.update_filenames()
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
def update_filenames(self) -> None:
"""Get the filename to target."""
for treefile in self.tree:
if treefile.full_path.startswith(
self.content.path.remote
) and treefile.full_path.endswith(".py"):
self.data.file_name = treefile.filename

View File

@@ -0,0 +1,106 @@
"""Class for themes in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsTemplateRepository(HacsRepository):
"""Custom templates in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.TEMPLATE
self.content.path.remote = ""
self.content.path.local = self.localpath
self.content.single = True
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/custom_templates"
async def async_post_installation(self):
"""Run post installation steps."""
await self._reload_custom_templates()
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
self.data.file_name = self.repository_manifest.filename
if (
not self.data.file_name
or "/" in self.data.file_name
or not self.data.file_name.endswith(".jinja")
or self.data.file_name not in self.treefiles
):
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set filenames
self.data.file_name = self.repository_manifest.filename
self.content.path.local = self.localpath
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
await self._reload_custom_templates()
async def _reload_custom_templates(self) -> None:
"""Reload custom templates."""
self.logger.debug("%s Reloading custom templates", self.string)
try:
await self.hacs.hass.services.async_call("homeassistant", "reload_custom_templates", {})
except HomeAssistantError as exception:
self.logger.exception("%s %s", self.string, exception)
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Update filenames
self.data.file_name = self.repository_manifest.filename
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)

View File

@@ -0,0 +1,119 @@
"""Class for themes in HACS."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.exceptions import HomeAssistantError
from ..enums import HacsCategory, HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.decorator import concurrent
from .base import HacsRepository
if TYPE_CHECKING:
from ..base import HacsBase
class HacsThemeRepository(HacsRepository):
"""Themes in HACS."""
def __init__(self, hacs: HacsBase, full_name: str):
"""Initialize."""
super().__init__(hacs=hacs)
self.data.full_name = full_name
self.data.full_name_lower = full_name.lower()
self.data.category = HacsCategory.THEME
self.content.path.remote = "themes"
self.content.path.local = self.localpath
self.content.single = False
@property
def localpath(self):
"""Return localpath."""
return f"{self.hacs.core.config_path}/themes/{self.data.file_name.replace('.yaml', '')}"
async def async_post_installation(self):
"""Run post installation steps."""
await self._reload_frontend_themes()
async def validate_repository(self):
"""Validate."""
# Run common validation steps.
await self.common_validate()
# Custom step 1: Validate content.
compliant = False
for treefile in self.treefiles:
if treefile.startswith("themes/") and treefile.endswith(".yaml"):
compliant = True
break
if not compliant:
raise HacsException(
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
)
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
# Handle potential errors
if self.validate.errors:
for error in self.validate.errors:
if not self.hacs.status.startup:
self.logger.error("%s %s", self.string, error)
return self.validate.success
async def async_post_registration(self):
"""Registration."""
# Set name
self.update_filenames()
self.content.path.local = self.localpath
if self.hacs.system.action:
await self.hacs.validation.async_run_repository_checks(self)
async def _reload_frontend_themes(self) -> None:
"""Reload frontend themes."""
self.logger.debug("%s Reloading frontend themes", self.string)
try:
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
except HomeAssistantError as exception:
self.logger.exception("%s %s", self.string, exception)
async def async_post_uninstall(self) -> None:
"""Run post uninstall steps."""
await self._reload_frontend_themes()
@concurrent(concurrenttasks=10, backoff_time=5)
async def update_repository(self, ignore_issues=False, force=False):
"""Update."""
if not await self.common_update(ignore_issues, force) and not force:
return
# Get theme objects.
if self.repository_manifest.content_in_root:
self.content.path.remote = ""
# Update name
self.update_filenames()
self.content.path.local = self.localpath
# Signal frontend to refresh
if self.data.installed:
self.hacs.async_dispatch(
HacsDispatchEvent.REPOSITORY,
{
"id": 1337,
"action": "update",
"repository": self.data.full_name,
"repository_id": self.data.id,
},
)
def update_filenames(self) -> None:
"""Get the filename to target."""
for treefile in self.tree:
if treefile.full_path.startswith(
self.content.path.remote
) and treefile.full_path.endswith(".yaml"):
self.data.file_name = treefile.filename

View File

@@ -0,0 +1,73 @@
"""Switch entities for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base import HacsBase
from .const import DOMAIN
from .entity import HacsRepositoryEntity
from .repositories.base import HacsRepository
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Setup switch platform."""
hacs: HacsBase = hass.data[DOMAIN]
async_add_entities(
HacsRepositoryPreReleaseSwitchEntity(hacs=hacs, repository=repository)
for repository in hacs.repositories.list_downloaded
)
class HacsRepositoryPreReleaseSwitchEntity(HacsRepositoryEntity, SwitchEntity):
"""Pre-release switch entities for repositories downloaded with HACS."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_translation_key = "pre-release"
def __init__(self, hacs: HacsBase, repository: HacsRepository) -> None:
"""Initialize the repository pre-release switch."""
super().__init__(hacs, repository)
self._attr_entity_registry_enabled_default = self.repository.data.show_beta
@property
def is_on(self) -> bool:
"""Return if the pre-release option is enabled for the repository."""
return self.repository.data.show_beta
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self._handle_change(value=True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._handle_change(value=False)
async def _handle_change(self, value: bool) -> None:
"""Handle attribute value changes."""
self.repository.data.show_beta = value
# As this value is directly affecting what data points is in use by other entities
# we need to update all entities to reflect the change
# Do force an update of the entities we need to clear the last fetched data
# since that is used to limit state updates
# Once we have signaled the update we can restore the last fetched data
_last_fetch = self.repository.data.last_fetched
self.repository.data.last_fetched = None
self.coordinator.async_update_listeners()
self.repository.data.last_fetched = _last_fetch # Restore last fetched
# Write the HACS data and update the entity state
await self.hacs.data.async_write()
self.async_write_ha_state()

View File

@@ -0,0 +1,52 @@
"""Provide info to system health."""
from typing import Any
from aiogithubapi.common.const import BASE_API_URL
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .base import HacsBase
from .const import DOMAIN
GITHUB_STATUS = "https://www.githubstatus.com/"
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
@callback
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
"""Register system health callbacks."""
register.domain = "Home Assistant Community Store"
register.async_register_info(system_health_info, "/hacs")
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
if DOMAIN not in hass.data:
return {"Disabled": "HACS is not loaded, but HA still requests this information..."}
hacs: HacsBase = hass.data[DOMAIN]
response = await hacs.githubapi.rate_limit()
data = {
"GitHub API": system_health.async_check_can_reach_url(hass, BASE_API_URL, GITHUB_STATUS),
"GitHub Content": system_health.async_check_can_reach_url(
hass, "https://raw.githubusercontent.com/hacs/integration/main/hacs.json"
),
"GitHub Web": system_health.async_check_can_reach_url(
hass, "https://github.com/", GITHUB_STATUS
),
"HACS Data": system_health.async_check_can_reach_url(
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
),
"GitHub API Calls Remaining": response.data.resources.core.remaining,
"Installed Version": hacs.version,
"Stage": hacs.stage,
"Available Repositories": len(hacs.repositories.list_all),
"Downloaded Repositories": len(hacs.repositories.list_downloaded),
}
if hacs.system.disabled:
data["Disabled"] = hacs.system.disabled_reason
return data

View File

@@ -0,0 +1,84 @@
{
"config": {
"abort": {
"single_instance_allowed": "Only a single configuration of HACS is allowed.",
"min_ha_version": "You need at least version {version} of Home Assistant to setup HACS.",
"github": "Could not authenticate with GitHub, try again later.",
"not_setup": "HACS is not setup.",
"reauth_successful": "Reauthentication was successful."
},
"error": {
"auth": "Personal Access Token is not correct",
"acc": "You need to acknowledge all the statements before continuing"
},
"step": {
"user": {
"data": {
"acc_logs": "I know how to access Home Assistant logs",
"acc_addons": "I know that there are no add-ons in HACS",
"acc_untested": "I know that everything inside HACS including HACS itself is custom and untested by Home Assistant",
"acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components"
},
"description": "Before you can setup HACS you need to acknowledge the following"
},
"device": {
"title": "Waiting for device activation"
},
"reauth_confirm": {
"title": "Reauthentication needed",
"description": "You need to reauthenticate with GitHub."
}
},
"progress": {
"wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```"
}
},
"options": {
"abort": {
"not_setup": "HACS is not setup.",
"pending_tasks": "There are pending tasks. Try again later.",
"release_limit_value": "The release limit needs to be between 1 and 100."
},
"step": {
"user": {
"data": {
"not_in_use": "Not in use with YAML",
"country": "Filter with country code",
"release_limit": "Number of releases to show",
"debug": "Enable debug",
"appdaemon": "Enable AppDaemon apps discovery & tracking",
"sidepanel_icon": "Side panel icon",
"sidepanel_title": "Side panel title"
}
}
}
},
"issues": {
"restart_required": {
"title": "Restart required",
"fix_flow": {
"step": {
"confirm_restart": {
"title": "Restart required",
"description": "Restart of Home Assistant is required to finish download/update of {name}, click submit to restart now."
}
}
}
},
"removed": {
"title": "Repository removed from HACS",
"description": "Because {reason}, `{name}` has been removed from HACS. Please visit the [HACS Panel](/hacs/repository/{repositry_id}) to remove it."
}
},
"entity": {
"switch": {
"pre-release": {
"name": "Pre-release",
"state": {
"off": "No pre-releases",
"on": "Pre-releases preferred"
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
"""Custom HACS types."""
from typing import TypedDict
class DownloadableContent(TypedDict):
"""Downloadable content."""
url: str
name: str

View File

@@ -0,0 +1,158 @@
"""Update entities for HACS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .base import HacsBase
from .const import DOMAIN
from .entity import HacsRepositoryEntity
from .enums import HacsCategory, HacsDispatchEvent
from .exceptions import HacsException
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup update platform."""
hacs: HacsBase = hass.data[DOMAIN]
async_add_entities(
HacsRepositoryUpdateEntity(hacs=hacs, repository=repository)
for repository in hacs.repositories.list_downloaded
)
class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
"""Update entities for repositories downloaded with HACS."""
_attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.PROGRESS
| UpdateEntityFeature.RELEASE_NOTES
)
@property
def name(self) -> str | None:
"""Return the name."""
return f"{self.repository.display_name} update"
@property
def latest_version(self) -> str:
"""Return latest version of the entity."""
return self.repository.display_available_version
@property
def release_url(self) -> str:
"""Return the URL of the release page."""
if self.repository.display_version_or_commit == "commit":
return f"https://github.com/{self.repository.data.full_name}"
return f"https://github.com/{self.repository.data.full_name}/releases/{self.latest_version}"
@property
def installed_version(self) -> str:
"""Return downloaded version of the entity."""
return self.repository.display_installed_version
@property
def release_summary(self) -> str | None:
"""Return the release summary."""
if self.repository.pending_restart:
return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>"
return None
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend."""
if (
self.repository.data.category != HacsCategory.INTEGRATION
or self.repository.data.domain is None
):
return None
return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png"
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
"""Install an update."""
to_download = version or self.latest_version
if to_download == self.installed_version:
raise HomeAssistantError(f"Version {self.installed_version} of {
self.repository.data.full_name} is already downloaded")
try:
await self.repository.async_download_repository(ref=version or self.latest_version)
except HacsException as exception:
raise HomeAssistantError(exception) from exception
async def async_release_notes(self) -> str | None:
"""Return the release notes."""
if self.repository.pending_restart:
return None
if self.latest_version not in self.repository.data.published_tags:
releases = await self.repository.get_releases(
prerelease=self.repository.data.show_beta,
returnlimit=self.hacs.configuration.release_limit,
)
if releases:
self.repository.data.releases = True
self.repository.releases.objects = releases
self.repository.data.published_tags = [x.tag_name for x in releases]
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
release_notes = ""
# Compile release notes from installed version up to the latest
if self.installed_version in self.repository.data.published_tags:
for release in self.repository.releases.objects:
if release.tag_name == self.installed_version:
break
release_notes += f"# {release.tag_name}"
if release.tag_name != release.name:
release_notes += f" - {release.name}"
release_notes += f"\n\n{release.body}"
release_notes += "\n\n---\n\n"
elif any(self.repository.releases.objects):
release_notes += self.repository.releases.objects[0].body
if self.repository.pending_update:
if self.repository.data.category == HacsCategory.INTEGRATION:
release_notes += (
"\n\n<ha-alert alert-type='warning'>You need to restart"
" Home Assistant manually after updating.</ha-alert>\n\n"
)
if self.repository.data.category == HacsCategory.PLUGIN:
release_notes += (
"\n\n<ha-alert alert-type='warning'>You need to manually"
" clear the frontend cache after updating.</ha-alert>\n\n"
)
return release_notes.replace("\n#", "\n\n#")
async def async_added_to_hass(self) -> None:
"""Register for status events."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
self._update_download_progress,
)
)
@callback
def _update_download_progress(self, data: dict) -> None:
"""Update the download progress."""
if data["repository"] != self.repository.data.full_name:
return
self._update_in_progress(progress=data["progress"])
@callback
def _update_in_progress(self, progress: int | bool) -> None:
"""Update the download progress."""
self._attr_in_progress = progress
self.async_write_ha_state()

View File

@@ -0,0 +1 @@
"""Initialize HACS utils."""

View File

@@ -0,0 +1,110 @@
"""Backup."""
from __future__ import annotations
import os
import shutil
import tempfile
from time import sleep
from typing import TYPE_CHECKING
from .path import is_safe
if TYPE_CHECKING:
from ..base import HacsBase
from ..repositories.base import HacsRepository
DEFAULT_BACKUP_PATH = f"{tempfile.gettempdir()}/hacs_backup/"
class Backup:
"""Backup."""
def __init__(
self,
hacs: HacsBase,
local_path: str | None = None,
backup_path: str = DEFAULT_BACKUP_PATH,
repository: HacsRepository | None = None,
) -> None:
"""Initialize."""
self.hacs = hacs
self.repository = repository
self.local_path = local_path or repository.content.path.local
self.backup_path = backup_path
if repository:
self.backup_path = (
tempfile.gettempdir()
+ f"/hacs_persistent_{repository.data.category}/"
+ repository.data.name
)
self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}"
def _init_backup_dir(self) -> bool:
"""Init backup dir."""
if not os.path.exists(self.local_path):
return False
if not is_safe(self.hacs, self.local_path):
return False
if os.path.exists(self.backup_path):
shutil.rmtree(self.backup_path)
# Wait for the folder to be removed
while os.path.exists(self.backup_path):
sleep(0.1)
os.makedirs(self.backup_path, exist_ok=True)
return True
def create(self) -> None:
"""Create a backup in /tmp"""
if not self._init_backup_dir():
return
try:
if os.path.isfile(self.local_path):
shutil.copyfile(self.local_path, self.backup_path_full)
os.remove(self.local_path)
else:
shutil.copytree(self.local_path, self.backup_path_full)
shutil.rmtree(self.local_path)
while os.path.exists(self.local_path):
sleep(0.1)
self.hacs.log.debug(
"Backup for %s, created in %s",
self.local_path,
self.backup_path_full,
)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
self.hacs.log.warning("Could not create backup: %s", exception)
def restore(self) -> None:
"""Restore from backup."""
if not os.path.exists(self.backup_path_full):
return
if os.path.isfile(self.backup_path_full):
if os.path.exists(self.local_path):
os.remove(self.local_path)
shutil.copyfile(self.backup_path_full, self.local_path)
else:
if os.path.exists(self.local_path):
shutil.rmtree(self.local_path)
while os.path.exists(self.local_path):
sleep(0.1)
shutil.copytree(self.backup_path_full, self.local_path)
self.hacs.log.debug("Restored %s, from backup %s", self.local_path, self.backup_path_full)
def cleanup(self) -> None:
"""Cleanup backup files."""
if not os.path.exists(self.backup_path):
return
shutil.rmtree(self.backup_path)
# Wait for the folder to be removed
while os.path.exists(self.backup_path):
sleep(0.1)
self.hacs.log.debug("Backup dir %s cleared", self.backup_path)

View File

@@ -0,0 +1,9 @@
"""HACS Configuration Schemas."""
# Configuration:
SIDEPANEL_TITLE = "sidepanel_title"
SIDEPANEL_ICON = "sidepanel_icon"
APPDAEMON = "appdaemon"
# Options:
COUNTRY = "country"

View File

@@ -0,0 +1,323 @@
"""Data handler for HACS."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from ..base import HacsBase
from ..const import HACS_REPOSITORY_ID
from ..enums import HacsDisabledReason, HacsDispatchEvent
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
from .logger import LOGGER
from .path import is_safe
from .store import async_load_from_store, async_save_to_store
EXPORTED_BASE_DATA = (
("new", False),
("full_name", ""),
)
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
("authors", []),
("category", ""),
("description", ""),
("domain", None),
("downloads", 0),
("etag_repository", None),
("hide", False),
("last_updated", 0),
("new", False),
("stargazers_count", 0),
("topics", []),
)
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
("archived", False),
("config_flow", False),
("default_branch", None),
("first_install", False),
("installed_commit", None),
("installed", False),
("last_commit", None),
("last_version", None),
("manifest_name", None),
("open_issues", 0),
("prerelease", None),
("published_tags", []),
("releases", False),
("selected_tag", None),
("show_beta", False),
)
class HacsData:
"""HacsData class."""
def __init__(self, hacs: HacsBase):
"""Initialize."""
self.logger = LOGGER
self.hacs = hacs
self.content = {}
async def async_force_write(self, _=None):
"""Force write."""
await self.async_write(force=True)
async def async_write(self, force: bool = False) -> None:
"""Write content to the store files."""
if not force and self.hacs.system.disabled:
return
self.logger.debug("<HacsData async_write> Saving data")
# Hacs
await async_save_to_store(
self.hacs.hass,
"hacs",
{
"archived_repositories": self.hacs.common.archived_repositories,
"renamed_repositories": self.hacs.common.renamed_repositories,
"ignored_repositories": self.hacs.common.ignored_repositories,
},
)
await self._async_store_experimental_content_and_repos()
await self._async_store_content_and_repos()
async def _async_store_content_and_repos(self, _=None): # bb: ignore
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_repository_data(repository)
await async_save_to_store(self.hacs.hass, "repositories", self.content)
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
self.hacs.async_dispatch(event, {})
async def _async_store_experimental_content_and_repos(self, _=None):
"""Store the main repos file and each repo that is out of date."""
# Repositories
self.content = {}
for repository in self.hacs.repositories.list_all:
if repository.data.category in self.hacs.common.categories:
self.async_store_experimental_repository_data(repository)
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
@callback
def async_store_repository_data(self, repository: HacsRepository) -> dict:
"""Store the repository data."""
data = {"repository_manifest": repository.repository_manifest.manifest}
for key, default in (
EXPORTED_DOWNLOADED_REPOSITORY_DATA
if repository.data.installed
else EXPORTED_REPOSITORY_DATA
):
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
self.content[str(repository.data.id)] = data
@callback
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
"""Store the experimental repository data for non downloaded repositories."""
data = {}
self.content.setdefault(repository.data.category, [])
if repository.data.installed:
data["repository_manifest"] = repository.repository_manifest.manifest
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
if repository.data.installed_version:
data["version_installed"] = repository.data.installed_version
if repository.data.last_fetched:
data["last_fetched"] = repository.data.last_fetched.timestamp()
else:
for key, default in EXPORTED_BASE_DATA:
if (value := getattr(repository.data, key, default)) != default:
data[key] = value
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
async def restore(self):
"""Restore saved data."""
self.hacs.status.new = False
repositories = {}
hacs = {}
try:
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
except HomeAssistantError:
pass
try:
repositories = await async_load_from_store(self.hacs.hass, "repositories")
if not repositories and (data := await async_load_from_store(self.hacs.hass, "data")):
for category, entries in data.get("repositories", {}).items():
for repository in entries:
repositories[repository["id"]] = {"category": category, **repository}
except HomeAssistantError as exception:
self.hacs.log.error(
"Could not read %s, restore the file from a backup - %s",
self.hacs.hass.config.path(".storage/hacs.data"),
exception,
)
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
return False
if not hacs and not repositories:
# Assume new install
self.hacs.status.new = True
return True
self.logger.info("<HacsData restore> Restore started")
# Hacs
self.hacs.common.archived_repositories = set()
self.hacs.common.ignored_repositories = set()
self.hacs.common.renamed_repositories = {}
# Clear out doubble renamed values
renamed = hacs.get("renamed_repositories", {})
for entry in renamed:
value = renamed.get(entry)
if value not in renamed:
self.hacs.common.renamed_repositories[entry] = value
# Clear out doubble archived values
for entry in hacs.get("archived_repositories", set()):
if entry not in self.hacs.common.archived_repositories:
self.hacs.common.archived_repositories.add(entry)
# Clear out doubble ignored values
for entry in hacs.get("ignored_repositories", set()):
if entry not in self.hacs.common.ignored_repositories:
self.hacs.common.ignored_repositories.add(entry)
try:
await self.register_unknown_repositories(repositories)
for entry, repo_data in repositories.items():
if entry == "0":
# Ignore repositories with ID 0
self.logger.debug(
"<HacsData restore> Found repository with ID %s - %s", entry, repo_data
)
continue
self.async_restore_repository(entry, repo_data)
self.logger.info("<HacsData restore> Restore done")
except (
# lgtm [py/catch-base-exception] pylint: disable=broad-except
BaseException
) as exception:
self.logger.critical(
"<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception
)
return False
return True
async def register_unknown_repositories(
self, repositories: dict[str, dict[str, Any]], category: str | None = None
):
"""Registry any unknown repositories."""
for repo_idx, (entry, repo_data) in enumerate(repositories.items()):
# async_register_repository is awaited in a loop
# since its unlikely to ever suspend at startup
if (
entry == "0"
or repo_data.get("category", category) is None
or self.hacs.repositories.is_registered(repository_id=entry)
):
continue
await self.hacs.async_register_repository(
repository_full_name=repo_data["full_name"],
category=repo_data.get("category", category),
check=False,
repository_id=entry,
)
if repo_idx % 100 == 0:
# yield to avoid blocking the event loop
await asyncio.sleep(0)
@callback
def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
"""Restore repository."""
repository: HacsRepository | None = None
if full_name := repository_data.get("full_name"):
repository = self.hacs.repositories.get_by_full_name(full_name)
if not repository:
repository = self.hacs.repositories.get_by_id(entry)
if not repository:
return
try:
self.hacs.repositories.set_repository_id(repository, entry)
except ValueError as exception:
self.logger.warning("<HacsData async_restore_repository> duplicate IDs %s", exception)
return
# Restore repository attributes
repository.data.authors = repository_data.get("authors", [])
repository.data.description = repository_data.get("description", "")
repository.data.downloads = repository_data.get("downloads", 0)
repository.data.last_updated = repository_data.get("last_updated", 0)
if self.hacs.system.generator:
repository.data.etag_releases = repository_data.get("etag_releases")
repository.data.open_issues = repository_data.get("open_issues", 0)
repository.data.etag_repository = repository_data.get("etag_repository")
repository.data.topics = [
topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER
]
repository.data.domain = repository_data.get("domain")
repository.data.stargazers_count = repository_data.get(
"stargazers_count"
) or repository_data.get("stars", 0)
repository.releases.last_release = repository_data.get("last_release_tag")
repository.data.releases = repository_data.get("releases", False)
repository.data.installed = repository_data.get("installed", False)
repository.data.new = repository_data.get("new", False)
repository.data.selected_tag = repository_data.get("selected_tag")
repository.data.show_beta = repository_data.get("show_beta", False)
repository.data.last_version = repository_data.get("last_version")
repository.data.prerelease = repository_data.get("prerelease")
repository.data.last_commit = repository_data.get("last_commit")
repository.data.installed_version = repository_data.get("version_installed")
repository.data.installed_commit = repository_data.get("installed_commit")
repository.data.manifest_name = repository_data.get("manifest_name")
if last_fetched := repository_data.get("last_fetched"):
repository.data.last_fetched = datetime.fromtimestamp(last_fetched, UTC)
repository.repository_manifest = HacsManifest.from_dict(
repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
)
if repository.data.prerelease == repository.data.last_version:
repository.data.prerelease = None
if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
# Set local path
repository.content.path.local = repository.localpath
if repository.data.installed:
repository.data.first_install = False
if entry == HACS_REPOSITORY_ID:
repository.data.installed_version = self.hacs.version
repository.data.installed = True

View File

@@ -0,0 +1,8 @@
"""Util to decode content from the github API."""
from base64 import b64decode
def decode_content(content: str) -> str:
"""Decode content."""
return b64decode(bytearray(content, "utf-8")).decode()

View File

@@ -0,0 +1,43 @@
"""HACS Decorators."""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any
from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS
if TYPE_CHECKING:
from ..base import HacsBase
def concurrent(
concurrenttasks: int = DEFAULT_CONCURRENT_TASKS,
backoff_time: int = DEFAULT_CONCURRENT_BACKOFF_TIME,
) -> Coroutine[Any, Any, None]:
"""Return a modified function."""
max_concurrent = asyncio.Semaphore(concurrenttasks)
def inner_function(function) -> Coroutine[Any, Any, None]:
@wraps(function)
async def wrapper(*args, **kwargs) -> None:
hacs: HacsBase = getattr(args[0], "hacs", None)
async with max_concurrent:
result = await function(*args, **kwargs)
if (
hacs is None
or hacs.queue is None
or hacs.queue.has_pending_tasks
or "update" not in function.__name__
):
await asyncio.sleep(backoff_time)
return result
return wrapper
return inner_function

View File

@@ -0,0 +1,42 @@
"""File system functions."""
from __future__ import annotations
import os
import shutil
from typing import TypeAlias
from homeassistant.core import HomeAssistant
# From typeshed
StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes]
FileDescriptorOrPath: TypeAlias = int | StrOrBytesPath
async def async_exists(hass: HomeAssistant, path: FileDescriptorOrPath) -> bool:
"""Test whether a path exists."""
return await hass.async_add_executor_job(os.path.exists, path)
async def async_remove(
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
) -> None:
"""Remove a path."""
try:
return await hass.async_add_executor_job(os.remove, path)
except FileNotFoundError:
if missing_ok:
return
raise
async def async_remove_directory(
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
) -> None:
"""Remove a directory."""
try:
return await hass.async_add_executor_job(shutil.rmtree, path)
except FileNotFoundError:
if missing_ok:
return
raise

View File

@@ -0,0 +1,47 @@
"""Filter functions."""
from __future__ import annotations
from typing import Any
def filter_content_return_one_of_type(
content: list[str | Any],
namestartswith: str,
filterfiltype: str,
attr: str = "name",
) -> list[str]:
"""Only match 1 of the filter."""
contents = []
filetypefound = False
for filename in content:
if isinstance(filename, str):
if filename.startswith(namestartswith):
if filename.endswith(f".{filterfiltype}"):
if not filetypefound:
contents.append(filename)
filetypefound = True
continue
else:
contents.append(filename)
else:
if getattr(filename, attr).startswith(namestartswith):
if getattr(filename, attr).endswith(f".{filterfiltype}"):
if not filetypefound:
contents.append(filename)
filetypefound = True
continue
else:
contents.append(filename)
return contents
def get_first_directory_in_directory(content: list[str | Any], dirname: str) -> str | None:
"""Return the first directory in dirname or None."""
directory = None
for path in content:
if path.full_path.startswith(dirname) and path.full_path != dirname:
if path.is_directory:
directory = path.filename
break
return directory

View File

@@ -0,0 +1,19 @@
"""GitHub GraphQL Queries."""
GET_REPOSITORY_RELEASES = """
query ($owner: String!, $name: String!, $first: Int!) {
rateLimit {
cost
}
repository(owner: $owner, name: $name) {
releases(first: $first, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes {
tagName
name
isPrerelease
publishedAt
}
}
}
}
"""

View File

@@ -0,0 +1,5 @@
"""JSON utils."""
from homeassistant.util.json import json_loads
__all__ = ["json_loads"]

View File

@@ -0,0 +1,7 @@
"""Custom logger for HACS."""
import logging
from ..const import PACKAGE_NAME
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)

View File

@@ -0,0 +1,41 @@
"""Path utils"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..base import HacsBase
@lru_cache(maxsize=1)
def _get_safe_paths(
config_path: str,
appdaemon_path: str,
plugin_path: str,
python_script_path: str,
theme_path: str,
) -> set[str]:
"""Get safe paths."""
return {
Path(f"{config_path}/{appdaemon_path}").as_posix(),
Path(f"{config_path}/{plugin_path}").as_posix(),
Path(f"{config_path}/{python_script_path}").as_posix(),
Path(f"{config_path}/{theme_path}").as_posix(),
Path(f"{config_path}/custom_components/").as_posix(),
Path(f"{config_path}/custom_templates/").as_posix(),
}
def is_safe(hacs: HacsBase, path: str | Path) -> bool:
"""Helper to check if path is safe to remove."""
configuration = hacs.configuration
return Path(path).as_posix() not in _get_safe_paths(
hacs.core.config_path,
configuration.appdaemon_path,
configuration.plugin_path,
configuration.python_script_path,
configuration.theme_path,
)

View File

@@ -0,0 +1,82 @@
"""The QueueManager class."""
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
import time
from homeassistant.core import HomeAssistant
from ..exceptions import HacsExecutionStillInProgress
from .logger import LOGGER
_LOGGER = LOGGER
class QueueManager:
"""The QueueManager class."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.queue: list[Coroutine] = []
self.running = False
@property
def pending_tasks(self) -> int:
"""Return a count of pending tasks in the queue."""
return len(self.queue)
@property
def has_pending_tasks(self) -> bool:
"""Return a count of pending tasks in the queue."""
return self.pending_tasks != 0
def clear(self) -> None:
"""Clear the queue."""
self.queue = []
def add(self, task: Coroutine) -> None:
"""Add a task to the queue."""
self.queue.append(task)
async def execute(self, number_of_tasks: int | None = None) -> None:
"""Execute the tasks in the queue."""
if self.running:
_LOGGER.debug("<QueueManager> Execution is already running")
raise HacsExecutionStillInProgress
if len(self.queue) == 0:
_LOGGER.debug("<QueueManager> The queue is empty")
return
self.running = True
_LOGGER.debug("<QueueManager> Checking out tasks to execute")
local_queue = []
if number_of_tasks:
for task in self.queue[:number_of_tasks]:
local_queue.append(task)
else:
for task in self.queue:
local_queue.append(task)
_LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue))
start = time.time()
result = await asyncio.gather(*local_queue, return_exceptions=True)
for entry in result:
if isinstance(entry, Exception):
_LOGGER.error("<QueueManager> %s", entry)
end = time.time() - start
for task in local_queue:
self.queue.remove(task)
_LOGGER.debug(
"<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds",
len(local_queue),
end,
)
if self.has_pending_tasks:
_LOGGER.debug("<QueueManager> %s tasks remaining in the queue", len(self.queue))
self.running = False

View File

@@ -0,0 +1,17 @@
"""Regex utils"""
from __future__ import annotations
import re
RE_REPOSITORY = re.compile(
r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$"
)
def extract_repository_from_url(url: str) -> str | None:
"""Extract the owner/repo part form a URL."""
match = re.match(RE_REPOSITORY, url)
if not match:
return None
return match.group(1).lower()

View File

@@ -0,0 +1,79 @@
"""Storage handers."""
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.storage import Store
from homeassistant.util import json as json_util
from ..const import VERSION_STORAGE
from ..exceptions import HacsException
from .logger import LOGGER
_LOGGER = LOGGER
class HACSStore(Store):
"""A subclass of Store that allows multiple loads in the executor."""
def load(self):
"""Load the data from disk if version matches."""
try:
data = json_util.load_json(self.path)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
_LOGGER.critical(
"Could not load '%s', restore it from a backup or delete the file: %s",
self.path,
exception,
)
raise HacsException(exception) from exception
if data == {} or data["version"] != self.version:
return None
return data["data"]
def get_store_key(key):
"""Return the key to use with homeassistant.helpers.storage.Storage."""
return key if "/" in key else f"hacs.{key}"
def _get_store_for_key(hass, key, encoder):
"""Create a Store object for the key."""
return HACSStore(hass, VERSION_STORAGE, get_store_key(key), encoder=encoder, atomic_writes=True)
def get_store_for_key(hass, key):
"""Create a Store object for the key."""
return _get_store_for_key(hass, key, JSONEncoder)
async def async_load_from_store(hass, key):
"""Load the retained data from store and return de-serialized data."""
return await get_store_for_key(hass, key).async_load() or {}
async def async_save_to_store(hass, key, data):
"""Generate dynamic data to store and save it to the filesystem.
The data is only written if the content on the disk has changed
by reading the existing content and comparing it.
If the data has changed this will generate two executor jobs
If the data has not changed this will generate one executor job
"""
current = await async_load_from_store(hass, key)
if current is None or current != data:
await get_store_for_key(hass, key).async_save(data)
return
_LOGGER.debug(
"<HACSStore async_save_to_store> Did not store data for '%s'. Content did not change",
get_store_key(key),
)
async def async_remove_store(hass, key):
"""Remove a store element that should no longer be used."""
if "/" not in key:
return
await get_store_for_key(hass, key).async_remove()

View File

@@ -0,0 +1,30 @@
"""Various URL utils for HACS."""
import re
from typing import Literal
GIT_SHA = re.compile(r"^[a-fA-F0-9]{40}$")
def github_release_asset(
*,
repository: str,
version: str,
filename: str,
**_,
) -> str:
"""Generate a download URL for a release asset."""
return f"https://github.com/{repository}/releases/download/{version}/{filename}"
def github_archive(
*,
repository: str,
version: str,
variant: Literal["heads", "tags"] = "heads",
**_,
) -> str:
"""Generate a download URL for a repository zip."""
if GIT_SHA.match(version):
return f"https://github.com/{repository}/archive/{version}.zip"
return f"https://github.com/{repository}/archive/refs/{variant}/{version}.zip"

View File

@@ -0,0 +1,215 @@
"""Validation utilities."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from awesomeversion import AwesomeVersion
from homeassistant.helpers.config_validation import url as url_validator
import voluptuous as vol
from ..const import LOCALE
@dataclass
class Validate:
"""Validate."""
errors: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""Return bool if the validation was a success."""
return len(self.errors) == 0
def _country_validator(values) -> list[str]:
"""Custom country validator."""
countries = []
if isinstance(values, str):
countries.append(values.upper())
elif isinstance(values, list):
for value in values:
countries.append(value.upper())
else:
raise vol.Invalid(f"Value '{values}' is not a string or list.", path=["country"])
for country in countries:
if country not in LOCALE:
raise vol.Invalid(f"Value '{country}' is not in {LOCALE}.", path=["country"])
return countries
HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Optional("content_in_root"): bool,
vol.Optional("country"): _country_validator,
vol.Optional("filename"): str,
vol.Optional("hacs"): str,
vol.Optional("hide_default_branch"): bool,
vol.Optional("homeassistant"): str,
vol.Optional("persistent_directory"): str,
vol.Optional("render_readme"): bool,
vol.Optional("zip_release"): bool,
vol.Required("name"): str,
},
extra=vol.PREVENT_EXTRA,
)
INTEGRATION_MANIFEST_JSON_SCHEMA = vol.Schema(
{
vol.Required("codeowners"): list,
vol.Required("documentation"): url_validator,
vol.Required("domain"): str,
vol.Required("issue_tracker"): url_validator,
vol.Required("name"): str,
vol.Required("version"): vol.Coerce(AwesomeVersion),
},
extra=vol.ALLOW_EXTRA,
)
def validate_repo_data(schema: dict[str, Any], extra: int) -> Callable[[Any], Any]:
"""Return a validator for repo data.
This is used instead of vol.All to always try both the repo schema and
and the validate_version validator.
"""
_schema = vol.Schema(schema, extra=extra)
def validate_repo_data(data: Any) -> Any:
"""Validate integration repo data."""
schema_errors: vol.MultipleInvalid | None = None
try:
_schema(data)
except vol.MultipleInvalid as err:
schema_errors = err
try:
validate_version(data)
except vol.Invalid as err:
if schema_errors:
schema_errors.add(err)
else:
raise
if schema_errors:
raise schema_errors
return data
return validate_repo_data
def validate_version(data: Any) -> Any:
"""Ensure at least one of last_commit or last_version is present."""
if "last_commit" not in data and "last_version" not in data:
raise vol.Invalid("Expected at least one of [`last_commit`, `last_version`], got none")
return data
V2_COMMON_DATA_JSON_SCHEMA = {
vol.Required("description"): vol.Any(str, None),
vol.Optional("downloads"): int,
vol.Optional("etag_releases"): str,
vol.Required("etag_repository"): str,
vol.Required("full_name"): str,
vol.Optional("last_commit"): str,
vol.Required("last_fetched"): vol.Any(int, float),
vol.Required("last_updated"): str,
vol.Optional("last_version"): str,
vol.Optional("prerelease"): str,
vol.Required("manifest"): {
vol.Optional("country"): vol.Any([str], False),
vol.Optional("name"): str,
},
vol.Optional("open_issues"): int,
vol.Optional("stargazers_count"): int,
vol.Optional("topics"): [str],
}
V2_INTEGRATION_DATA_JSON_SCHEMA = {
**V2_COMMON_DATA_JSON_SCHEMA,
vol.Required("domain"): str,
vol.Required("manifest_name"): str,
}
_V2_REPO_SCHEMAS = {
"appdaemon": V2_COMMON_DATA_JSON_SCHEMA,
"integration": V2_INTEGRATION_DATA_JSON_SCHEMA,
"plugin": V2_COMMON_DATA_JSON_SCHEMA,
"python_script": V2_COMMON_DATA_JSON_SCHEMA,
"template": V2_COMMON_DATA_JSON_SCHEMA,
"theme": V2_COMMON_DATA_JSON_SCHEMA,
}
# Used when validating repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REPO_DATA = {
category: validate_repo_data(schema, vol.REMOVE_EXTRA)
for category, schema in _V2_REPO_SCHEMAS.items()
}
# Used when validating repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REPO_DATA = {
category: vol.Schema({str: validate_repo_data(schema, vol.PREVENT_EXTRA)})
for category, schema in _V2_REPO_SCHEMAS.items()
}
V2_CRITICAL_REPO_DATA_SCHEMA = {
vol.Required("link"): str,
vol.Required("reason"): str,
vol.Required("repository"): str,
}
# Used when validating critical repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating critical repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_CRITICAL_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)
V2_REMOVED_REPO_DATA_SCHEMA = {
vol.Optional("link"): str,
vol.Optional("reason"): str,
vol.Required("removal_type"): vol.In(
[
"Integration is missing a version, and is abandoned.",
"Remove",
"archived",
"blacklist",
"critical",
"deprecated",
"removal",
"remove",
"removed",
"replaced",
"repository",
]
),
vol.Required("repository"): str,
}
# Used when validating removed repos in the hacs integration, discards extra keys
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.REMOVE_EXTRA,
)
# Used when validating removed repos when generating data, fails on extra keys
VALIDATE_GENERATED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
[
vol.Schema(
V2_REMOVED_REPO_DATA_SCHEMA,
extra=vol.PREVENT_EXTRA,
)
]
)

View File

@@ -0,0 +1,36 @@
"""Version utils."""
from __future__ import annotations
from functools import lru_cache
from awesomeversion import (
AwesomeVersion,
AwesomeVersionException,
AwesomeVersionStrategy,
)
@lru_cache(maxsize=1024)
def version_left_higher_then_right(left: str, right: str) -> bool | None:
"""Return a bool if source is newer than target, will also be true if identical."""
try:
left_version = AwesomeVersion(left)
right_version = AwesomeVersion(right)
if (
left_version.strategy != AwesomeVersionStrategy.UNKNOWN
and right_version.strategy != AwesomeVersionStrategy.UNKNOWN
):
return left_version > right_version
except (AwesomeVersionException, AttributeError, KeyError):
pass
return None
def version_left_higher_or_equal_then_right(left: str, right: str) -> bool:
"""Return a bool if source is newer than target, will also be true if identical."""
if left == right:
return True
return version_left_higher_then_right(left, right)

View File

@@ -0,0 +1,37 @@
"""Workarounds."""
from homeassistant.core import HomeAssistant
DOMAIN_OVERRIDES = {
# https://github.com/hacs/integration/issues/2465
"custom-components/sensor.custom_aftership": "custom_aftership"
}
try:
from homeassistant.components.http import StaticPathConfig
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component."""
await hass.http.async_register_static_paths(
[StaticPathConfig(url_path, path, cache_headers)]
)
except ImportError:
async def async_register_static_path(
hass: HomeAssistant,
url_path: str,
path: str,
cache_headers: bool = True,
) -> None:
"""Register a static path with the HTTP component.
Legacy: Can be removed when min version is 2024.7
https://developers.home-assistant.io/blog/2024/06/18/async_register_static_paths/
"""
hass.http.register_static_path(url_path, path, cache_headers)

View File

@@ -0,0 +1,30 @@
# Repository validation
This is where the validation rules that run against the various repository categories live.
## Structure
- There is one file pr. rule.
- All rule needs tests to verify every possible outcome for the rule.
- It's better with multiple files than a big rule.
- All rules uses `ActionValidationBase` as the base class.
- Only use `validate` or `async_validate` methods to define validation rules.
- If a rule should fail, raise `ValidationException` with the failure message.
## Example
```python
from .base import (
ActionValidationBase,
ValidationBase,
ValidationException,
)
class SuperAwesomeRepository(ActionValidationBase):
category = "integration"
async def async_validate(self):
if self.repository != "super-awesome":
raise ValidationException("The repository is not super-awesome")
```

View File

@@ -0,0 +1 @@
"""Initialize validation."""

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-archived"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if self.repository.data.archived:
raise ValidationException("The repository is archived")

View File

@@ -0,0 +1,54 @@
"""Base class for validation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..exceptions import HacsException
if TYPE_CHECKING:
from ..enums import HacsCategory
from ..repositories.base import HacsRepository
class ValidationException(HacsException):
"""Raise when there is a validation issue."""
class ActionValidationBase:
"""Base class for action validation."""
categories: tuple[HacsCategory, ...] = ()
allow_fork: bool = True
more_info: str = "https://hacs.xyz/docs/publish/action"
def __init__(self, repository: HacsRepository) -> None:
self.hacs = repository.hacs
self.repository = repository
self.failed = False
@property
def slug(self) -> str:
"""Return the check slug."""
return self.__class__.__module__.rsplit(".", maxsplit=1)[-1]
async def async_validate(self) -> None:
"""Validate the repository."""
async def execute_validation(self, *_: Any, **__: Any) -> None:
"""Execute the task defined in subclass."""
self.failed = False
try:
await self.async_validate()
except ValidationException as exception:
self.failed = True
self.hacs.log.error(
"<Validation %s> failed: %s (More info: %s )",
self.slug,
exception,
self.more_info,
)
else:
self.hacs.log.info("<Validation %s> completed", self.slug)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from custom_components.hacs.enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
URL = "https://brands.home-assistant.io/domains.json"
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-brands"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self) -> None:
"""Validate the repository."""
response = await self.hacs.session.get(URL)
content = await response.json()
if self.repository.data.domain not in content["custom"]:
raise ValidationException(
"The repository has not been added as a custom domain to the brands repo"
)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.description:
raise ValidationException("The repository has no description")

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from voluptuous.error import Invalid
from voluptuous.humanize import humanize_error
from ..enums import HacsCategory, RepositoryFile
from ..repositories.base import HacsManifest, HacsRepository
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest"
async def async_validate(self) -> None:
"""Validate the repository."""
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
content = await self.repository.async_get_hacs_json(self.repository.ref)
try:
hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
except Invalid as exception:
raise ValidationException(humanize_error(content, exception)) from exception
if self.repository.data.category == HacsCategory.INTEGRATION:
if hacsjson.zip_release and not hacsjson.filename:
raise ValidationException("zip_release is True, but filename is not set")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ..enums import HacsCategory
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
IGNORED = ["-shield", "img.shields.io", "buymeacoffee.com"]
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
categories = (HacsCategory.PLUGIN, HacsCategory.THEME)
more_info = "https://hacs.xyz/docs/publish/include#check-images"
async def async_validate(self) -> None:
"""Validate the repository."""
info = await self.repository.async_get_info_file_contents(version=self.repository.ref)
for line in info.split("\n"):
if "<img" in line or "![" in line:
if [ignore for ignore in IGNORED if ignore in line]:
continue
return
raise ValidationException("The repository does not have images in the Readme file")

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-info"
async def async_validate(self) -> None:
"""Validate the repository."""
filenames = [x.filename.lower() for x in self.repository.tree]
if "readme" in filenames:
pass
elif "readme.md" in filenames:
pass
elif "info" in filenames:
pass
elif "info.md" in filenames:
pass
else:
raise ValidationException("The repository has no information file")

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from voluptuous.error import Invalid
from ..enums import HacsCategory, RepositoryFile
from ..utils.validate import INTEGRATION_MANIFEST_JSON_SCHEMA
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
from ..repositories.integration import HacsIntegrationRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
repository: HacsIntegrationRepository
more_info = "https://hacs.xyz/docs/publish/include#check-manifest"
categories = (HacsCategory.INTEGRATION,)
async def async_validate(self) -> None:
"""Validate the repository."""
if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]:
raise ValidationException(
f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file"
)
content = await self.repository.get_integration_manifest(version=self.repository.ref)
try:
INTEGRATION_MANIFEST_JSON_SCHEMA(content)
except Invalid as exception:
raise ValidationException(exception) from exception

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.has_issues:
raise ValidationException("The repository does not have issues enabled")

View File

@@ -0,0 +1,81 @@
"""Hacs validation manager."""
from __future__ import annotations
import asyncio
from importlib import import_module
import os
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
from ..repositories.base import HacsRepository
from .base import ActionValidationBase
class ValidationManager:
"""Hacs validation manager."""
def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None:
"""Initialize the setup manager class."""
self.hacs = hacs
self.hass = hass
self._validators: dict[str, ActionValidationBase] = {}
@property
def validators(self) -> list[ActionValidationBase]:
"""Return all list of all tasks."""
return list(self._validators.values())
async def async_load(self, repository: HacsRepository) -> None:
"""Load all tasks."""
self._validators = {}
validator_files = Path(__file__).parent
validator_modules = (
module.stem
for module in validator_files.glob("*.py")
if module.name not in ("base.py", "__init__.py", "manager.py")
)
async def _load_module(module: str) -> None:
task_module = import_module(f"{__package__}.{module}")
if task := await task_module.async_setup_validator(repository=repository):
self._validators[task.slug] = task
await asyncio.gather(*[_load_module(task) for task in validator_modules])
async def async_run_repository_checks(self, repository: HacsRepository) -> None:
"""Run all validators for a repository."""
if not self.hacs.system.action:
return
await self.async_load(repository)
is_pull_from_fork = (
not os.getenv("INPUT_REPOSITORY")
and os.getenv("GITHUB_REPOSITORY") != repository.data.full_name
)
validators = [
validator
for validator in self.validators or []
if (
(not validator.categories or repository.data.category in validator.categories)
and validator.slug not in os.getenv("INPUT_IGNORE", "").split(" ")
and (not is_pull_from_fork or validator.allow_fork)
)
]
await asyncio.gather(*[validator.execute_validation() for validator in validators])
total = len(validators)
failed = len([x for x in validators if x.failed])
if failed != 0:
repository.logger.error("%s %s/%s checks failed", repository.string, failed, total)
exit(1)
else:
repository.logger.info("%s All (%s) checks passed", repository.string, total)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ActionValidationBase, ValidationException
if TYPE_CHECKING:
from ..repositories.base import HacsRepository
async def async_setup_validator(repository: HacsRepository) -> Validator:
"""Set up this validator."""
return Validator(repository=repository)
class Validator(ActionValidationBase):
"""Validate the repository."""
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
allow_fork = False
async def async_validate(self) -> None:
"""Validate the repository."""
if not self.repository.data.topics:
raise ValidationException("The repository has no valid topics")

View File

@@ -0,0 +1,123 @@
"""Register_commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
import voluptuous as vol
from ..const import DOMAIN
from .critical import hacs_critical_acknowledge, hacs_critical_list
from .repositories import (
hacs_repositories_add,
hacs_repositories_clear_new,
hacs_repositories_list,
hacs_repositories_remove,
hacs_repositories_removed,
)
from .repository import (
hacs_repository_beta,
hacs_repository_download,
hacs_repository_ignore,
hacs_repository_info,
hacs_repository_refresh,
hacs_repository_release_notes,
hacs_repository_releases,
hacs_repository_remove,
hacs_repository_state,
hacs_repository_version,
)
if TYPE_CHECKING:
from ..base import HacsBase
@callback
def async_register_websocket_commands(hass: HomeAssistant) -> None:
"""Register_commands."""
websocket_api.async_register_command(hass, hacs_info)
websocket_api.async_register_command(hass, hacs_subscribe)
websocket_api.async_register_command(hass, hacs_repository_info)
websocket_api.async_register_command(hass, hacs_repository_download)
websocket_api.async_register_command(hass, hacs_repository_ignore)
websocket_api.async_register_command(hass, hacs_repository_state)
websocket_api.async_register_command(hass, hacs_repository_version)
websocket_api.async_register_command(hass, hacs_repository_beta)
websocket_api.async_register_command(hass, hacs_repository_refresh)
websocket_api.async_register_command(hass, hacs_repository_release_notes)
websocket_api.async_register_command(hass, hacs_repository_remove)
websocket_api.async_register_command(hass, hacs_critical_acknowledge)
websocket_api.async_register_command(hass, hacs_critical_list)
websocket_api.async_register_command(hass, hacs_repositories_list)
websocket_api.async_register_command(hass, hacs_repositories_add)
websocket_api.async_register_command(hass, hacs_repositories_clear_new)
websocket_api.async_register_command(hass, hacs_repositories_removed)
websocket_api.async_register_command(hass, hacs_repositories_remove)
websocket_api.async_register_command(hass, hacs_repository_releases)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/subscribe",
vol.Required("signal"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_subscribe(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle websocket subscriptions."""
@callback
def forward_messages(data: dict | None = None) -> None:
"""Forward events to websocket."""
connection.send_message(websocket_api.event_message(msg["id"], data))
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass,
msg["signal"],
forward_messages,
)
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/info",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return information about HACS."""
hacs: HacsBase = hass.data.get(DOMAIN)
connection.send_message(
websocket_api.result_message(
msg["id"],
{
"categories": hacs.common.categories,
"country": hacs.configuration.country,
"debug": hacs.configuration.debug,
"dev": hacs.configuration.dev,
"disabled_reason": hacs.system.disabled_reason,
"has_pending_tasks": hacs.queue.has_pending_tasks,
"lovelace_mode": hacs.core.lovelace_mode,
"stage": hacs.stage,
"startup": hacs.status.startup,
"version": hacs.version,
},
)
)

View File

@@ -0,0 +1,59 @@
"""Register info websocket commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from ..utils.store import async_load_from_store, async_save_to_store
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/critical/list",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_critical_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List critical repositories."""
connection.send_message(
websocket_api.result_message(
msg["id"],
(await async_load_from_store(hass, "critical") or []),
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/critical/acknowledge",
vol.Optional("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_critical_acknowledge(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Acknowledge critical repository."""
repository = msg["repository"]
critical = await async_load_from_store(hass, "critical")
for repo in critical:
if repository == repo["repository"]:
repo["acknowledged"] = True
await async_save_to_store(hass, "critical", critical)
connection.send_message(websocket_api.result_message(msg["id"], critical))

View File

@@ -0,0 +1,216 @@
"""Register info websocket commands."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from custom_components.hacs.utils import regex
from ..const import DOMAIN
from ..enums import HacsDispatchEvent
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/list",
vol.Optional("categories"): [str],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_list(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List repositories."""
hacs: HacsBase = hass.data.get(DOMAIN)
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"authors": repo.data.authors,
"available_version": repo.display_available_version,
"installed_version": repo.display_installed_version,
"config_flow": repo.data.config_flow,
"can_download": repo.can_download,
"category": repo.data.category,
"country": repo.repository_manifest.country,
"custom": not hacs.repositories.is_default(str(repo.data.id)),
"description": repo.data.description,
"domain": repo.data.domain,
"downloads": repo.data.downloads,
"file_name": repo.data.file_name,
"full_name": repo.data.full_name,
"hide": repo.data.hide,
"homeassistant": repo.repository_manifest.homeassistant,
"id": repo.data.id,
"installed": repo.data.installed,
"last_updated": repo.data.last_updated,
"local_path": repo.content.path.local,
"name": repo.display_name,
"new": repo.data.new,
"pending_upgrade": repo.pending_update,
"stars": repo.data.stargazers_count,
"state": repo.state,
"status": repo.display_status,
"topics": repo.data.topics,
}
for repo in hacs.repositories.list_all
if repo.data.category in msg.get("categories", hacs.common.categories)
and not repo.ignored_by_country_configuration
and repo.data.last_fetched
],
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/clear_new",
vol.Optional("categories"): cv.ensure_list,
vol.Optional("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_clear_new(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Clear new repositories for specific categories."""
hacs: HacsBase = hass.data.get(DOMAIN)
if repo := msg.get("repository"):
repository = hacs.repositories.get_by_id(repo)
repository.data.new = False
else:
for repo in hacs.repositories.list_all:
if repo.data.new and repo.data.category in msg.get("categories", []):
hacs.log.debug(
"Clearing new flag from '%s'",
repo.data.full_name,
)
repo.data.new = False
hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/removed",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_removed(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get information about removed repositories."""
hacs: HacsBase = hass.data.get(DOMAIN)
content = []
for repo in hacs.repositories.list_removed:
if repo.repository not in hacs.common.ignored_repositories:
content.append(repo.to_json())
connection.send_message(websocket_api.result_message(msg["id"], content))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/add",
vol.Required("repository"): cv.string,
vol.Required("category"): vol.Lower,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_add(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Add custom repositoriy."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = regex.extract_repository_from_url(msg["repository"])
category = msg["category"]
if repository is None:
return
if repository in hacs.common.skip:
hacs.common.skip.remove(repository)
if renamed := hacs.common.renamed_repositories.get(repository):
repository = renamed
if category not in hacs.common.categories:
hacs.log.error("%s is not a valid category for %s", category, repository)
elif not hacs.repositories.get_by_full_name(repository):
try:
await hacs.async_register_repository(
repository_full_name=repository,
category=category,
)
except (
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
) as exception:
hacs.async_dispatch(
HacsDispatchEvent.ERROR,
{
"action": "add_repository",
"exception": str(sys.exc_info()[0].__name__),
"message": str(exception),
},
)
else:
hacs.async_dispatch(
HacsDispatchEvent.ERROR,
{
"action": "add_repository",
"message": f"Repository '{repository}' exists in the store.",
},
)
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repositories/remove",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repositories_remove(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove custom repositoriy."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.remove()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))

View File

@@ -0,0 +1,369 @@
"""Register info websocket commands."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import websocket_api
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from ..const import DOMAIN
from ..enums import HacsDispatchEvent
from ..exceptions import HacsException
from ..utils.version import version_left_higher_then_right
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..base import HacsBase
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/info",
vol.Required("repository_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return information about a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository_id = msg["repository_id"]
repository = hacs.repositories.get_by_id(repository_id)
if repository is None:
connection.send_error(
msg["id"],
"repository_not_found",
f"Repository with ID ({repository_id}) not found",
)
return
if not repository.updated_info:
try:
await repository.update_repository(ignore_issues=True, force=True)
except Exception as exception: # pylint: disable=broad-except
repository.logger.error("%s %s", repository.string, exception)
repository.updated_info = True
if repository.data.new:
repository.data.new = False
await hacs.data.async_write()
connection.send_message(
websocket_api.result_message(
msg["id"],
{
"additional_info": repository.additional_info,
"authors": repository.data.authors,
"available_version": repository.display_available_version,
"beta": repository.data.show_beta,
"can_download": repository.can_download,
"category": repository.data.category,
"config_flow": repository.data.config_flow,
"country": repository.repository_manifest.country,
"custom": not hacs.repositories.is_default(str(repository.data.id)),
"default_branch": repository.data.default_branch,
"description": repository.data.description,
"domain": repository.data.domain,
"downloads": repository.data.downloads,
"file_name": repository.data.file_name,
"full_name": repository.data.full_name,
"hide_default_branch": repository.repository_manifest.hide_default_branch,
"homeassistant": repository.repository_manifest.homeassistant,
"id": repository.data.id,
"installed_version": repository.display_installed_version,
"installed": repository.data.installed,
"issues": repository.data.open_issues,
"last_updated": repository.data.last_updated,
"local_path": repository.content.path.local,
"name": repository.display_name,
"new": False,
"pending_upgrade": repository.pending_update,
"releases": repository.data.published_tags,
"ref": repository.ref,
"selected_tag": repository.data.selected_tag,
"stars": repository.data.stargazers_count,
"state": repository.state,
"status": repository.display_status,
"topics": repository.data.topics,
"version_or_commit": repository.display_version_or_commit,
},
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/ignore",
vol.Required("repository"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_ignore(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Ignore a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository_id = msg["repository"]
hacs.log.info("Ignoring %s", repository_id)
repository = hacs.repositories.get_by_id(repository_id)
if repository is None:
connection.send_error(
msg["id"],
"repository_not_found",
f"Repository with ID ({repository_id}) not found",
)
return
hacs.common.ignored_repositories.add(repository.data.full_name)
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/state",
vol.Required("repository"): cv.string,
vol.Required("state"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_state(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the state of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.state = msg["state"]
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/version",
vol.Required("repository"): cv.string,
vol.Required("version"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_version(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the version of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
if msg["version"] == repository.data.default_branch:
repository.data.selected_tag = None
else:
repository.data.selected_tag = msg["version"]
await repository.update_repository(force=True)
repository.state = None
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/beta",
vol.Required("repository"): cv.string,
vol.Required("show_beta"): cv.boolean,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_beta(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Show or hide beta versions of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.data.show_beta = msg["show_beta"]
await repository.update_repository(force=True)
repository.state = None
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/download",
vol.Required("repository"): cv.string,
vol.Optional("version"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Set the version of a repository"""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
try:
was_installed = repository.data.installed
await repository.async_download_repository(ref=msg.get("version"))
if not was_installed:
hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
await hacs.async_recreate_entities()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
except HacsException as exception:
repository.logger.error("%s %s", repository.string, exception)
connection.send_error(msg["id"], "error", str(exception))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/remove",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_remove(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
repository.data.new = False
try:
await repository.update_repository(ignore_issues=True, force=True)
except Exception as exception: # pylint: disable=broad-except
repository.logger.error("%s %s", repository.string, exception)
await repository.uninstall()
await hacs.data.async_write()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/refresh",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_refresh(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Refresh a repository."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
await repository.update_repository(ignore_issues=True, force=True)
await hacs.data.async_write()
# Update state of update entity
hacs.coordinators[repository.data.category].async_update_listeners()
connection.send_message(websocket_api.result_message(msg["id"], {}))
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/release_notes",
vol.Required("repository"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_release_notes(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return release notes."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository"])
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"name": x.name,
"body": x.body,
"tag": x.tag_name,
}
for x in repository.releases.objects
if not repository.data.installed_version
or version_left_higher_then_right(x.tag_name, repository.data.installed_version)
],
)
)
@websocket_api.websocket_command(
{
vol.Required("type"): "hacs/repository/releases",
vol.Required("repository_id"): cv.string,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def hacs_repository_releases(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return releases."""
hacs: HacsBase = hass.data.get(DOMAIN)
repository = hacs.repositories.get_by_id(msg["repository_id"])
try:
releases = await repository.async_get_releases()
except Exception as exception:
hacs.log.exception(exception)
connection.send_error(msg["id"], "unknown", str(exception))
return
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
"name": release.name,
"tag": release.tag_name,
"published_at": release.published_at,
"prerelease": release.prerelease,
}
for release in releases
],
)
)

View File

@@ -0,0 +1,378 @@
"""The LocalTuya integration."""
import asyncio
import logging
import time
from datetime import timedelta
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_REGION,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import async_register_admin_service
from .cloud_api import TuyaCloudApi
from .common import TuyaDevice, async_config_entry_by_device_id
from .config_flow import ENTRIES_VERSION, config_schema
from .const import (
ATTR_UPDATED_AT,
CONF_NO_CLOUD,
CONF_PRODUCT_KEY,
CONF_USER_ID,
DATA_CLOUD,
DATA_DISCOVERY,
DOMAIN,
TUYA_DEVICES,
)
from .discovery import TuyaDiscovery
_LOGGER = logging.getLogger(__name__)
UNSUB_LISTENER = "unsub_listener"
RECONNECT_INTERVAL = timedelta(seconds=60)
CONFIG_SCHEMA = config_schema()
CONF_DP = "dp"
CONF_VALUE = "value"
SERVICE_SET_DP = "set_dp"
SERVICE_SET_DP_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_DP): int,
vol.Required(CONF_VALUE): object,
}
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the LocalTuya integration component."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][TUYA_DEVICES] = {}
device_cache = {}
async def _handle_reload(service):
"""Handle reload service call."""
_LOGGER.info("Service %s.reload called: reloading integration", DOMAIN)
current_entries = hass.config_entries.async_entries(DOMAIN)
reload_tasks = [
hass.config_entries.async_reload(entry.entry_id)
for entry in current_entries
]
await asyncio.gather(*reload_tasks)
async def _handle_set_dp(event):
"""Handle set_dp service call."""
dev_id = event.data[CONF_DEVICE_ID]
if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]:
raise HomeAssistantError("unknown device id")
device = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
if not device.connected:
raise HomeAssistantError("not connected to device")
await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP])
def _device_discovered(device):
"""Update address of device if it has changed."""
device_ip = device["ip"]
device_id = device["gwId"]
product_key = device["productKey"]
# If device is not in cache, check if a config entry exists
entry = async_config_entry_by_device_id(hass, device_id)
if entry is None:
return
if device_id not in device_cache:
if entry and device_id in entry.data[CONF_DEVICES]:
# Save address from config entry in cache to trigger
# potential update below
host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST]
device_cache[device_id] = host_ip
if device_id not in device_cache:
return
dev_entry = entry.data[CONF_DEVICES][device_id]
new_data = entry.data.copy()
updated = False
if device_cache[device_id] != device_ip:
updated = True
new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip
device_cache[device_id] = device_ip
if dev_entry.get(CONF_PRODUCT_KEY) != product_key:
updated = True
new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key
# Update settings if something changed, otherwise try to connect. Updating
# settings triggers a reload of the config entry, which tears down the device
# so no need to connect in that case.
if updated:
_LOGGER.debug(
"Updating keys for device %s: %s %s", device_id, device_ip, product_key
)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(entry, data=new_data)
elif device_id in hass.data[DOMAIN][TUYA_DEVICES]:
_LOGGER.debug("Device %s found with IP %s", device_id, device_ip)
device = hass.data[DOMAIN][TUYA_DEVICES].get(device_id)
if not device:
_LOGGER.warning(f"Could not find device for device_id {device_id}")
elif not device.connected:
device.async_connect()
def _shutdown(event):
"""Clean up resources when shutting down."""
discovery.close()
async def _async_reconnect(now):
"""Try connecting to devices not already connected to."""
for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
if not device.connected:
device.async_connect()
async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
_handle_reload,
)
hass.services.async_register(
DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA
)
discovery = TuyaDiscovery(_device_discovered)
try:
await discovery.start()
hass.data[DOMAIN][DATA_DISCOVERY] = discovery
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("failed to set up discovery")
return True
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entries merging all of them in one."""
new_version = ENTRIES_VERSION
stored_entries = hass.config_entries.async_entries(DOMAIN)
if config_entry.version == 1:
_LOGGER.debug("Migrating config entry from version %s", config_entry.version)
if config_entry.entry_id == stored_entries[0].entry_id:
_LOGGER.debug(
"Migrating the first config entry (%s)", config_entry.entry_id
)
new_data = {}
new_data[CONF_REGION] = "eu"
new_data[CONF_CLIENT_ID] = ""
new_data[CONF_CLIENT_SECRET] = ""
new_data[CONF_USER_ID] = ""
new_data[CONF_USERNAME] = DOMAIN
new_data[CONF_NO_CLOUD] = True
new_data[CONF_DEVICES] = {
config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()
}
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
config_entry.version = new_version
hass.config_entries.async_update_entry(
config_entry, title=DOMAIN, data=new_data
)
else:
_LOGGER.debug(
"Merging the config entry %s into the main one", config_entry.entry_id
)
new_data = stored_entries[0].data.copy()
new_data[CONF_DEVICES].update(
{config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()}
)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(stored_entries[0], data=new_data)
await hass.config_entries.async_remove(config_entry.entry_id)
_LOGGER.info(
"Entry %s successfully migrated to version %s.",
config_entry.entry_id,
new_version,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up LocalTuya integration from a config entry."""
if entry.version < ENTRIES_VERSION:
_LOGGER.debug(
"Skipping setup for entry %s since its version (%s) is old",
entry.entry_id,
entry.version,
)
return
region = entry.data[CONF_REGION]
client_id = entry.data[CONF_CLIENT_ID]
secret = entry.data[CONF_CLIENT_SECRET]
user_id = entry.data[CONF_USER_ID]
tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id)
no_cloud = True
if CONF_NO_CLOUD in entry.data:
no_cloud = entry.data.get(CONF_NO_CLOUD)
if no_cloud:
_LOGGER.info("Cloud API account not configured.")
# wait 1 second to make sure possible migration has finished
await asyncio.sleep(1)
else:
res = await tuya_api.async_get_access_token()
if res != "ok":
_LOGGER.error("Cloud API connection failed: %s", res)
else:
_LOGGER.info("Cloud API connection succeeded.")
res = await tuya_api.async_get_devices_list()
hass.data[DOMAIN][DATA_CLOUD] = tuya_api
platforms = set()
for dev_id in entry.data[CONF_DEVICES].keys():
entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES]
platforms = platforms.union(
set(entity[CONF_PLATFORM] for entity in entities)
)
hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id)
# Setup all platforms at once, letting HA handling each platform and avoiding
# potential integration restarts while elements are still initialising.
await hass.config_entries.async_forward_entry_setups(entry, platforms)
async def setup_entities(device_ids):
for dev_id in device_ids:
hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect()
await async_remove_orphan_entities(hass, entry)
hass.async_create_task(setup_entities(entry.data[CONF_DEVICES].keys()))
unsub_listener = entry.add_update_listener(update_listener)
hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener}
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
platforms = {}
for dev_id, dev_entry in entry.data[CONF_DEVICES].items():
for entity in dev_entry[CONF_ENTITIES]:
platforms[entity[CONF_PLATFORM]] = True
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in platforms
]
)
)
hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]()
for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
if device.connected:
await device.close()
if unload_ok:
hass.data[DOMAIN][TUYA_DEVICES] = {}
return True
async def update_listener(hass, config_entry):
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
dev_id = list(device_entry.identifiers)[0][1].split("_")[-1]
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
if dev_id in ent.unique_id
}
for entity_id in entities.values():
ent_reg.async_remove(entity_id)
if dev_id not in config_entry.data[CONF_DEVICES]:
_LOGGER.info(
"Device %s not found in config entry: finalizing device removal", dev_id
)
return True
await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close()
new_data = config_entry.data.copy()
new_data[CONF_DEVICES].pop(dev_id)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
)
_LOGGER.info("Device %s removed.", dev_id)
return True
async def async_remove_orphan_entities(hass, entry):
"""Remove entities associated with config entry that has been removed."""
return
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
}
_LOGGER.info("ENTITIES ORPHAN %s", entities)
return
for entity in entry.data[CONF_ENTITIES]:
if entity[CONF_ID] in entities:
del entities[entity[CONF_ID]]
for entity_id in entities.values():
ent_reg.async_remove(entity_id)

View File

@@ -0,0 +1,76 @@
"""Platform to present any Tuya DP as a binary sensor."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
DOMAIN,
BinarySensorEntity,
)
from homeassistant.const import CONF_DEVICE_CLASS
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_STATE_ON = "state_on"
CONF_STATE_OFF = "state_off"
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_STATE_ON, default="True"): str,
vol.Required(CONF_STATE_OFF, default="False"): str,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
}
class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
"""Representation of a Tuya binary sensor."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya binary sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._is_on = False
@property
def is_on(self):
"""Return sensor state."""
return self._is_on
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
def status_updated(self):
"""Device status was updated."""
super().status_updated()
state = str(self.dps(self._dp_id)).lower()
if state == self._config[CONF_STATE_ON].lower():
self._is_on = True
elif state == self._config[CONF_STATE_OFF].lower():
self._is_on = False
else:
self.warning(
"State for entity %s did not match state patterns", self.entity_id
)
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial(
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema
)

View File

@@ -0,0 +1,522 @@
"""Platform to locally control Tuya-based climate devices."""
import asyncio
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.climate import (
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
DOMAIN,
ClimateEntity,
)
from homeassistant.components.climate.const import (
HVACAction,
HVACMode,
PRESET_AWAY,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
ClimateEntityFeature,
FAN_AUTO,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
FAN_TOP,
SWING_ON,
SWING_OFF,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
UnitOfTemperature,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_CURRENT_TEMPERATURE_DP,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_ECO_DP,
CONF_ECO_VALUE,
CONF_HEURISTIC_ACTION,
CONF_HVAC_ACTION_DP,
CONF_HVAC_ACTION_SET,
CONF_HVAC_MODE_DP,
CONF_HVAC_MODE_SET,
CONF_MAX_TEMP_DP,
CONF_MIN_TEMP_DP,
CONF_PRECISION,
CONF_PRESET_DP,
CONF_PRESET_SET,
CONF_TARGET_PRECISION,
CONF_TARGET_TEMPERATURE_DP,
CONF_TEMPERATURE_STEP,
CONF_HVAC_FAN_MODE_DP,
CONF_HVAC_FAN_MODE_SET,
CONF_HVAC_SWING_MODE_DP,
CONF_HVAC_SWING_MODE_SET,
)
_LOGGER = logging.getLogger(__name__)
HVAC_MODE_SETS = {
"manual/auto": {
HVACMode.HEAT: "manual",
HVACMode.AUTO: "auto",
},
"Manual/Auto": {
HVACMode.HEAT: "Manual",
HVACMode.AUTO: "Auto",
},
"MANUAL/AUTO": {
HVACMode.HEAT: "MANUAL",
HVACMode.AUTO: "AUTO",
},
"Manual/Program": {
HVACMode.HEAT: "Manual",
HVACMode.AUTO: "Program",
},
"m/p": {
HVACMode.HEAT: "m",
HVACMode.AUTO: "p",
},
"True/False": {
HVACMode.HEAT: True,
},
"Auto/Cold/Dry/Wind/Hot": {
HVACMode.HEAT: "hot",
HVACMode.FAN_ONLY: "wind",
HVACMode.DRY: "wet",
HVACMode.COOL: "cold",
HVACMode.AUTO: "auto",
},
"Cold/Dehumidify/Hot": {
HVACMode.HEAT: "hot",
HVACMode.DRY: "dehumidify",
HVACMode.COOL: "cold",
},
"1/0": {
HVACMode.HEAT: "1",
HVACMode.AUTO: "0",
},
}
HVAC_ACTION_SETS = {
"True/False": {
HVACAction.HEATING: True,
HVACAction.IDLE: False,
},
"open/close": {
HVACAction.HEATING: "open",
HVACAction.IDLE: "close",
},
"heating/no_heating": {
HVACAction.HEATING: "heating",
HVACAction.IDLE: "no_heating",
},
"Heat/Warming": {
HVACAction.HEATING: "Heat",
HVACAction.IDLE: "Warming",
},
"heating/warming": {
HVACAction.HEATING: "heating",
HVACAction.IDLE: "warming",
},
}
HVAC_FAN_MODE_SETS = {
"Auto/Low/Middle/High/Strong": {
FAN_AUTO: "auto",
FAN_LOW: "low",
FAN_MEDIUM: "middle",
FAN_HIGH: "high",
FAN_TOP: "strong",
}
}
HVAC_SWING_MODE_SETS = {
"True/False": {
SWING_ON: True,
SWING_OFF: False,
}
}
PRESET_SETS = {
"Manual/Holiday/Program": {
PRESET_AWAY: "Holiday",
PRESET_HOME: "Program",
PRESET_NONE: "Manual",
},
"smart/holiday/hold": {
PRESET_AWAY: "holiday",
PRESET_HOME: "smart",
PRESET_NONE: "hold",
},
}
TEMPERATURE_CELSIUS = "celsius"
TEMPERATURE_FAHRENHEIT = "fahrenheit"
DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS
DEFAULT_PRECISION = PRECISION_TENTHS
DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES
# Empirically tested to work for AVATTO thermostat
MODE_WAIT = 0.1
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps),
vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps),
vol.Optional(CONF_TEMPERATURE_STEP, default=PRECISION_WHOLE): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps),
vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps),
vol.Optional(CONF_PRECISION, default=PRECISION_WHOLE): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps),
vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())),
vol.Optional(CONF_HVAC_FAN_MODE_DP): vol.In(dps),
vol.Optional(CONF_HVAC_FAN_MODE_SET): vol.In(list(HVAC_FAN_MODE_SETS.keys())),
vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps),
vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())),
vol.Optional(CONF_ECO_DP): vol.In(dps),
vol.Optional(CONF_ECO_VALUE): str,
vol.Optional(CONF_PRESET_DP): vol.In(dps),
vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())),
vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(
[TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT]
),
vol.Optional(CONF_TARGET_PRECISION, default=PRECISION_WHOLE): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_HEURISTIC_ACTION): bool,
}
class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity):
"""Tuya climate device."""
def __init__(
self,
device,
config_entry,
switchid,
**kwargs,
):
"""Initialize a new LocaltuyaClimate."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
self._state = None
self._target_temperature = None
self._current_temperature = None
self._hvac_mode = None
self._fan_mode = None
self._swing_mode = None
self._preset_mode = None
self._hvac_action = None
self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION)
self._target_precision = self._config.get(
CONF_TARGET_PRECISION, self._precision
)
self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP)
self._conf_hvac_mode_set = HVAC_MODE_SETS.get(
self._config.get(CONF_HVAC_MODE_SET), {}
)
self._conf_hvac_fan_mode_dp = self._config.get(CONF_HVAC_FAN_MODE_DP)
self._conf_hvac_fan_mode_set = HVAC_FAN_MODE_SETS.get(
self._config.get(CONF_HVAC_FAN_MODE_SET), {}
)
self._conf_hvac_swing_mode_dp = self._config.get(CONF_HVAC_SWING_MODE_DP)
self._conf_hvac_swing_mode_set = HVAC_SWING_MODE_SETS.get(
self._config.get(CONF_HVAC_SWING_MODE_SET), {}
)
self._conf_preset_dp = self._config.get(CONF_PRESET_DP)
self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {})
self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP)
self._conf_hvac_action_set = HVAC_ACTION_SETS.get(
self._config.get(CONF_HVAC_ACTION_SET), {}
)
self._conf_eco_dp = self._config.get(CONF_ECO_DP)
self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO")
self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config(
CONF_PRESET_DP
)
_LOGGER.debug("Initialized climate [%s]", self.name)
@property
def supported_features(self):
"""Flag supported features."""
supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE
if self.has_config(CONF_MAX_TEMP_DP):
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP):
supported_features = supported_features | ClimateEntityFeature.PRESET_MODE
if self.has_config(CONF_HVAC_FAN_MODE_DP) and self.has_config(CONF_HVAC_FAN_MODE_SET):
supported_features = supported_features | ClimateEntityFeature.FAN_MODE
if self.has_config(CONF_HVAC_SWING_MODE_DP):
supported_features = supported_features | ClimateEntityFeature.SWING_MODE
return supported_features
@property
def precision(self):
"""Return the precision of the system."""
return self._precision
@property
def target_precision(self):
"""Return the precision of the target."""
return self._target_precision
@property
def temperature_unit(self):
"""Return the unit of measurement used by the platform."""
if (
self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT)
== TEMPERATURE_FAHRENHEIT
):
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def hvac_mode(self):
"""Return current operation ie. heat, cool, idle."""
return self._hvac_mode
@property
def hvac_modes(self):
"""Return the list of available operation modes."""
if not self.has_config(CONF_HVAC_MODE_DP):
return None
return list(self._conf_hvac_mode_set) + [HVACMode.OFF]
@property
def hvac_action(self):
"""Return the current running hvac operation if supported.
Need to be one of CURRENT_HVAC_*.
"""
if self._config.get(CONF_HEURISTIC_ACTION, False):
if self._hvac_mode == HVACMode.HEAT:
if self._current_temperature < (
self._target_temperature - self._precision
):
self._hvac_action = HVACAction.HEATING
if self._current_temperature == (
self._target_temperature - self._precision
):
if self._hvac_action == HVACAction.HEATING:
self._hvac_action = HVACAction.HEATING
if self._hvac_action == HVACAction.IDLE:
self._hvac_action = HVACAction.IDLE
if (
self._current_temperature + self._precision
) > self._target_temperature:
self._hvac_action = HVACAction.IDLE
return self._hvac_action
return self._hvac_action
@property
def preset_mode(self):
"""Return current preset."""
return self._preset_mode
@property
def preset_modes(self):
"""Return the list of available presets modes."""
if not self._has_presets:
return None
presets = list(self._conf_preset_set)
if self._conf_eco_dp:
presets.append(PRESET_ECO)
return presets
@property
def current_temperature(self):
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temperature
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP)
@property
def fan_mode(self):
"""Return the fan setting."""
return self._fan_mode
@property
def fan_modes(self):
"""Return the list of available fan modes."""
if not self.has_config(CONF_HVAC_FAN_MODE_DP):
return None
return list(self._conf_hvac_fan_mode_set)
@property
def swing_mode(self):
"""Return the swing setting."""
return self._swing_mode
@property
def swing_modes(self):
"""Return the list of available swing modes."""
if not self.has_config(CONF_HVAC_SWING_MODE_DP):
return None
return list(self._conf_hvac_swing_mode_set)
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP):
temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision)
await self._device.set_dp(
temperature, self._config[CONF_TARGET_TEMPERATURE_DP]
)
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if self._conf_hvac_fan_mode_dp is None:
_LOGGER.error("Fan speed unsupported (no DP)")
return
if fan_mode not in self._conf_hvac_fan_mode_set:
_LOGGER.error("Unsupported fan_mode: %s" % fan_mode)
return
await self._device.set_dp(
self._conf_hvac_fan_mode_set[fan_mode], self._conf_hvac_fan_mode_dp
)
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target operation mode."""
if hvac_mode == HVACMode.OFF:
await self._device.set_dp(False, self._dp_id)
return
if not self._state and self._conf_hvac_mode_dp != self._dp_id:
await self._device.set_dp(True, self._dp_id)
# Some thermostats need a small wait before sending another update
await asyncio.sleep(MODE_WAIT)
await self._device.set_dp(
self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp
)
async def async_set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
if self._conf_hvac_swing_mode_dp is None:
_LOGGER.error("Swing mode unsupported (no DP)")
return
if swing_mode not in self._conf_hvac_swing_mode_set:
_LOGGER.error("Unsupported swing_mode: %s" % swing_mode)
return
await self._device.set_dp(
self._conf_hvac_swing_mode_set[swing_mode], self._conf_hvac_swing_mode_dp
)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set_dp(True, self._dp_id)
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self._device.set_dp(False, self._dp_id)
async def async_set_preset_mode(self, preset_mode):
"""Set new target preset mode."""
if preset_mode == PRESET_ECO:
await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp)
return
await self._device.set_dp(
self._conf_preset_set[preset_mode], self._conf_preset_dp
)
@property
def min_temp(self):
"""Return the minimum temperature."""
if self.has_config(CONF_MIN_TEMP_DP):
return self.dps_conf(CONF_MIN_TEMP_DP)
return self._config[CONF_TEMP_MIN]
@property
def max_temp(self):
"""Return the maximum temperature."""
if self.has_config(CONF_MAX_TEMP_DP):
return self.dps_conf(CONF_MAX_TEMP_DP)
return self._config[CONF_TEMP_MAX]
def status_updated(self):
"""Device status was updated."""
self._state = self.dps(self._dp_id)
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
self._target_temperature = (
self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision
)
if self.has_config(CONF_CURRENT_TEMPERATURE_DP):
self._current_temperature = (
self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision
)
if self._has_presets:
if (
self.has_config(CONF_ECO_DP)
and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value
):
self._preset_mode = PRESET_ECO
else:
for preset, value in self._conf_preset_set.items(): # todo remove
if self.dps_conf(CONF_PRESET_DP) == value:
self._preset_mode = preset
break
else:
self._preset_mode = PRESET_NONE
# Update the HVAC status
if self.has_config(CONF_HVAC_MODE_DP):
if not self._state:
self._hvac_mode = HVACMode.OFF
else:
for mode, value in self._conf_hvac_mode_set.items():
if self.dps_conf(CONF_HVAC_MODE_DP) == value:
self._hvac_mode = mode
break
else:
# in case hvac mode and preset share the same dp
self._hvac_mode = HVACMode.AUTO
# Update the fan status
if self.has_config(CONF_HVAC_FAN_MODE_DP):
for mode, value in self._conf_hvac_fan_mode_set.items():
if self.dps_conf(CONF_HVAC_FAN_MODE_DP) == value:
self._fan_mode = mode
break
else:
# in case fan mode and preset share the same dp
_LOGGER.debug("Unknown fan mode %s" % self.dps_conf(CONF_HVAC_FAN_MODE_DP))
self._fan_mode = FAN_AUTO
# Update the swing status
if self.has_config(CONF_HVAC_SWING_MODE_DP):
for mode, value in self._conf_hvac_swing_mode_set.items():
if self.dps_conf(CONF_HVAC_SWING_MODE_DP) == value:
self._swing_mode = mode
break
else:
_LOGGER.debug("Unknown swing mode %s" % self.dps_conf(CONF_HVAC_SWING_MODE_DP))
self._swing_mode = SWING_OFF
# Update the current action
for action, value in self._conf_hvac_action_set.items():
if self.dps_conf(CONF_HVAC_ACTION_DP) == value:
self._hvac_action = action
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema)

View File

@@ -0,0 +1,139 @@
"""Class to perform requests to Tuya Cloud APIs."""
import functools
import hashlib
import hmac
import json
import logging
import time
import requests
_LOGGER = logging.getLogger(__name__)
# Signature algorithm.
def calc_sign(msg, key):
"""Calculate signature for request."""
sign = (
hmac.new(
msg=bytes(msg, "latin-1"),
key=bytes(key, "latin-1"),
digestmod=hashlib.sha256,
)
.hexdigest()
.upper()
)
return sign
class TuyaCloudApi:
"""Class to send API calls."""
def __init__(self, hass, region_code, client_id, secret, user_id):
"""Initialize the class."""
self._hass = hass
self._base_url = f"https://openapi.tuya{region_code}.com"
self._client_id = client_id
self._secret = secret
self._user_id = user_id
self._access_token = ""
self.device_list = {}
def generate_payload(self, method, timestamp, url, headers, body=None):
"""Generate signed payload for requests."""
payload = self._client_id + self._access_token + timestamp
payload += method + "\n"
# Content-SHA256
payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest()
payload += (
"\n"
+ "".join(
[
"%s:%s\n" % (key, headers[key]) # Headers
for key in headers.get("Signature-Headers", "").split(":")
if key in headers
]
)
+ "\n/"
+ url.split("//", 1)[-1].split("/", 1)[-1] # Url
)
# _LOGGER.debug("PAYLOAD: %s", payload)
return payload
async def async_make_request(self, method, url, body=None, headers={}):
"""Perform requests."""
timestamp = str(int(time.time() * 1000))
payload = self.generate_payload(method, timestamp, url, headers, body)
default_par = {
"client_id": self._client_id,
"access_token": self._access_token,
"sign": calc_sign(payload, self._secret),
"t": timestamp,
"sign_method": "HMAC-SHA256",
}
full_url = self._base_url + url
# _LOGGER.debug("\n" + method + ": [%s]", full_url)
if method == "GET":
func = functools.partial(
requests.get, full_url, headers=dict(default_par, **headers)
)
elif method == "POST":
func = functools.partial(
requests.post,
full_url,
headers=dict(default_par, **headers),
data=json.dumps(body),
)
# _LOGGER.debug("BODY: [%s]", body)
elif method == "PUT":
func = functools.partial(
requests.put,
full_url,
headers=dict(default_par, **headers),
data=json.dumps(body),
)
resp = await self._hass.async_add_executor_job(func)
# r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format
return resp
async def async_get_access_token(self):
"""Obtain a valid access token."""
try:
resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1")
except requests.exceptions.ConnectionError:
return "Request failed, status ConnectionError"
if not resp.ok:
return "Request failed, status " + str(resp.status)
r_json = resp.json()
if not r_json["success"]:
return f"Error {r_json['code']}: {r_json['msg']}"
self._access_token = resp.json()["result"]["access_token"]
return "ok"
async def async_get_devices_list(self):
"""Obtain the list of devices associated to a user."""
resp = await self.async_make_request(
"GET", url=f"/v1.0/users/{self._user_id}/devices"
)
if not resp.ok:
return "Request failed, status " + str(resp.status)
r_json = resp.json()
if not r_json["success"]:
# _LOGGER.debug(
# "Request failed, reply is %s",
# json.dumps(r_json, indent=2, ensure_ascii=False)
# )
return f"Error {r_json['code']}: {r_json['msg']}"
self.device_list = {dev["id"]: dev for dev in r_json["result"]}
# _LOGGER.debug("DEV_LIST: %s", self.device_list)
return "ok"

View File

@@ -0,0 +1,607 @@
"""Code shared between all platforms."""
import asyncio
import json.decoder
import logging
import time
from datetime import timedelta
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
STATE_UNKNOWN,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.restore_state import RestoreEntity
from . import pytuya
from .const import (
ATTR_STATE,
ATTR_UPDATED_AT,
CONF_DEFAULT_VALUE,
CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
CONF_MODEL,
CONF_PASSIVE_ENTITY,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_RESTORE_ON_RECONNECT,
DATA_CLOUD,
DOMAIN,
TUYA_DEVICES,
)
_LOGGER = logging.getLogger(__name__)
def prepare_setup_entities(hass, config_entry, platform):
"""Prepare ro setup entities for a platform."""
entities_to_setup = [
entity
for entity in config_entry.data[CONF_ENTITIES]
if entity[CONF_PLATFORM] == platform
]
if not entities_to_setup:
return None, None
tuyainterface = []
return tuyainterface, entities_to_setup
async def async_setup_entry(
domain, entity_class, flow_schema, hass, config_entry, async_add_entities
):
"""Set up a Tuya platform based on a config entry.
This is a generic method and each platform should lock domain and
entity_class with functools.partial.
"""
entities = []
for dev_id in config_entry.data[CONF_DEVICES]:
# entities_to_setup = prepare_setup_entities(
# hass, config_entry.data[dev_id], domain
# )
dev_entry = config_entry.data[CONF_DEVICES][dev_id]
entities_to_setup = [
entity
for entity in dev_entry[CONF_ENTITIES]
if entity[CONF_PLATFORM] == domain
]
if entities_to_setup:
tuyainterface = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
dps_config_fields = list(get_dps_for_platform(flow_schema))
for entity_config in entities_to_setup:
# Add DPS used by this platform to the request list
for dp_conf in dps_config_fields:
if dp_conf in entity_config:
tuyainterface.dps_to_request[entity_config[dp_conf]] = None
entities.append(
entity_class(
tuyainterface,
dev_entry,
entity_config[CONF_ID],
)
)
# Once the entities have been created, add to the TuyaDevice instance
tuyainterface.add_entities(entities)
async_add_entities(entities)
def get_dps_for_platform(flow_schema):
"""Return config keys for all platform keys that depends on a datapoint."""
for key, value in flow_schema(None).items():
if hasattr(value, "container") and value.container is None:
yield key.schema
def get_entity_config(config_entry, dp_id):
"""Return entity config for a given DPS id."""
for entity in config_entry[CONF_ENTITIES]:
if entity[CONF_ID] == dp_id:
return entity
raise Exception(f"missing entity config for id {dp_id}")
@callback
def async_config_entry_by_device_id(hass, device_id):
"""Look up config entry by device id."""
current_entries = hass.config_entries.async_entries(DOMAIN)
for entry in current_entries:
if device_id in entry.data.get(CONF_DEVICES, []):
return entry
else:
_LOGGER.debug(f"Missing device configuration for device_id {device_id}")
return None
class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
"""Cache wrapper for pytuya.TuyaInterface."""
def __init__(self, hass, config_entry, dev_id):
"""Initialize the cache."""
super().__init__()
self._hass = hass
self._config_entry = config_entry
self._dev_config_entry = config_entry.data[CONF_DEVICES][dev_id].copy()
self._interface = None
self._status = {}
self.dps_to_request = {}
self._is_closing = False
self._connect_task = None
self._disconnect_task = None
self._unsub_interval = None
self._entities = []
self._local_key = self._dev_config_entry[CONF_LOCAL_KEY]
self._default_reset_dpids = None
if CONF_RESET_DPIDS in self._dev_config_entry:
reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",")
self._default_reset_dpids = []
for reset_id in reset_ids_str:
self._default_reset_dpids.append(int(reset_id.strip()))
self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d
for entity in self._dev_config_entry[CONF_ENTITIES]:
self.dps_to_request[entity[CONF_ID]] = None
def add_entities(self, entities):
"""Set the entities associated with this device."""
self._entities.extend(entities)
@property
def is_connecting(self):
"""Return whether device is currently connecting."""
return self._connect_task is not None
@property
def connected(self):
"""Return if connected to device."""
return self._interface is not None
def async_connect(self):
"""Connect to device if not already connected."""
# self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface)
if not self._is_closing and self._connect_task is None and not self._interface:
self._connect_task = asyncio.create_task(self._make_connection())
async def _make_connection(self):
"""Subscribe localtuya entity events."""
self.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST])
try:
self._interface = await pytuya.connect(
self._dev_config_entry[CONF_HOST],
self._dev_config_entry[CONF_DEVICE_ID],
self._local_key,
float(self._dev_config_entry[CONF_PROTOCOL_VERSION]),
self._dev_config_entry.get(CONF_ENABLE_DEBUG, False),
self,
)
self._interface.add_dps_to_request(self.dps_to_request)
except Exception as ex: # pylint: disable=broad-except
self.warning(
f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex
)
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
try:
try:
self.debug("Retrieving initial state")
status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self._interface.start_heartbeat()
self.status_updated(status)
except Exception as ex:
if (self._default_reset_dpids is not None) and (
len(self._default_reset_dpids) > 0
):
self.debug(
"Initial state update failed, trying reset command "
+ "for DP IDs: %s",
self._default_reset_dpids,
)
await self._interface.reset(self._default_reset_dpids)
self.debug("Update completed, retrying initial state")
status = await self._interface.status()
if status is None or not status:
raise Exception("Failed to retrieve status") from ex
self._interface.start_heartbeat()
self.status_updated(status)
else:
self.error("Initial state update failed, giving up: %r", ex)
if self._interface is not None:
await self._interface.close()
self._interface = None
except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex:
self.warning("Initial state update failed (%s), trying key update", ex)
await self.update_local_key()
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
# Attempt to restore status for all entities that need to first set
# the DPS value before the device will respond with status.
for entity in self._entities:
await entity.restore_state_when_connected()
def _new_entity_handler(entity_id):
self.debug(
"New entity %s was added to %s",
entity_id,
self._dev_config_entry[CONF_HOST],
)
self._dispatch_status()
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
self._disconnect_task = async_dispatcher_connect(
self._hass, signal, _new_entity_handler
)
if (
CONF_SCAN_INTERVAL in self._dev_config_entry
and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0
):
self._unsub_interval = async_track_time_interval(
self._hass,
self._async_refresh,
timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])),
)
self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}")
self._connect_task = None
async def update_local_key(self):
"""Retrieve updated local_key from Cloud API and update the config_entry."""
dev_id = self._dev_config_entry[CONF_DEVICE_ID]
await self._hass.data[DOMAIN][DATA_CLOUD].async_get_devices_list()
cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
new_data = self._config_entry.data.copy()
new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self._hass.config_entries.async_update_entry(
self._config_entry,
data=new_data,
)
self.info("local_key updated for device %s.", dev_id)
async def _async_refresh(self, _now):
if self._interface is not None:
await self._interface.update_dps()
async def close(self):
"""Close connection and stop re-connect loop."""
self._is_closing = True
if self._connect_task is not None:
self._connect_task.cancel()
await self._connect_task
if self._interface is not None:
await self._interface.close()
if self._disconnect_task is not None:
self._disconnect_task()
self.info(
"Closed connection with device %s.",
self._dev_config_entry[CONF_FRIENDLY_NAME],
)
async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device."""
if self._interface is not None:
try:
await self._interface.set_dp(state, dp_index)
except Exception: # pylint: disable=broad-except
self.exception("Failed to set DP %d to %s", dp_index, str(state))
else:
self.error(
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
)
async def set_dps(self, states):
"""Change value of a DPs of the Tuya device."""
if self._interface is not None:
try:
await self._interface.set_dps(states)
except Exception: # pylint: disable=broad-except
self.exception("Failed to set DPs %r", states)
else:
self.error(
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
)
@callback
def status_updated(self, status):
"""Device updated status."""
self._status.update(status)
self._dispatch_status()
def _dispatch_status(self):
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, self._status)
@callback
def disconnected(self):
"""Device disconnected."""
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, None)
if self._unsub_interval is not None:
self._unsub_interval()
self._unsub_interval = None
self._interface = None
if self._connect_task is not None:
self._connect_task.cancel()
self._connect_task = None
self.warning("Disconnected - waiting for discovery broadcast")
class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
"""Representation of a Tuya entity."""
def __init__(self, device, config_entry, dp_id, logger, **kwargs):
"""Initialize the Tuya entity."""
super().__init__()
self._device = device
self._dev_config_entry = config_entry
self._config = get_entity_config(config_entry, dp_id)
self._dp_id = dp_id
self._status = {}
self._state = None
self._last_state = None
# Default value is available to be provided by Platform entities if required
self._default_value = self._config.get(CONF_DEFAULT_VALUE)
# Determine whether is a passive entity
self._is_passive_entity = self._config.get(CONF_PASSIVE_ENTITY) or False
""" Restore on connect setting is available to be provided by Platform entities
if required"""
self._restore_on_reconnect = (
self._config.get(CONF_RESTORE_ON_RECONNECT) or False
)
self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID])
async def async_added_to_hass(self):
"""Subscribe localtuya events."""
await super().async_added_to_hass()
self.debug("Adding %s with configuration: %s", self.entity_id, self._config)
state = await self.async_get_last_state()
if state:
self.status_restored(state)
def _update_handler(status):
"""Update entity state when status was updated."""
if status is None:
status = {}
if self._status != status:
self._status = status.copy()
if status:
self.status_updated()
# Update HA
self.schedule_update_ha_state()
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, _update_handler)
)
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id)
@property
def extra_state_attributes(self):
"""Return entity specific state attributes to be saved.
These attributes are then available for restore when the
entity is restored at startup.
"""
attributes = {}
if self._state is not None:
attributes[ATTR_STATE] = self._state
elif self._last_state is not None:
attributes[ATTR_STATE] = self._last_state
self.debug("Entity %s - Additional attributes: %s", self.name, attributes)
return attributes
@property
def device_info(self):
"""Return device information for the device registry."""
model = self._dev_config_entry.get(CONF_MODEL, "Tuya generic")
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, f"local_{self._dev_config_entry[CONF_DEVICE_ID]}")
},
"name": self._dev_config_entry[CONF_FRIENDLY_NAME],
"manufacturer": "Tuya",
"model": f"{model} ({self._dev_config_entry[CONF_DEVICE_ID]})",
"sw_version": self._dev_config_entry[CONF_PROTOCOL_VERSION],
}
@property
def name(self):
"""Get name of Tuya entity."""
return self._config[CONF_FRIENDLY_NAME]
@property
def should_poll(self):
"""Return if platform should poll for updates."""
return False
@property
def unique_id(self):
"""Return unique device identifier."""
return f"local_{self._dev_config_entry[CONF_DEVICE_ID]}_{self._dp_id}"
def has_config(self, attr):
"""Return if a config parameter has a valid value."""
value = self._config.get(attr, "-1")
return value is not None and value != "-1"
@property
def available(self):
"""Return if device is available or not."""
return str(self._dp_id) in self._status
def dps(self, dp_index):
"""Return cached value for DPS index."""
value = self._status.get(str(dp_index))
if value is None:
self.warning(
"Entity %s is requesting unknown DPS index %s",
self.entity_id,
dp_index,
)
return value
def dps_conf(self, conf_item):
"""Return value of datapoint for user specified config item.
This method looks up which DP a certain config item uses based on
user configuration and returns its value.
"""
dp_index = self._config.get(conf_item)
if dp_index is None:
self.warning(
"Entity %s is requesting unset index for option %s",
self.entity_id,
conf_item,
)
return self.dps(dp_index)
def status_updated(self):
"""Device status was updated.
Override in subclasses and update entity specific state.
"""
state = self.dps(self._dp_id)
self._state = state
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (state is not None) and (not self._device.is_connecting):
self._last_state = state
def status_restored(self, stored_state):
"""Device status was restored.
Override in subclasses and update entity specific state.
"""
raw_state = stored_state.attributes.get(ATTR_STATE)
if raw_state is not None:
self._last_state = raw_state
self.debug(
"Restoring state for entity: %s - state: %s",
self.name,
str(self._last_state),
)
def default_value(self):
"""Return default value of this entity.
Override in subclasses to specify the default value for the entity.
"""
# Check if default value has been set - if not, default to the entity defaults.
if self._default_value is None:
self._default_value = self.entity_default_value()
return self._default_value
def entity_default_value(self): # pylint: disable=no-self-use
"""Return default value of the entity type.
Override in subclasses to specify the default value for the entity.
"""
return 0
@property
def restore_on_reconnect(self):
"""Return whether the last state should be restored on a reconnect.
Useful where the device loses settings if powered off
"""
return self._restore_on_reconnect
async def restore_state_when_connected(self):
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
Which indicates a DPS that needs to be set before it starts returning
status.
"""
if (not self.restore_on_reconnect) and (
(str(self._dp_id) in self._status) or (not self._is_passive_entity)
):
self.debug(
"Entity %s (DP %d) - Not restoring as restore on reconnect is "
+ "disabled for this entity and the entity has an initial status "
+ "or it is not a passive entity",
self.name,
self._dp_id,
)
return
self.debug("Attempting to restore state for entity: %s", self.name)
# Attempt to restore the current state - in case reset.
restore_state = self._state
# If no state stored in the entity currently, go from last saved state
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
self.debug("No current state for entity")
restore_state = self._last_state
# If no current or saved state, then use the default value
if restore_state is None:
if self._is_passive_entity:
self.debug("No last restored state - using default")
restore_state = self.default_value()
else:
self.debug("Not a passive entity and no state found - aborting restore")
return
self.debug(
"Entity %s (DP %d) - Restoring state: %s",
self.name,
self._dp_id,
str(restore_state),
)
# Manually initialise
await self._device.set_dp(restore_state, self._dp_id)

View File

@@ -0,0 +1,819 @@
"""Config flow for LocalTuya integration integration."""
import errno
import logging
import time
from importlib import import_module
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PLATFORM,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from homeassistant.core import callback
from .cloud_api import TuyaCloudApi
from .common import pytuya
from .const import (
ATTR_UPDATED_AT,
CONF_ACTION,
CONF_ADD_DEVICE,
CONF_DPS_STRINGS,
CONF_EDIT_DEVICE,
CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
CONF_MANUAL_DPS,
CONF_MODEL,
CONF_NO_CLOUD,
CONF_PRODUCT_NAME,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_SETUP_CLOUD,
CONF_USER_ID,
CONF_ENABLE_ADD_ENTITIES,
DATA_CLOUD,
DATA_DISCOVERY,
DOMAIN,
PLATFORMS,
)
from .discovery import discover
_LOGGER = logging.getLogger(__name__)
ENTRIES_VERSION = 2
PLATFORM_TO_ADD = "platform_to_add"
NO_ADDITIONAL_ENTITIES = "no_additional_entities"
SELECTED_DEVICE = "selected_device"
CUSTOM_DEVICE = "..."
CONF_ACTIONS = {
CONF_ADD_DEVICE: "Add a new device",
CONF_EDIT_DEVICE: "Edit a device",
CONF_SETUP_CLOUD: "Reconfigure Cloud API account",
}
CONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS),
}
)
CLOUD_SETUP_SCHEMA = vol.Schema(
{
vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]),
vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_USER_ID): cv.string,
vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string,
vol.Required(CONF_NO_CLOUD, default=False): bool,
}
)
DEVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
["3.1", "3.2", "3.3", "3.4"]
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
}
)
PICK_ENTITY_SCHEMA = vol.Schema(
{vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)}
)
def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True):
"""Create schema for devices step."""
devices = {}
for dev_id, dev_host in discovered_devices.items():
dev_name = dev_id
if dev_id in cloud_devices_list.keys():
dev_name = cloud_devices_list[dev_id][CONF_NAME]
devices[dev_id] = f"{dev_name} ({dev_host})"
if add_custom_device:
devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE})
# devices.update(
# {
# ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME]
# for ent in entries
# }
# )
return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)})
def options_schema(entities):
"""Create schema for options."""
entity_names = [
f"{entity[CONF_ID]}: {entity[CONF_FRIENDLY_NAME]}" for entity in entities
]
return vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
["3.1", "3.2", "3.3", "3.4"]
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool,
}
)
def schema_defaults(schema, dps_list=None, **defaults):
"""Create a new schema with default values filled in."""
copy = schema.extend({})
for field, field_type in copy.schema.items():
if isinstance(field_type, vol.In):
value = None
for dps in dps_list or []:
if dps.startswith(f"{defaults.get(field)} "):
value = dps
break
if value in field_type.container:
field.default = vol.default_factory(value)
continue
if field.schema in defaults:
field.default = vol.default_factory(defaults[field])
return copy
def dps_string_list(dps_data):
"""Return list of friendly DPS values."""
return [f"{id} (value: {value})" for id, value in dps_data.items()]
def gen_dps_strings():
"""Generate list of DPS values."""
return [f"{dp} (value: ?)" for dp in range(1, 256)]
def platform_schema(platform, dps_strings, allow_id=True, yaml=False):
"""Generate input validation schema for a platform."""
schema = {}
if yaml:
# In YAML mode we force the specified platform to match flow schema
schema[vol.Required(CONF_PLATFORM)] = vol.In([platform])
if allow_id:
schema[vol.Required(CONF_ID)] = vol.In(dps_strings)
schema[vol.Required(CONF_FRIENDLY_NAME)] = str
return vol.Schema(schema).extend(flow_schema(platform, dps_strings))
def flow_schema(platform, dps_strings):
"""Return flow schema for a specific platform."""
integration_module = ".".join(__name__.split(".")[:-1])
return import_module("." + platform, integration_module).flow_schema(dps_strings)
def strip_dps_values(user_input, dps_strings):
"""Remove values and keep only index for DPS config items."""
stripped = {}
for field, value in user_input.items():
if value in dps_strings:
stripped[field] = int(user_input[field].split(" ")[0])
else:
stripped[field] = user_input[field]
return stripped
def config_schema():
"""Build schema used for setting up component."""
entity_schemas = [
platform_schema(platform, range(1, 256), yaml=True) for platform in PLATFORMS
]
return vol.Schema(
{
DOMAIN: vol.All(
cv.ensure_list,
[
DEVICE_SCHEMA.extend(
{vol.Required(CONF_ENTITIES): [vol.Any(*entity_schemas)]}
)
],
)
},
extra=vol.ALLOW_EXTRA,
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
detected_dps = {}
interface = None
reset_ids = None
try:
interface = await pytuya.connect(
data[CONF_HOST],
data[CONF_DEVICE_ID],
data[CONF_LOCAL_KEY],
float(data[CONF_PROTOCOL_VERSION]),
data[CONF_ENABLE_DEBUG],
)
if CONF_RESET_DPIDS in data:
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
reset_ids = []
for reset_id in reset_ids_str:
reset_ids.append(int(reset_id.strip()))
_LOGGER.debug(
"Reset DPIDs configured: %s (%s)",
data[CONF_RESET_DPIDS],
reset_ids,
)
try:
detected_dps = await interface.detect_available_dps()
except Exception as ex:
try:
_LOGGER.debug(
"Initial state update failed (%s), trying reset command", ex
)
if len(reset_ids) > 0:
await interface.reset(reset_ids)
detected_dps = await interface.detect_available_dps()
except Exception as ex:
_LOGGER.debug("No DPS able to be detected: %s", ex)
detected_dps = {}
# if manual DPs are set, merge these.
_LOGGER.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
_LOGGER.debug(
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
)
# merge the lists
for new_dps in manual_dps_list + (reset_ids or []):
# If the DPS not in the detected dps list, then add with a
# default value indicating that it has been manually added
if str(new_dps) not in detected_dps:
detected_dps[new_dps] = -1
except (ConnectionRefusedError, ConnectionResetError) as ex:
raise CannotConnect from ex
except ValueError as ex:
raise InvalidAuth from ex
finally:
if interface:
await interface.close()
# Indicate an error if no datapoints found as the rest of the flow
# won't work in this case
if not detected_dps:
raise EmptyDpsList
_LOGGER.debug("Total DPS: %s", detected_dps)
return dps_string_list(detected_dps)
async def attempt_cloud_connection(hass, user_input):
"""Create device."""
cloud_api = TuyaCloudApi(
hass,
user_input.get(CONF_REGION),
user_input.get(CONF_CLIENT_ID),
user_input.get(CONF_CLIENT_SECRET),
user_input.get(CONF_USER_ID),
)
res = await cloud_api.async_get_access_token()
if res != "ok":
_LOGGER.error("Cloud API connection failed: %s", res)
return cloud_api, {"reason": "authentication_failed", "msg": res}
res = await cloud_api.async_get_devices_list()
if res != "ok":
_LOGGER.error("Cloud API get_devices_list failed: %s", res)
return cloud_api, {"reason": "device_list_failed", "msg": res}
_LOGGER.info("Cloud API connection succeeded.")
return cloud_api, {}
class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LocalTuya integration."""
VERSION = ENTRIES_VERSION
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get options flow for this handler."""
return LocalTuyaOptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize a new LocaltuyaConfigFlow."""
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
if user_input.get(CONF_NO_CLOUD):
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
user_input[i] = ""
return await self._create_entry(user_input)
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
if not res:
return await self._create_entry(user_input)
errors["base"] = res["reason"]
placeholders = {"msg": res["msg"]}
defaults = {}
defaults.update(user_input or {})
return self.async_show_form(
step_id="user",
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def _create_entry(self, user_input):
"""Register new entry."""
# if self._async_current_entries():
# return self.async_abort(reason="already_configured")
await self.async_set_unique_id(user_input.get(CONF_USER_ID))
user_input[CONF_DEVICES] = {}
return self.async_create_entry(
title=user_input.get(CONF_USERNAME),
data=user_input,
)
async def async_step_import(self, user_input):
"""Handle import from YAML."""
_LOGGER.error(
"Configuration via YAML file is no longer supported by this integration."
)
class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for LocalTuya integration."""
def __init__(self, config_entry):
"""Initialize localtuya options flow."""
self._config_entry = config_entry
# self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings())
# self.entities = config_entry.data[CONF_ENTITIES]
self.selected_device = None
self.editing_device = False
self.device_data = None
self.dps_strings = []
self.selected_platform = None
self.discovered_devices = {}
self.entities = []
async def async_step_init(self, user_input=None):
"""Manage basic options."""
# device_id = self.config_entry.data[CONF_DEVICE_ID]
if user_input is not None:
if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD:
return await self.async_step_cloud_setup()
if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE:
return await self.async_step_add_device()
if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE:
return await self.async_step_edit_device()
return self.async_show_form(
step_id="init",
data_schema=CONFIGURE_SCHEMA,
)
async def async_step_cloud_setup(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
if user_input.get(CONF_NO_CLOUD):
new_data = self.config_entry.data.copy()
new_data.update(user_input)
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
new_data[i] = ""
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(
title=new_data.get(CONF_USERNAME), data={}
)
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
if not res:
new_data = self.config_entry.data.copy()
new_data.update(user_input)
cloud_devs = cloud_api.device_list
for dev_id, dev in new_data[CONF_DEVICES].items():
if CONF_MODEL not in dev and dev_id in cloud_devs:
model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME)
new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(
title=new_data.get(CONF_USERNAME), data={}
)
errors["base"] = res["reason"]
placeholders = {"msg": res["msg"]}
defaults = self.config_entry.data.copy()
defaults.update(user_input or {})
defaults[CONF_NO_CLOUD] = False
return self.async_show_form(
step_id="cloud_setup",
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def async_step_add_device(self, user_input=None):
"""Handle adding a new device."""
# Use cache if available or fallback to manual discovery
self.editing_device = False
self.selected_device = None
errors = {}
if user_input is not None:
if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE:
self.selected_device = user_input[SELECTED_DEVICE]
return await self.async_step_configure_device()
self.discovered_devices = {}
data = self.hass.data.get(DOMAIN)
if data and DATA_DISCOVERY in data:
self.discovered_devices = data[DATA_DISCOVERY].devices
else:
try:
self.discovered_devices = await discover()
except OSError as ex:
if ex.errno == errno.EADDRINUSE:
errors["base"] = "address_in_use"
else:
errors["base"] = "discovery_failed"
except Exception as ex:
_LOGGER.exception("discovery failed: %s", ex)
errors["base"] = "discovery_failed"
devices = {
dev_id: dev["ip"]
for dev_id, dev in self.discovered_devices.items()
if dev["gwId"] not in self.config_entry.data[CONF_DEVICES]
}
return self.async_show_form(
step_id="add_device",
data_schema=devices_schema(
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list
),
errors=errors,
)
async def async_step_edit_device(self, user_input=None):
"""Handle editing a device."""
self.editing_device = True
# Use cache if available or fallback to manual discovery
errors = {}
if user_input is not None:
self.selected_device = user_input[SELECTED_DEVICE]
dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device]
self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings())
self.entities = dev_conf[CONF_ENTITIES]
return await self.async_step_configure_device()
devices = {}
for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items():
devices[dev_id] = configured_dev[CONF_HOST]
return self.async_show_form(
step_id="edit_device",
data_schema=devices_schema(
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False
),
errors=errors,
)
async def async_step_configure_device(self, user_input=None):
"""Handle input of basic info."""
errors = {}
dev_id = self.selected_device
if user_input is not None:
try:
self.device_data = user_input.copy()
if dev_id is not None:
# self.device_data[CONF_PRODUCT_KEY] = self.devices[
# self.selected_device
# ]["productKey"]
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
self.device_data[CONF_MODEL] = cloud_devs[dev_id].get(
CONF_PRODUCT_NAME
)
if self.editing_device:
if user_input[CONF_ENABLE_ADD_ENTITIES]:
self.editing_device = False
user_input[CONF_DEVICE_ID] = dev_id
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_DPS_STRINGS: self.dps_strings,
}
)
return await self.async_step_pick_entity_type()
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: [],
}
)
if len(user_input[CONF_ENTITIES]) == 0:
return self.async_abort(
reason="no_entities",
description_placeholders={},
)
if user_input[CONF_ENTITIES]:
entity_ids = [
int(entity.split(":")[0])
for entity in user_input[CONF_ENTITIES]
]
device_config = self.config_entry.data[CONF_DEVICES][dev_id]
self.entities = [
entity
for entity in device_config[CONF_ENTITIES]
if entity[CONF_ID] in entity_ids
]
return await self.async_step_configure_entity()
self.dps_strings = await validate_input(self.hass, user_input)
return await self.async_step_pick_entity_type()
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except EmptyDpsList:
errors["base"] = "empty_dps"
except Exception as ex:
_LOGGER.exception("Unexpected exception: %s", ex)
errors["base"] = "unknown"
defaults = {}
if self.editing_device:
# If selected device exists as a config entry, load config from it
defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy()
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
placeholders = {"for_device": f" for device `{dev_id}`"}
if dev_id in cloud_devs:
cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
if defaults[CONF_LOCAL_KEY] != cloud_local_key:
_LOGGER.info(
"New local_key detected: new %s vs old %s",
cloud_local_key,
defaults[CONF_LOCAL_KEY],
)
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
note = "\nNOTE: a new local_key has been retrieved using cloud API"
placeholders = {"for_device": f" for device `{dev_id}`.{note}"}
defaults[CONF_ENABLE_ADD_ENTITIES] = False
schema = schema_defaults(options_schema(self.entities), **defaults)
else:
defaults[CONF_PROTOCOL_VERSION] = "3.3"
defaults[CONF_HOST] = ""
defaults[CONF_DEVICE_ID] = ""
defaults[CONF_LOCAL_KEY] = ""
defaults[CONF_FRIENDLY_NAME] = ""
if dev_id is not None:
# Insert default values from discovery and cloud if present
device = self.discovered_devices[dev_id]
defaults[CONF_HOST] = device.get("ip")
defaults[CONF_DEVICE_ID] = device.get("gwId")
defaults[CONF_PROTOCOL_VERSION] = device.get("version")
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME)
schema = schema_defaults(DEVICE_SCHEMA, **defaults)
placeholders = {"for_device": ""}
return self.async_show_form(
step_id="configure_device",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
async def async_step_pick_entity_type(self, user_input=None):
"""Handle asking if user wants to add another entity."""
if user_input is not None:
if user_input.get(NO_ADDITIONAL_ENTITIES):
config = {
**self.device_data,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: self.entities,
}
dev_id = self.device_data.get(CONF_DEVICE_ID)
new_data = self.config_entry.data.copy()
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
new_data[CONF_DEVICES].update({dev_id: config})
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
self.selected_platform = user_input[PLATFORM_TO_ADD]
return await self.async_step_configure_entity()
# Add a checkbox that allows bailing out from config flow if at least one
# entity has been added
schema = PICK_ENTITY_SCHEMA
if self.selected_platform is not None:
schema = schema.extend(
{vol.Required(NO_ADDITIONAL_ENTITIES, default=True): bool}
)
return self.async_show_form(step_id="pick_entity_type", data_schema=schema)
def available_dps_strings(self):
"""Return list of DPs use by the device's entities."""
available_dps = []
used_dps = [str(entity[CONF_ID]) for entity in self.entities]
for dp_string in self.dps_strings:
dp = dp_string.split(" ")[0]
if dp not in used_dps:
available_dps.append(dp_string)
return available_dps
async def async_step_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
entity = strip_dps_values(user_input, self.dps_strings)
entity[CONF_ID] = self.current_entity[CONF_ID]
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
self.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
self.hass.config_entries.async_update_entry(
self.config_entry,
title=self.device_data[CONF_FRIENDLY_NAME],
data=self.device_data,
)
return self.async_create_entry(title="", data={})
schema = platform_schema(
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
)
return self.async_show_form(
step_id="entity",
errors=errors,
data_schema=schema_defaults(
schema, self.dps_strings, **self.current_entity
),
description_placeholders={
"id": self.current_entity[CONF_ID],
"platform": self.current_entity[CONF_PLATFORM],
},
)
async def async_step_configure_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
if self.editing_device:
entity = strip_dps_values(user_input, self.dps_strings)
entity[CONF_ID] = self.current_entity[CONF_ID]
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
self.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
# finished editing device. Let's store the new config entry....
dev_id = self.device_data[CONF_DEVICE_ID]
new_data = self.config_entry.data.copy()
entry_id = self.config_entry.entry_id
# removing entities from registry (they will be recreated)
ent_reg = er.async_get(self.hass)
reg_entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry_id)
if dev_id in ent.unique_id
}
for entity_id in reg_entities.values():
ent_reg.async_remove(entity_id)
new_data[CONF_DEVICES][dev_id] = self.device_data
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
else:
user_input[CONF_PLATFORM] = self.selected_platform
self.entities.append(strip_dps_values(user_input, self.dps_strings))
# new entity added. Let's check if there are more left...
user_input = None
if len(self.available_dps_strings()) == 0:
user_input = {NO_ADDITIONAL_ENTITIES: True}
return await self.async_step_pick_entity_type(user_input)
if self.editing_device:
schema = platform_schema(
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
)
schema = schema_defaults(schema, self.dps_strings, **self.current_entity)
placeholders = {
"entity": f"entity with DP {self.current_entity[CONF_ID]}",
"platform": self.current_entity[CONF_PLATFORM],
}
else:
available_dps = self.available_dps_strings()
schema = platform_schema(self.selected_platform, available_dps)
placeholders = {
"entity": "an entity",
"platform": self.selected_platform,
}
return self.async_show_form(
step_id="configure_entity",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
async def async_step_yaml_import(self, user_input=None):
"""Manage YAML imports."""
_LOGGER.error(
"Configuration via YAML file is no longer supported by this integration."
)
# if user_input is not None:
# return self.async_create_entry(title="", data={})
# return self.async_show_form(step_id="yaml_import")
@property
def current_entity(self):
"""Existing configuration for entity currently being edited."""
return self.entities[len(self.device_data[CONF_ENTITIES])]
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
class EmptyDpsList(exceptions.HomeAssistantError):
"""Error to indicate no datapoints found."""

View File

@@ -0,0 +1,143 @@
"""Constants for localtuya integration."""
DOMAIN = "localtuya"
DATA_DISCOVERY = "discovery"
DATA_CLOUD = "cloud_data"
# Platforms in this list must support config flows
PLATFORMS = [
"binary_sensor",
"climate",
"cover",
"fan",
"light",
"number",
"select",
"sensor",
"switch",
"vacuum",
]
TUYA_DEVICES = "tuya_devices"
ATTR_CURRENT = "current"
ATTR_CURRENT_CONSUMPTION = "current_consumption"
ATTR_VOLTAGE = "voltage"
ATTR_UPDATED_AT = "updated_at"
# config flow
CONF_LOCAL_KEY = "local_key"
CONF_ENABLE_DEBUG = "enable_debug"
CONF_PROTOCOL_VERSION = "protocol_version"
CONF_DPS_STRINGS = "dps_strings"
CONF_MODEL = "model"
CONF_PRODUCT_KEY = "product_key"
CONF_PRODUCT_NAME = "product_name"
CONF_USER_ID = "user_id"
CONF_ENABLE_ADD_ENTITIES = "add_entities"
CONF_ACTION = "action"
CONF_ADD_DEVICE = "add_device"
CONF_EDIT_DEVICE = "edit_device"
CONF_SETUP_CLOUD = "setup_cloud"
CONF_NO_CLOUD = "no_cloud"
CONF_MANUAL_DPS = "manual_dps_strings"
CONF_DEFAULT_VALUE = "dps_default_value"
CONF_RESET_DPIDS = "reset_dpids"
CONF_PASSIVE_ENTITY = "is_passive_entity"
# light
CONF_BRIGHTNESS_LOWER = "brightness_lower"
CONF_BRIGHTNESS_UPPER = "brightness_upper"
CONF_COLOR = "color"
CONF_COLOR_MODE = "color_mode"
CONF_COLOR_MODE_SET = "color_mode_set"
CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin"
CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin"
CONF_COLOR_TEMP_REVERSE = "color_temp_reverse"
CONF_MUSIC_MODE = "music_mode"
# switch
CONF_CURRENT = "current"
CONF_CURRENT_CONSUMPTION = "current_consumption"
CONF_VOLTAGE = "voltage"
# cover
CONF_COMMANDS_SET = "commands_set"
CONF_POSITIONING_MODE = "positioning_mode"
CONF_CURRENT_POSITION_DP = "current_position_dp"
CONF_SET_POSITION_DP = "set_position_dp"
CONF_POSITION_INVERTED = "position_inverted"
CONF_SPAN_TIME = "span_time"
# fan
CONF_FAN_SPEED_CONTROL = "fan_speed_control"
CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control"
CONF_FAN_SPEED_MIN = "fan_speed_min"
CONF_FAN_SPEED_MAX = "fan_speed_max"
CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list"
CONF_FAN_DIRECTION = "fan_direction"
CONF_FAN_DIRECTION_FWD = "fan_direction_forward"
CONF_FAN_DIRECTION_REV = "fan_direction_reverse"
CONF_FAN_DPS_TYPE = "fan_dps_type"
# sensor
CONF_SCALING = "scaling"
# climate
CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp"
CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp"
CONF_TEMPERATURE_STEP = "temperature_step"
CONF_MAX_TEMP_DP = "max_temperature_dp"
CONF_MIN_TEMP_DP = "min_temperature_dp"
CONF_TEMP_MAX = "max_temperature_const"
CONF_TEMP_MIN = "min_temperature_const"
CONF_PRECISION = "precision"
CONF_TARGET_PRECISION = "target_precision"
CONF_HVAC_MODE_DP = "hvac_mode_dp"
CONF_HVAC_MODE_SET = "hvac_mode_set"
CONF_HVAC_FAN_MODE_DP = "hvac_fan_mode_dp"
CONF_HVAC_FAN_MODE_SET = "hvac_fan_mode_set"
CONF_HVAC_SWING_MODE_DP = "hvac_swing_mode_dp"
CONF_HVAC_SWING_MODE_SET = "hvac_swing_mode_set"
CONF_PRESET_DP = "preset_dp"
CONF_PRESET_SET = "preset_set"
CONF_HEURISTIC_ACTION = "heuristic_action"
CONF_HVAC_ACTION_DP = "hvac_action_dp"
CONF_HVAC_ACTION_SET = "hvac_action_set"
CONF_ECO_DP = "eco_dp"
CONF_ECO_VALUE = "eco_value"
# vacuum
CONF_POWERGO_DP = "powergo_dp"
CONF_IDLE_STATUS_VALUE = "idle_status_value"
CONF_RETURNING_STATUS_VALUE = "returning_status_value"
CONF_DOCKED_STATUS_VALUE = "docked_status_value"
CONF_BATTERY_DP = "battery_dp"
CONF_MODE_DP = "mode_dp"
CONF_MODES = "modes"
CONF_FAN_SPEED_DP = "fan_speed_dp"
CONF_FAN_SPEEDS = "fan_speeds"
CONF_CLEAN_TIME_DP = "clean_time_dp"
CONF_CLEAN_AREA_DP = "clean_area_dp"
CONF_CLEAN_RECORD_DP = "clean_record_dp"
CONF_LOCATE_DP = "locate_dp"
CONF_FAULT_DP = "fault_dp"
CONF_PAUSED_STATE = "paused_state"
CONF_RETURN_MODE = "return_mode"
CONF_STOP_STATUS = "stop_status"
# number
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
CONF_STEPSIZE_VALUE = "step_size"
# select
CONF_OPTIONS = "select_options"
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
# States
ATTR_STATE = "raw_state"
CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect"

View File

@@ -0,0 +1,233 @@
"""Platform to locally control Tuya-based cover devices."""
import asyncio
import logging
import time
from functools import partial
import voluptuous as vol
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN,
CoverEntity, CoverEntityFeature,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_COMMANDS_SET,
CONF_CURRENT_POSITION_DP,
CONF_POSITION_INVERTED,
CONF_POSITIONING_MODE,
CONF_SET_POSITION_DP,
CONF_SPAN_TIME,
)
_LOGGER = logging.getLogger(__name__)
COVER_ONOFF_CMDS = "on_off_stop"
COVER_OPENCLOSE_CMDS = "open_close_stop"
COVER_FZZZ_CMDS = "fz_zz_stop"
COVER_12_CMDS = "1_2_3"
COVER_MODE_NONE = "none"
COVER_MODE_POSITION = "position"
COVER_MODE_TIMED = "timed"
COVER_TIMEOUT_TOLERANCE = 3.0
DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS
DEFAULT_POSITIONING_MODE = COVER_MODE_NONE
DEFAULT_SPAN_TIME = 25.0
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_COMMANDS_SET): vol.In(
[COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS]
),
vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In(
[COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED]
),
vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps),
vol.Optional(CONF_SET_POSITION_DP): vol.In(dps),
vol.Optional(CONF_POSITION_INVERTED, default=False): bool,
vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All(
vol.Coerce(float), vol.Range(min=1.0, max=300.0)
),
}
class LocaltuyaCover(LocalTuyaEntity, CoverEntity):
"""Tuya cover device."""
def __init__(self, device, config_entry, switchid, **kwargs):
"""Initialize a new LocaltuyaCover."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
commands_set = DEFAULT_COMMANDS_SET
if self.has_config(CONF_COMMANDS_SET):
commands_set = self._config[CONF_COMMANDS_SET]
self._open_cmd = commands_set.split("_")[0]
self._close_cmd = commands_set.split("_")[1]
self._stop_cmd = commands_set.split("_")[2]
self._timer_start = time.time()
self._state = self._stop_cmd
self._previous_state = self._state
self._current_cover_position = 0
_LOGGER.debug("Initialized cover [%s]", self.name)
@property
def supported_features(self):
"""Flag supported features."""
supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE:
supported_features = supported_features | CoverEntityFeature.SET_POSITION
return supported_features
@property
def current_cover_position(self):
"""Return current cover position in percent."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
return None
return self._current_cover_position
@property
def is_opening(self):
"""Return if cover is opening."""
state = self._state
return state == self._open_cmd
@property
def is_closing(self):
"""Return if cover is closing."""
state = self._state
return state == self._close_cmd
@property
def is_closed(self):
"""Return if the cover is closed or not."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
return False
if self._current_cover_position == 0:
return True
if self._current_cover_position == 100:
return False
return False
async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
self.debug("Setting cover position: %r", kwargs[ATTR_POSITION])
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
newpos = float(kwargs[ATTR_POSITION])
currpos = self.current_cover_position
posdiff = abs(newpos - currpos)
mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME]
if newpos > currpos:
self.debug("Opening to %f: delay %f", newpos, mydelay)
await self.async_open_cover()
else:
self.debug("Closing to %f: delay %f", newpos, mydelay)
await self.async_close_cover()
self.hass.async_create_task(self.async_stop_after_timeout(mydelay))
self.debug("Done")
elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION:
converted_position = int(kwargs[ATTR_POSITION])
if self._config[CONF_POSITION_INVERTED]:
converted_position = 100 - converted_position
if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP):
await self._device.set_dp(
converted_position, self._config[CONF_SET_POSITION_DP]
)
async def async_stop_after_timeout(self, delay_sec):
"""Stop the cover if timeout (max movement span) occurred."""
await asyncio.sleep(delay_sec)
await self.async_stop_cover()
async def async_open_cover(self, **kwargs):
"""Open the cover."""
self.debug("Launching command %s to cover ", self._open_cmd)
await self._device.set_dp(self._open_cmd, self._dp_id)
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
# for timed positioning, stop the cover after a full opening timespan
# instead of waiting the internal timeout
self.hass.async_create_task(
self.async_stop_after_timeout(
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
)
)
async def async_close_cover(self, **kwargs):
"""Close cover."""
self.debug("Launching command %s to cover ", self._close_cmd)
await self._device.set_dp(self._close_cmd, self._dp_id)
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
# for timed positioning, stop the cover after a full opening timespan
# instead of waiting the internal timeout
self.hass.async_create_task(
self.async_stop_after_timeout(
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
)
)
async def async_stop_cover(self, **kwargs):
"""Stop the cover."""
self.debug("Launching command %s to cover ", self._stop_cmd)
await self._device.set_dp(self._stop_cmd, self._dp_id)
def status_restored(self, stored_state):
"""Restore the last stored cover status."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
stored_pos = stored_state.attributes.get("current_position")
if stored_pos is not None:
self._current_cover_position = stored_pos
self.debug("Restored cover position %s", self._current_cover_position)
def status_updated(self):
"""Device status was updated."""
self._previous_state = self._state
self._state = self.dps(self._dp_id)
if self._state.isupper():
self._open_cmd = self._open_cmd.upper()
self._close_cmd = self._close_cmd.upper()
self._stop_cmd = self._stop_cmd.upper()
if self.has_config(CONF_CURRENT_POSITION_DP):
curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP)
if self._config[CONF_POSITION_INVERTED]:
self._current_cover_position = 100 - curr_pos
else:
self._current_cover_position = curr_pos
if (
self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED
and self._state != self._previous_state
):
if self._previous_state != self._stop_cmd:
# the state has changed, and the cover was moving
time_diff = time.time() - self._timer_start
pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0)
if self._previous_state == self._close_cmd:
pos_diff = -pos_diff
self._current_cover_position = min(
100, max(0, self._current_cover_position + pos_diff)
)
change = "stopped" if self._state == self._stop_cmd else "inverted"
self.debug(
"Movement %s after %s sec., position difference %s",
change,
time_diff,
pos_diff,
)
# store the time of the last movement change
self._timer_start = time.time()
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (self._state is not None) and (not self._device.is_connecting):
self._last_state = self._state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema)

View File

@@ -0,0 +1,65 @@
"""Diagnostics support for LocalTuya."""
from __future__ import annotations
import copy
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN
CLOUD_DEVICES = "cloud_devices"
DEVICE_CONFIG = "device_config"
DEVICE_CLOUD_INFO = "device_cloud_info"
_LOGGER = logging.getLogger(__name__)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = {}
data = dict(entry.data)
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
# censoring private information on integration diagnostic data
for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
data[field] = f"{data[field][0:3]}...{data[field][-3:]}"
data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES])
for dev_id, dev in data[CONF_DEVICES].items():
local_key = dev[CONF_LOCAL_KEY]
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
dev[CONF_LOCAL_KEY] = local_key_obfuscated
data[CLOUD_DEVICES] = tuya_api.device_list
for dev_id, dev in data[CLOUD_DEVICES].items():
local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY]
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated
return data
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
data = {}
dev_id = list(device.identifiers)[0][1].split("_")[-1]
data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy()
# NOT censoring private information on device diagnostic data
# local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY]
# data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}"
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
if dev_id in tuya_api.device_list:
data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id]
# NOT censoring private information on device diagnostic data
# local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY]
# local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}"
# data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated
# data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log()
return data

View File

@@ -0,0 +1,90 @@
"""Discovery module for Tuya devices.
Entirely based on tuya-convert.py from tuya-convert:
https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py
"""
import asyncio
import json
import logging
from hashlib import md5
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__name__)
UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
DEFAULT_TIMEOUT = 6.0
def decrypt_udp(message):
"""Decrypt encrypted UDP broadcasts."""
def _unpad(data):
return data[: -ord(data[len(data) - 1 :])]
cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend())
decryptor = cipher.decryptor()
return _unpad(decryptor.update(message) + decryptor.finalize()).decode()
class TuyaDiscovery(asyncio.DatagramProtocol):
"""Datagram handler listening for Tuya broadcast messages."""
def __init__(self, callback=None):
"""Initialize a new BaseDiscovery."""
self.devices = {}
self._listeners = []
self._callback = callback
async def start(self):
"""Start discovery by listening to broadcasts."""
loop = asyncio.get_running_loop()
listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True
)
encrypted_listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True
)
self._listeners = await asyncio.gather(listener, encrypted_listener)
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
def close(self):
"""Stop discovery."""
self._callback = None
for transport, _ in self._listeners:
transport.close()
def datagram_received(self, data, addr):
"""Handle received broadcast message."""
data = data[20:-8]
try:
data = decrypt_udp(data)
except Exception: # pylint: disable=broad-except
data = data.decode()
decoded = json.loads(data)
self.device_found(decoded)
def device_found(self, device):
"""Discover a new device."""
if device.get("gwId") not in self.devices:
self.devices[device.get("gwId")] = device
_LOGGER.debug("Discovered device: %s", device)
if self._callback:
self._callback(device)
async def discover():
"""Discover and return devices on local network."""
discovery = TuyaDiscovery()
try:
await discovery.start()
await asyncio.sleep(DEFAULT_TIMEOUT)
finally:
discovery.close()
return discovery.devices

View File

@@ -0,0 +1,259 @@
"""Platform to locally control Tuya-based fan devices."""
import logging
import math
from functools import partial
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components.fan import (
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN,
FanEntityFeature,
FanEntity,
)
from homeassistant.util.percentage import (
int_states_in_range,
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_FAN_DIRECTION,
CONF_FAN_DIRECTION_FWD,
CONF_FAN_DIRECTION_REV,
CONF_FAN_DPS_TYPE,
CONF_FAN_ORDERED_LIST,
CONF_FAN_OSCILLATING_CONTROL,
CONF_FAN_SPEED_CONTROL,
CONF_FAN_SPEED_MAX,
CONF_FAN_SPEED_MIN,
)
_LOGGER = logging.getLogger(__name__)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps),
vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps),
vol.Optional(CONF_FAN_DIRECTION): vol.In(dps),
vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string,
vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string,
vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int,
vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int,
vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string,
vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]),
}
class LocaltuyaFan(LocalTuyaEntity, FanEntity):
"""Representation of a Tuya fan."""
def __init__(
self,
device,
config_entry,
fanid,
**kwargs,
):
"""Initialize the entity."""
super().__init__(device, config_entry, fanid, _LOGGER, **kwargs)
self._is_on = False
self._oscillating = None
self._direction = None
self._percentage = None
self._speed_range = (
self._config.get(CONF_FAN_SPEED_MIN),
self._config.get(CONF_FAN_SPEED_MAX),
)
self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",")
self._ordered_list_mode = None
self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str
if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1:
self._use_ordered_list = True
_LOGGER.debug(
"Fan _use_ordered_list: %s > %s",
self._use_ordered_list,
self._ordered_list,
)
else:
self._use_ordered_list = False
_LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list)
@property
def oscillating(self):
"""Return current oscillating status."""
return self._oscillating
@property
def current_direction(self):
"""Return the current direction of the fan."""
return self._direction
@property
def is_on(self):
"""Check if Tuya fan is on."""
return self._is_on
@property
def percentage(self):
"""Return the current percentage."""
return self._percentage
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
preset_mode: str = None,
**kwargs,
) -> None:
"""Turn on the entity."""
_LOGGER.debug("Fan async_turn_on")
await self._device.set_dp(True, self._dp_id)
if percentage is not None:
await self.async_set_percentage(percentage)
else:
self.schedule_update_ha_state()
async def async_turn_off(self, **kwargs) -> None:
"""Turn off the entity."""
_LOGGER.debug("Fan async_turn_off")
await self._device.set_dp(False, self._dp_id)
self.schedule_update_ha_state()
async def async_set_percentage(self, percentage):
"""Set the speed of the fan."""
_LOGGER.debug("Fan async_set_percentage: %s", percentage)
if percentage is not None:
if percentage == 0:
return await self.async_turn_off()
if not self.is_on:
await self.async_turn_on()
if self._use_ordered_list:
await self._device.set_dp(
self._dps_type(
percentage_to_ordered_list_item(self._ordered_list, percentage)
),
self._config.get(CONF_FAN_SPEED_CONTROL),
)
_LOGGER.debug(
"Fan async_set_percentage: %s > %s",
percentage,
percentage_to_ordered_list_item(self._ordered_list, percentage),
)
else:
await self._device.set_dp(
self._dps_type(
math.ceil(
percentage_to_ranged_value(self._speed_range, percentage)
)
),
self._config.get(CONF_FAN_SPEED_CONTROL),
)
_LOGGER.debug(
"Fan async_set_percentage: %s > %s",
percentage,
percentage_to_ranged_value(self._speed_range, percentage),
)
self.schedule_update_ha_state()
async def async_oscillate(self, oscillating: bool) -> None:
"""Set oscillation."""
_LOGGER.debug("Fan async_oscillate: %s", oscillating)
await self._device.set_dp(
oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL)
)
self.schedule_update_ha_state()
async def async_set_direction(self, direction):
"""Set the direction of the fan."""
_LOGGER.debug("Fan async_set_direction: %s", direction)
if direction == DIRECTION_FORWARD:
value = self._config.get(CONF_FAN_DIRECTION_FWD)
if direction == DIRECTION_REVERSE:
value = self._config.get(CONF_FAN_DIRECTION_REV)
await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION))
self.schedule_update_ha_state()
@property
def supported_features(self) -> FanEntityFeature:
"""Flag supported features."""
features = FanEntityFeature(0)
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
features |= FanEntityFeature.OSCILLATE
if self.has_config(CONF_FAN_SPEED_CONTROL):
features |= FanEntityFeature.SET_SPEED
if self.has_config(CONF_FAN_DIRECTION):
features |= FanEntityFeature.DIRECTION
features |= FanEntityFeature.TURN_OFF
features |= FanEntityFeature.TURN_ON
return features
@property
def speed_count(self) -> int:
"""Speed count for the fan."""
speed_count = int_states_in_range(self._speed_range)
_LOGGER.debug("Fan speed_count: %s", speed_count)
return speed_count
def status_updated(self):
"""Get state of Tuya fan."""
self._is_on = self.dps(self._dp_id)
current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL)
if self._use_ordered_list:
_LOGGER.debug(
"Fan current_speed ordered_list_item_to_percentage: %s from %s",
current_speed,
self._ordered_list,
)
if current_speed is not None:
self._percentage = ordered_list_item_to_percentage(
self._ordered_list, str(current_speed)
)
else:
_LOGGER.debug(
"Fan current_speed ranged_value_to_percentage: %s from %s",
current_speed,
self._speed_range,
)
if current_speed is not None:
self._percentage = ranged_value_to_percentage(
self._speed_range, int(current_speed)
)
_LOGGER.debug("Fan current_percentage: %s", self._percentage)
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL)
_LOGGER.debug("Fan current_oscillating : %s", self._oscillating)
if self.has_config(CONF_FAN_DIRECTION):
value = self.dps_conf(CONF_FAN_DIRECTION)
if value is not None:
if value == self._config.get(CONF_FAN_DIRECTION_FWD):
self._direction = DIRECTION_FORWARD
if value == self._config.get(CONF_FAN_DIRECTION_REV):
self._direction = DIRECTION_REVERSE
_LOGGER.debug("Fan current_direction : %s > %s", value, self._direction)
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema)

View File

@@ -0,0 +1,506 @@
"""Platform to locally control Tuya-based light devices."""
import logging
import textwrap
from dataclasses import dataclass
from functools import partial
import homeassistant.util.color as color_util
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
ATTR_HS_COLOR,
DOMAIN,
LightEntity,
LightEntityFeature,
ColorMode,
)
from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_BRIGHTNESS_LOWER,
CONF_BRIGHTNESS_UPPER,
CONF_COLOR,
CONF_COLOR_MODE,
CONF_COLOR_TEMP_MAX_KELVIN,
CONF_COLOR_TEMP_MIN_KELVIN,
CONF_COLOR_TEMP_REVERSE,
CONF_MUSIC_MODE, CONF_COLOR_MODE_SET,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_MIN_KELVIN = 2700 # MIRED 370
DEFAULT_MAX_KELVIN = 6500 # MIRED 153
DEFAULT_COLOR_TEMP_REVERSE = False
DEFAULT_LOWER_BRIGHTNESS = 29
DEFAULT_UPPER_BRIGHTNESS = 1000
MODE_MANUAL = "manual"
MODE_COLOR = "colour"
MODE_MUSIC = "music"
MODE_SCENE = "scene"
MODE_WHITE = "white"
SCENE_CUSTOM = "Custom"
SCENE_MUSIC = "Music"
MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1}
SCENE_LIST_RGBW_1000 = {
"Night": "000e0d0000000000000000c80000",
"Read": "010e0d0000000000000003e801f4",
"Meeting": "020e0d0000000000000003e803e8",
"Leasure": "030e0d0000000000000001f401f4",
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
"Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803"
+ "e800000000",
"Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8"
+ "00000000",
"Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8"
+ "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80"
+ "3e800000000",
}
SCENE_LIST_RGBW_255 = {
"Night": "bd76000168ffff",
"Read": "fffcf70168ffff",
"Meeting": "cf38000168ffff",
"Leasure": "3855b40168ffff",
"Scenario 1": "scene_1",
"Scenario 2": "scene_2",
"Scenario 3": "scene_3",
"Scenario 4": "scene_4",
}
SCENE_LIST_RGB_1000 = {
"Night": "000e0d00002e03e802cc00000000",
"Read": "010e0d000084000003e800000000",
"Working": "020e0d00001403e803e800000000",
"Leisure": "030e0d0000e80383031c00000000",
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
"Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80"
+ "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803"
+ "e800000000",
"Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80"
+ "3e800000000",
"Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8"
+ "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80"
+ "0000000",
}
@dataclass(frozen=True)
class Mode:
color: str = MODE_COLOR
music: str = MODE_MUSIC
scene: str = MODE_SCENE
white: str = MODE_WHITE
def as_list(self) -> list:
return [self.color, self.music, self.scene, self.white]
def as_dict(self) -> dict[str, str]:
default = {"Default": self.white}
return {**default, "Mode Color": self.color, "Mode Scene": self.scene}
MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)}
def map_range(value, from_lower, from_upper, to_lower, to_upper):
"""Map a value in one range to another."""
mapped = (value - from_lower) * (to_upper - to_lower) / (
from_upper - from_lower
) + to_lower
return round(min(max(mapped, to_lower), to_upper))
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_BRIGHTNESS): vol.In(dps),
vol.Optional(CONF_COLOR_TEMP): vol.In(dps),
vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=10000)
),
vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=10000)
),
vol.Optional(CONF_COLOR_MODE): vol.In(dps),
vol.Optional(CONF_COLOR): vol.In(dps),
vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=8000)
),
vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=8000)
),
vol.Optional(
CONF_COLOR_TEMP_REVERSE,
default=DEFAULT_COLOR_TEMP_REVERSE,
description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE},
): bool,
vol.Optional(CONF_SCENE): vol.In(dps),
vol.Optional(
CONF_MUSIC_MODE, default=False, description={"suggested_value": False}
): bool,
}
class LocaltuyaLight(LocalTuyaEntity, LightEntity):
"""Representation of a Tuya light."""
def __init__(
self,
device,
config_entry,
lightid,
**kwargs,
):
"""Initialize the Tuya light."""
super().__init__(device, config_entry, lightid, _LOGGER, **kwargs)
self._state = False
self._brightness = None
self._color_temp = None
self._lower_brightness = self._config.get(
CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS
)
self._upper_brightness = self._config.get(
CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS
)
self._upper_color_temp = self._upper_brightness
self._max_mired = color_util.color_temperature_kelvin_to_mired(
self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._min_mired = color_util.color_temperature_kelvin_to_mired(
self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
self._color_temp_reverse = self._config.get(
CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE
)
self._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))]
self._hs = None
self._effect = None
self._effect_list = []
self._scenes = {}
if self.has_config(CONF_SCENE):
if self._config.get(CONF_SCENE) < 20:
self._scenes = SCENE_LIST_RGBW_255
elif self._config.get(CONF_BRIGHTNESS) is None:
self._scenes = SCENE_LIST_RGB_1000
else:
self._scenes = SCENE_LIST_RGBW_1000
self._effect_list = list(self._scenes.keys())
if self._config.get(CONF_MUSIC_MODE):
self._effect_list.append(SCENE_MUSIC)
@property
def is_on(self):
"""Check if Tuya light is on."""
return self._state
@property
def brightness(self):
"""Return the brightness of the light."""
if self.is_color_mode or self.is_white_mode:
return map_range(
self._brightness, self._lower_brightness, self._upper_brightness, 0, 255
)
return None
@property
def hs_color(self):
"""Return the hs color value."""
if self.is_color_mode:
return self._hs
if (
ColorMode.HS in self.supported_color_modes
and not ColorMode.COLOR_TEMP in self.supported_color_modes
):
return [0, 0]
return None
@property
def color_temp(self):
"""Return the color_temp of the light."""
if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode:
color_temp_value = (
self._upper_color_temp - self._color_temp
if self._color_temp_reverse
else self._color_temp
)
return int(
self._max_mired
- (
((self._max_mired - self._min_mired) / self._upper_color_temp)
* color_temp_value
)
)
return None
@property
def min_mireds(self):
"""Return color temperature min mireds."""
return self._min_mired
@property
def max_mireds(self):
"""Return color temperature max mireds."""
return self._max_mired
@property
def effect(self):
"""Return the current effect for this light."""
if self.is_scene_mode or self.is_music_mode:
return self._effect
return None
@property
def effect_list(self):
"""Return the list of supported effects for this light."""
if self.is_scene_mode or self.is_music_mode:
return self._effect
elif (color_mode := self.__get_color_mode()) in self._scenes.values():
return self.__find_scene_by_scene_data(color_mode)
return None
@property
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
"""Flag supported color modes."""
color_modes: set[ColorMode] = set()
if self.has_config(CONF_COLOR_TEMP):
color_modes.add(ColorMode.COLOR_TEMP)
if self.has_config(CONF_COLOR):
color_modes.add(ColorMode.HS)
if not color_modes and self.has_config(CONF_BRIGHTNESS):
return {ColorMode.BRIGHTNESS}
if not color_modes:
return {ColorMode.ONOFF}
return color_modes
@property
def supported_features(self) -> LightEntityFeature:
"""Flag supported features."""
supports = LightEntityFeature(0)
if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE):
supports |= LightEntityFeature.EFFECT
return supports
@property
def color_mode(self) -> ColorMode:
"""Return the color_mode of the light."""
if len(self.supported_color_modes) == 1:
return next(iter(self.supported_color_modes))
if self.is_color_mode:
return ColorMode.HS
if self.is_white_mode:
return ColorMode.COLOR_TEMP
if self._brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def is_white_mode(self):
"""Return true if the light is in white mode."""
color_mode = self.__get_color_mode()
return color_mode is None or color_mode == self._modes.white
@property
def is_color_mode(self):
"""Return true if the light is in color mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode == self._modes.color
@property
def is_scene_mode(self):
"""Return true if the light is in scene mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode.startswith(self._modes.scene)
@property
def is_music_mode(self):
"""Return true if the light is in music mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode == self._modes.music
def __is_color_rgb_encoded(self):
return len(self.dps_conf(CONF_COLOR)) > 12
def __find_scene_by_scene_data(self, data):
return next(
(item for item in self._effect_list if self._scenes.get(item) == data),
SCENE_CUSTOM,
)
def __get_color_mode(self):
return (
self.dps_conf(CONF_COLOR_MODE)
if self.has_config(CONF_COLOR_MODE)
else self._modes.white
)
async def async_turn_on(self, **kwargs):
"""Turn on or control the light."""
states = {}
if not self.is_on:
states[self._dp_id] = True
features = self.supported_features
brightness = None
if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT):
scene = self._scenes.get(kwargs[ATTR_EFFECT])
if scene is not None:
if scene.startswith(MODE_SCENE):
states[self._config.get(CONF_COLOR_MODE)] = scene
else:
states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE
states[self._config.get(CONF_SCENE)] = scene
elif kwargs[ATTR_EFFECT] == SCENE_MUSIC:
states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC
if ATTR_BRIGHTNESS in kwargs and (
ColorMode.BRIGHTNESS in self.supported_color_modes
or self.has_config(CONF_BRIGHTNESS)
or self.has_config(CONF_COLOR)
):
brightness = map_range(
int(kwargs[ATTR_BRIGHTNESS]),
0,
255,
self._lower_brightness,
self._upper_brightness,
)
if self.is_white_mode:
states[self._config.get(CONF_BRIGHTNESS)] = brightness
else:
if self.__is_color_rgb_encoded():
rgb = color_util.color_hsv_to_RGB(
self._hs[0],
self._hs[1],
int(brightness * 100 / self._upper_brightness),
)
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
round(rgb[0]),
round(rgb[1]),
round(rgb[2]),
round(self._hs[0]),
round(self._hs[1] * 255 / 100),
brightness,
)
else:
color = "{:04x}{:04x}{:04x}".format(
round(self._hs[0]), round(self._hs[1] * 10.0), brightness
)
states[self._config.get(CONF_COLOR)] = color
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
if ATTR_HS_COLOR in kwargs and ColorMode.HS in self.supported_color_modes:
if brightness is None:
brightness = self._brightness
hs = kwargs[ATTR_HS_COLOR]
if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS):
states[self._config.get(CONF_BRIGHTNESS)] = brightness
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
else:
if self.__is_color_rgb_encoded():
rgb = color_util.color_hsv_to_RGB(
hs[0], hs[1], int(brightness * 100 / self._upper_brightness)
)
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
round(rgb[0]),
round(rgb[1]),
round(rgb[2]),
round(hs[0]),
round(hs[1] * 255 / 100),
brightness,
)
else:
color = "{:04x}{:04x}{:04x}".format(
round(hs[0]), round(hs[1] * 10.0), brightness
)
states[self._config.get(CONF_COLOR)] = color
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
if ColorMode.COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in self.supported_color_modes:
if brightness is None:
brightness = self._brightness
mired = int(kwargs[ColorMode.COLOR_TEMP])
if self._color_temp_reverse:
mired = self._max_mired - (mired - self._min_mired)
if mired < self._min_mired:
mired = self._min_mired
elif mired > self._max_mired:
mired = self._max_mired
color_temp = int(
self._upper_color_temp
- (self._upper_color_temp / (self._max_mired - self._min_mired))
* (mired - self._min_mired)
)
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
states[self._config.get(CONF_BRIGHTNESS)] = brightness
states[self._config.get(CONF_COLOR_TEMP)] = color_temp
await self._device.set_dps(states)
async def async_turn_off(self, **kwargs):
"""Turn Tuya light off."""
await self._device.set_dp(False, self._dp_id)
def status_updated(self):
"""Device status was updated."""
self._state = self.dps(self._dp_id)
supported = self.supported_features
self._effect = None
if (ColorMode.BRIGHTNESS in self.supported_color_modes
or self.has_config(CONF_BRIGHTNESS)
or self.has_config(CONF_COLOR)
):
self._brightness = self.dps_conf(CONF_BRIGHTNESS)
if ColorMode.HS in self.supported_color_modes:
color = self.dps_conf(CONF_COLOR)
if color is not None and not self.is_white_mode:
if self.__is_color_rgb_encoded():
hue = int(color[6:10], 16)
sat = int(color[10:12], 16)
value = int(color[12:14], 16)
self._hs = [hue, (sat * 100 / 255)]
self._brightness = value
else:
hue, sat, value = [
int(value, 16) for value in textwrap.wrap(color, 4)
]
self._hs = [hue, sat / 10.0]
self._brightness = value
if ColorMode.COLOR_TEMP in self.supported_color_modes:
self._color_temp = self.dps_conf(CONF_COLOR_TEMP)
if self.is_scene_mode and supported & LightEntityFeature.EFFECT:
if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE:
self._effect = self.__find_scene_by_scene_data(
self.dps_conf(CONF_COLOR_MODE)
)
else:
self._effect = self.__find_scene_by_scene_data(
self.dps_conf(CONF_SCENE)
)
if self._effect == SCENE_CUSTOM:
if SCENE_CUSTOM not in self._effect_list:
self._effect_list.append(SCENE_CUSTOM)
elif SCENE_CUSTOM in self._effect_list:
self._effect_list.remove(SCENE_CUSTOM)
if self.is_music_mode and supported & LightEntityFeature.EFFECT:
self._effect = SCENE_MUSIC
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema)

View File

@@ -0,0 +1,14 @@
{
"domain": "localtuya",
"name": "LocalTuya integration",
"codeowners": [
"@rospogrigio", "@postlund"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/rospogrigio/localtuya/",
"iot_class": "local_push",
"issue_tracker": "https://github.com/rospogrigio/localtuya/issues",
"requirements": [],
"version": "5.2.3"
}

View File

@@ -0,0 +1,113 @@
"""Platform to present any Tuya DP as a number."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_DEFAULT_VALUE,
CONF_MAX_VALUE,
CONF_MIN_VALUE,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
CONF_STEPSIZE_VALUE,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_MIN = 0
DEFAULT_MAX = 100000
DEFAULT_STEP = 1.0
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All(
vol.Coerce(float),
vol.Range(min=0.0, max=1000000.0),
),
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
"""Representation of a Tuya Number."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._min_value = DEFAULT_MIN
if CONF_MIN_VALUE in self._config:
self._min_value = self._config.get(CONF_MIN_VALUE)
self._max_value = DEFAULT_MAX
if CONF_MAX_VALUE in self._config:
self._max_value = self._config.get(CONF_MAX_VALUE)
self._step_size = DEFAULT_STEP
if CONF_STEPSIZE_VALUE in self._config:
self._step_size = self._config.get(CONF_STEPSIZE_VALUE)
# Override standard default value handling to cast to a float
default_value = self._config.get(CONF_DEFAULT_VALUE)
if default_value is not None:
self._default_value = float(default_value)
@property
def native_value(self) -> float:
"""Return sensor state."""
return self._state
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
return self._min_value
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
return self._max_value
@property
def native_step(self) -> float:
"""Return the maximum value."""
return self._step_size
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await self._device.set_dp(value, self._dp_id)
# Default value is the minimum value
def entity_default_value(self):
"""Return the minimum value as the default for this entity type."""
return self._min_value
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
"""Platform to present any Tuya DP as an enumeration."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.select import DOMAIN, SelectEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_DEFAULT_VALUE,
CONF_OPTIONS,
CONF_OPTIONS_FRIENDLY,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_OPTIONS): str,
vol.Optional(CONF_OPTIONS_FRIENDLY): str,
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
_LOGGER = logging.getLogger(__name__)
class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
"""Representation of a Tuya Enumeration."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._state_friendly = ""
self._valid_options = self._config.get(CONF_OPTIONS).split(";")
# Set Display options
self._display_options = []
display_options_str = ""
if CONF_OPTIONS_FRIENDLY in self._config:
display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip()
_LOGGER.debug("Display Options Configured: %s", display_options_str)
if display_options_str.find(";") >= 0:
self._display_options = display_options_str.split(";")
elif len(display_options_str.strip()) > 0:
self._display_options.append(display_options_str)
else:
# Default display string to raw string
_LOGGER.debug("No Display options configured - defaulting to raw values")
self._display_options = self._valid_options
_LOGGER.debug(
"Total Raw Options: %s - Total Display Options: %s",
str(len(self._valid_options)),
str(len(self._display_options)),
)
if len(self._valid_options) > len(self._display_options):
# If list of display items smaller than list of valid items,
# then default remaining items to be the raw value
_LOGGER.debug(
"Valid options is larger than display options - \
filling up with raw values"
)
for i in range(len(self._display_options), len(self._valid_options)):
self._display_options.append(self._valid_options[i])
@property
def current_option(self) -> str:
"""Return the current value."""
return self._state_friendly
@property
def options(self) -> list:
"""Return the list of values."""
return self._display_options
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_select_option(self, option: str) -> None:
"""Update the current value."""
option_value = self._valid_options[self._display_options.index(option)]
_LOGGER.debug("Sending Option: " + option + " -> " + option_value)
await self._device.set_dp(option_value, self._dp_id)
def status_updated(self):
"""Device status was updated."""
super().status_updated()
state = self.dps(self._dp_id)
# Check that received status update for this entity.
if state is not None:
try:
self._state_friendly = self._display_options[
self._valid_options.index(state)
]
except Exception: # pylint: disable=broad-except
# Friendly value couldn't be mapped
self._state_friendly = state
# Default value is the first option
def entity_default_value(self):
"""Return the first option as the default value for this entity type."""
return self._valid_options[0]
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)

View File

@@ -0,0 +1,75 @@
"""Platform to present any Tuya DP as a sensor."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNKNOWN,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import CONF_SCALING
_LOGGER = logging.getLogger(__name__)
DEFAULT_PRECISION = 2
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES),
vol.Optional(CONF_SCALING): vol.All(
vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0)
),
}
class LocaltuyaSensor(LocalTuyaEntity):
"""Representation of a Tuya sensor."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
@property
def state(self):
"""Return sensor state."""
return self._state
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return self._config.get(CONF_UNIT_OF_MEASUREMENT)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
scale_factor = self._config.get(CONF_SCALING)
if scale_factor is not None and isinstance(state, (int, float)):
state = round(state * scale_factor, DEFAULT_PRECISION)
self._state = state
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema)

View File

@@ -0,0 +1,15 @@
reload:
description: Reload localtuya and reconnect to all devices.
set_dp:
description: Change the value of a datapoint (DP)
fields:
device_id:
description: Device ID of device to change datapoint value for
example: 11100118278aab4de001
dp:
description: Datapoint index
example: 1
value:
description: New value to set
example: False

View File

@@ -0,0 +1,139 @@
{
"config": {
"abort": {
"already_configured": "Device has already been configured.",
"unsupported_device_type": "Unsupported device type!"
},
"error": {
"cannot_connect": "Cannot connect to device. Verify that address is correct.",
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
"unknown": "An unknown error occurred. See log for details.",
"switch_already_configured": "Switch with this ID has already been configured."
},
"step": {
"user": {
"title": "Main Configuration",
"description": "Input the credentials for Tuya Cloud API.",
"data": {
"region": "API server region",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID"
}
},
"power_outlet": {
"title": "Add subswitch",
"description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.",
"data": {
"id": "ID",
"name": "Name",
"friendly_name": "Friendly name",
"current": "Current",
"current_consumption": "Current Consumption",
"voltage": "Voltage",
"add_another_switch": "Add another switch"
}
}
}
},
"options": {
"step": {
"init": {
"title": "LocalTuya Configuration",
"description": "Please select the desired actionSSSS.",
"data": {
"add_device": "Add a new device",
"edit_device": "Edit a device",
"delete_device": "Delete a device",
"setup_cloud": "Reconfigure Cloud API account"
}
},
"entity": {
"title": "Entity Configuration",
"description": "Editing entity with DPS `{id}` and platform `{platform}`.",
"data": {
"id": "ID",
"friendly_name": "Friendly name",
"current": "Current",
"current_consumption": "Current Consumption",
"voltage": "Voltage",
"commands_set": "Open_Close_Stop Commands Set",
"positioning_mode": "Positioning mode",
"current_position_dp": "Current Position (for *position* mode only)",
"set_position_dp": "Set Position (for *position* mode only)",
"position_inverted": "Invert 0-100 position (for *position* mode only)",
"span_time": "Full opening time, in secs. (for *timed* mode only)",
"unit_of_measurement": "Unit of Measurement",
"device_class": "Device Class",
"scaling": "Scaling Factor",
"state_on": "On Value",
"state_off": "Off Value",
"powergo_dp": "Power DP (Usually 25 or 2)",
"idle_status_value": "Idle Status (comma-separated)",
"returning_status_value": "Returning Status",
"docked_status_value": "Docked Status (comma-separated)",
"fault_dp": "Fault DP (Usually 11)",
"battery_dp": "Battery status DP (Usually 14)",
"mode_dp": "Mode DP (Usually 27)",
"modes": "Modes list",
"return_mode": "Return home mode",
"fan_speed_dp": "Fan speeds DP (Usually 30)",
"fan_speeds": "Fan speeds list (comma-separated)",
"clean_time_dp": "Clean Time DP (Usually 33)",
"clean_area_dp": "Clean Area DP (Usually 32)",
"clean_record_dp": "Clean Record DP (Usually 34)",
"locate_dp": "Locate DP (Usually 31)",
"paused_state": "Pause state (pause, paused, etc)",
"stop_status": "Stop status",
"brightness": "Brightness (only for white color)",
"brightness_lower": "Brightness Lower Value",
"brightness_upper": "Brightness Upper Value",
"color_temp": "Color Temperature",
"color_temp_reverse": "Color Temperature Reverse",
"color": "Color",
"color_mode": "Color Mode",
"color_temp_min_kelvin": "Minimum Color Temperature in K",
"color_temp_max_kelvin": "Maximum Color Temperature in K",
"music_mode": "Music mode available",
"scene": "Scene",
"fan_speed_control": "Fan Speed Control dps",
"fan_oscillating_control": "Fan Oscillating Control dps",
"fan_speed_min": "minimum fan speed integer",
"fan_speed_max": "maximum fan speed integer",
"fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
"fan_direction": "fan direction dps",
"fan_direction_forward": "forward dps string",
"fan_direction_reverse": "reverse dps string",
"fan_dps_type": "DP value type",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",
"max_temperature_dp": "Max Temperature (optional)",
"min_temperature_dp": "Min Temperature (optional)",
"precision": "Precision (optional, for DPs values)",
"target_precision": "Target Precision (optional, for DPs values)",
"temperature_unit": "Temperature Unit (optional)",
"hvac_mode_dp": "HVAC Mode DP (optional)",
"hvac_mode_set": "HVAC Mode Set (optional)",
"hvac_action_dp": "HVAC Current Action DP (optional)",
"hvac_action_set": "HVAC Current Action Set (optional)",
"preset_dp": "Presets DP (optional)",
"preset_set": "Presets Set (optional)",
"eco_dp": "Eco DP (optional)",
"eco_value": "Eco value (optional)",
"heuristic_action": "Enable heuristic action (optional)",
"dps_default_value": "Default value when un-initialised (optional)",
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
"min_value": "Minimum Value",
"max_value": "Maximum Value",
"step_size": "Minimum increment between numbers"
}
},
"yaml_import": {
"title": "Not Supported",
"description": "Options cannot be edited when configured via YAML."
}
}
},
"title": "LocalTuya"
}

View File

@@ -0,0 +1,91 @@
"""Platform to locally control Tuya-based switch devices."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.switch import DOMAIN, SwitchEntity
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
ATTR_CURRENT,
ATTR_CURRENT_CONSUMPTION,
ATTR_STATE,
ATTR_VOLTAGE,
CONF_CURRENT,
CONF_CURRENT_CONSUMPTION,
CONF_DEFAULT_VALUE,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
CONF_VOLTAGE,
)
_LOGGER = logging.getLogger(__name__)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_CURRENT): vol.In(dps),
vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps),
vol.Optional(CONF_VOLTAGE): vol.In(dps),
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity):
"""Representation of a Tuya switch."""
def __init__(
self,
device,
config_entry,
switchid,
**kwargs,
):
"""Initialize the Tuya switch."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
self._state = None
_LOGGER.debug("Initialized switch [%s]", self.name)
@property
def is_on(self):
"""Check if Tuya switch is on."""
return self._state
@property
def extra_state_attributes(self):
"""Return device state attributes."""
attrs = {}
if self.has_config(CONF_CURRENT):
attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT])
if self.has_config(CONF_CURRENT_CONSUMPTION):
attrs[ATTR_CURRENT_CONSUMPTION] = (
self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10
)
if self.has_config(CONF_VOLTAGE):
attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10
# Store the state
if self._state is not None:
attrs[ATTR_STATE] = self._state
elif self._last_state is not None:
attrs[ATTR_STATE] = self._last_state
return attrs
async def async_turn_on(self, **kwargs):
"""Turn Tuya switch on."""
await self._device.set_dp(True, self._dp_id)
async def async_turn_off(self, **kwargs):
"""Turn Tuya switch off."""
await self._device.set_dp(False, self._dp_id)
# Default value is the "OFF" state
def entity_default_value(self):
"""Return False as the default value for this entity type."""
return False
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema)

Some files were not shown because too many files have changed in this diff Show More