Initialize docker stack repo
This commit is contained in:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# /srv/docker
|
||||
|
||||
Единый репозиторий docker‑стеков сервера.
|
||||
|
||||
## Содержимое
|
||||
- `bitwarden/` — vaultwarden
|
||||
- `gitlab/` — GitLab CE (self‑hosted)
|
||||
- `homeassistant/` — HA
|
||||
- `immich/` — Immich
|
||||
- `minio/` — MinIO
|
||||
- `voice/` — Wyoming (Piper/Whisper)
|
||||
- `_legacy/` — старые/временные compose
|
||||
- `_archive/` — архивы (в git не попадают)
|
||||
|
||||
## Примечания
|
||||
- Файлы `.env`, `data/`, `logs/`, `backups/` и прочие runtime‑данные исключены из git.
|
||||
- Для добавления новых сервисов — создаём папку и `docker-compose.yml`.
|
||||
241
_legacy/docker-compose-no-envs.yml
Normal file
241
_legacy/docker-compose-no-envs.yml
Normal 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:
|
||||
17
bitwarden/docker-compose.yml
Normal file
17
bitwarden/docker-compose.yml
Normal 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
19
gitlab/docker-compose.yml
Normal 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
|
||||
1
homeassistant/config/.HA_VERSION
Normal file
1
homeassistant/config/.HA_VERSION
Normal file
@@ -0,0 +1 @@
|
||||
2025.11.1
|
||||
1
homeassistant/config/.ha_run.lock
Normal file
1
homeassistant/config/.ha_run.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"pid": 67, "version": 1, "ha_version": "2025.11.1", "start_ts": 1771675562.0109854}
|
||||
395
homeassistant/config/automations.yaml
Normal file
395
homeassistant/config/automations.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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') }}"
|
||||
63
homeassistant/config/configuration.yaml
Normal file
63
homeassistant/config/configuration.yaml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
229
homeassistant/config/custom_components/hacs/__init__.py
Normal file
229
homeassistant/config/custom_components/hacs/__init__.py
Normal 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)
|
||||
1110
homeassistant/config/custom_components/hacs/base.py
Normal file
1110
homeassistant/config/custom_components/hacs/base.py
Normal file
File diff suppressed because it is too large
Load Diff
225
homeassistant/config/custom_components/hacs/config_flow.py
Normal file
225
homeassistant/config/custom_components/hacs/config_flow.py
Normal 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))
|
||||
294
homeassistant/config/custom_components/hacs/const.py
Normal file
294
homeassistant/config/custom_components/hacs/const.py
Normal 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",
|
||||
]
|
||||
38
homeassistant/config/custom_components/hacs/coordinator.py
Normal file
38
homeassistant/config/custom_components/hacs/coordinator.py
Normal 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()
|
||||
98
homeassistant/config/custom_components/hacs/data_client.py
Normal file
98
homeassistant/config/custom_components/hacs/data_client.py
Normal 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)
|
||||
80
homeassistant/config/custom_components/hacs/diagnostics.py
Normal file
80
homeassistant/config/custom_components/hacs/diagnostics.py
Normal 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",))
|
||||
143
homeassistant/config/custom_components/hacs/entity.py
Normal file
143
homeassistant/config/custom_components/hacs/entity.py
Normal 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.
|
||||
"""
|
||||
71
homeassistant/config/custom_components/hacs/enums.py
Normal file
71
homeassistant/config/custom_components/hacs/enums.py
Normal 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"
|
||||
49
homeassistant/config/custom_components/hacs/exceptions.py
Normal file
49
homeassistant/config/custom_components/hacs/exceptions.py
Normal 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)
|
||||
67
homeassistant/config/custom_components/hacs/frontend.py
Normal file
67
homeassistant/config/custom_components/hacs/frontend.py
Normal 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()
|
||||
12
homeassistant/config/custom_components/hacs/icons.json
Normal file
12
homeassistant/config/custom_components/hacs/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"pre-release": {
|
||||
"state": {
|
||||
"on": "mdi:test-tube",
|
||||
"off": "mdi:test-tube-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
homeassistant/config/custom_components/hacs/iconset.js
Normal file
21
homeassistant/config/custom_components/hacs/iconset.js
Normal 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,
|
||||
})
|
||||
)
|
||||
};
|
||||
26
homeassistant/config/custom_components/hacs/manifest.json
Normal file
26
homeassistant/config/custom_components/hacs/manifest.json
Normal 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"
|
||||
}
|
||||
58
homeassistant/config/custom_components/hacs/repairs.py
Normal file
58
homeassistant/config/custom_components/hacs/repairs.py
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
1454
homeassistant/config/custom_components/hacs/repositories/base.py
Normal file
1454
homeassistant/config/custom_components/hacs/repositories/base.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
73
homeassistant/config/custom_components/hacs/switch.py
Normal file
73
homeassistant/config/custom_components/hacs/switch.py
Normal 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()
|
||||
52
homeassistant/config/custom_components/hacs/system_health.py
Normal file
52
homeassistant/config/custom_components/hacs/system_health.py
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
homeassistant/config/custom_components/hacs/types.py
Normal file
10
homeassistant/config/custom_components/hacs/types.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Custom HACS types."""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class DownloadableContent(TypedDict):
|
||||
"""Downloadable content."""
|
||||
|
||||
url: str
|
||||
name: str
|
||||
158
homeassistant/config/custom_components/hacs/update.py
Normal file
158
homeassistant/config/custom_components/hacs/update.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize HACS utils."""
|
||||
110
homeassistant/config/custom_components/hacs/utils/backup.py
Normal file
110
homeassistant/config/custom_components/hacs/utils/backup.py
Normal 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)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""HACS Configuration Schemas."""
|
||||
|
||||
# Configuration:
|
||||
SIDEPANEL_TITLE = "sidepanel_title"
|
||||
SIDEPANEL_ICON = "sidepanel_icon"
|
||||
APPDAEMON = "appdaemon"
|
||||
|
||||
# Options:
|
||||
COUNTRY = "country"
|
||||
323
homeassistant/config/custom_components/hacs/utils/data.py
Normal file
323
homeassistant/config/custom_components/hacs/utils/data.py
Normal 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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
47
homeassistant/config/custom_components/hacs/utils/filters.py
Normal file
47
homeassistant/config/custom_components/hacs/utils/filters.py
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""JSON utils."""
|
||||
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
__all__ = ["json_loads"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Custom logger for HACS."""
|
||||
|
||||
import logging
|
||||
|
||||
from ..const import PACKAGE_NAME
|
||||
|
||||
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
|
||||
41
homeassistant/config/custom_components/hacs/utils/path.py
Normal file
41
homeassistant/config/custom_components/hacs/utils/path.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
17
homeassistant/config/custom_components/hacs/utils/regex.py
Normal file
17
homeassistant/config/custom_components/hacs/utils/regex.py
Normal 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()
|
||||
79
homeassistant/config/custom_components/hacs/utils/store.py
Normal file
79
homeassistant/config/custom_components/hacs/utils/store.py
Normal 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()
|
||||
30
homeassistant/config/custom_components/hacs/utils/url.py
Normal file
30
homeassistant/config/custom_components/hacs/utils/url.py
Normal 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"
|
||||
215
homeassistant/config/custom_components/hacs/utils/validate.py
Normal file
215
homeassistant/config/custom_components/hacs/utils/validate.py
Normal 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,
|
||||
)
|
||||
]
|
||||
)
|
||||
36
homeassistant/config/custom_components/hacs/utils/version.py
Normal file
36
homeassistant/config/custom_components/hacs/utils/version.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize validation."""
|
||||
@@ -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")
|
||||
54
homeassistant/config/custom_components/hacs/validate/base.py
Normal file
54
homeassistant/config/custom_components/hacs/validate/base.py
Normal 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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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"], {}))
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
)
|
||||
378
homeassistant/config/custom_components/localtuya/__init__.py
Normal file
378
homeassistant/config/custom_components/localtuya/__init__.py
Normal 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)
|
||||
@@ -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
|
||||
)
|
||||
522
homeassistant/config/custom_components/localtuya/climate.py
Normal file
522
homeassistant/config/custom_components/localtuya/climate.py
Normal 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)
|
||||
139
homeassistant/config/custom_components/localtuya/cloud_api.py
Normal file
139
homeassistant/config/custom_components/localtuya/cloud_api.py
Normal 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"
|
||||
607
homeassistant/config/custom_components/localtuya/common.py
Normal file
607
homeassistant/config/custom_components/localtuya/common.py
Normal 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)
|
||||
819
homeassistant/config/custom_components/localtuya/config_flow.py
Normal file
819
homeassistant/config/custom_components/localtuya/config_flow.py
Normal 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."""
|
||||
143
homeassistant/config/custom_components/localtuya/const.py
Normal file
143
homeassistant/config/custom_components/localtuya/const.py
Normal 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"
|
||||
233
homeassistant/config/custom_components/localtuya/cover.py
Normal file
233
homeassistant/config/custom_components/localtuya/cover.py
Normal 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
259
homeassistant/config/custom_components/localtuya/fan.py
Normal file
259
homeassistant/config/custom_components/localtuya/fan.py
Normal 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)
|
||||
506
homeassistant/config/custom_components/localtuya/light.py
Normal file
506
homeassistant/config/custom_components/localtuya/light.py
Normal 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)
|
||||
@@ -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"
|
||||
}
|
||||
113
homeassistant/config/custom_components/localtuya/number.py
Normal file
113
homeassistant/config/custom_components/localtuya/number.py
Normal 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)
|
||||
1196
homeassistant/config/custom_components/localtuya/pytuya/__init__.py
Normal file
1196
homeassistant/config/custom_components/localtuya/pytuya/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
123
homeassistant/config/custom_components/localtuya/select.py
Normal file
123
homeassistant/config/custom_components/localtuya/select.py
Normal 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)
|
||||
75
homeassistant/config/custom_components/localtuya/sensor.py
Normal file
75
homeassistant/config/custom_components/localtuya/sensor.py
Normal 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)
|
||||
@@ -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
|
||||
139
homeassistant/config/custom_components/localtuya/strings.json
Normal file
139
homeassistant/config/custom_components/localtuya/strings.json
Normal 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"
|
||||
}
|
||||
91
homeassistant/config/custom_components/localtuya/switch.py
Normal file
91
homeassistant/config/custom_components/localtuya/switch.py
Normal 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
Reference in New Issue
Block a user