Initial commit.

This commit is contained in:
2025-08-30 16:34:17 -04:00
commit f9e55b8704
2194 changed files with 86781 additions and 0 deletions
@@ -0,0 +1,214 @@
name: Gitea Branch PR & Ansible Deployment
on:
workflow_dispatch:
push:
branches-ignore:
- 'main'
paths:
- '**.j2'
- '**/pr-ansible-config-deployment.yaml'
- 'ansible/**.yml'
jobs:
check-and-create-pr:
if: github.ref != 'refs/heads/main'
name: Check and Create PR
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install tea CLI
uses: supplypike/setup-bin@v4
with:
uri: 'https://gitea.com/gitea/tea/releases/download/v0.9.2/tea-0.9.2-linux-amd64'
name: 'tea'
version: '0.9.2'
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: PR Check'
notification_message: 'Checking for existing PR... 🔍'
- name: Check if open PR exists
id: check-opened-pr-step
continue-on-error: true
run: |
tea login add --name gitea-rinoa --url "${{ secrets.RINOA_GITEA_URL }}" --user gitea-sonarqube-bot --password "${{ secrets.BOT_GITEA_PASSWORD }}" --token ${{ secrets.BOT_GITEA_TOKEN }}
pr_exists=$(tea pr list --repo ${{ github.repository }} --state open --fields index,title,head | egrep '\[ANSIBLE\].*${{ github.ref_name }}' | tail -1 | wc -l)
echo "exists=$pr_exists" >> $GITHUB_OUTPUT
- name: Create PR
if: ${{ steps.check-opened-pr-step.outputs.exists == '0' }}
run: |
tea login default gitea-rinoa
pr_index_old=$(tea pr ls --repo ${{ github.repository }} --state all --fields index,title,head --output csv | sed -e 's|"||g' | egrep '^[0-9]' | head -1 | awk -F"," '{print $1}')
pr_index_new=$(expr ${pr_index_old} + 1)
tea pr c -r ${{ github.repository }} -t "[ANSIBLE] Automated PR for ${{ github.ref_name }} - #${pr_index_new}" -d "Automatically created PR for branch: ${{ github.ref_name }}" -a ${{ github.actor }} -L "Ansible Configs.j2"
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: PR Check'
notification_message: 'PR Created 🎟️'
ansible-dry-run:
name: Ansible Dry Run
needs: [check-and-create-pr]
runs-on: ubuntu-latest
strategy:
matrix:
host: [rinoa, rikku, benedikta]
env:
VAULT_ADDR: ${{ secrets.RINOA_VAULT_ADDR }}
VAULT_TOKEN: ${{ secrets.VAULT_GITEA_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Cache Ansible Galaxy Collections
uses: actions/cache@v3
with:
path: ansible/collections
key: ${{ runner.os }}-ansible-${{ hashFiles('./ansible/collections/requirements.yml') }}
restore-keys: |
${{ runner.os }}-ansible-
- name: Install Ansible
uses: alex-oleshkevich/setup-ansible@v1.0.1
with:
version: "11.4.0"
- name: Install Vault & hvac
run: |
sudo apt-get update && sudo apt-get install -y unzip
curl -fsSL https://releases.hashicorp.com/vault/1.18.0/vault_1.18.0_linux_amd64.zip -o vault.zip
unzip vault.zip
sudo mv vault /usr/local/bin/
pip install hvac
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: Ansible Dry Run'
notification_message: 'Starting dry run for ${{ matrix.host }}...'
- name: Run Ansible Dry Run
uses: dawidd6/action-ansible-playbook@v3
with:
directory: ansible/
playbook: homelab_config_deploy.yml
key: ${{ secrets.RINOA_ANSIBLE_PRIVATE_KEY }}
vault_password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
requirements: collections/requirements.yml
options: |
--inventory inventory/hosts.yml
--limit ${{ matrix.host }}
--check
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: Ansible Dry Run'
notification_message: 'Dry run for ${{ matrix.host }} completed.'
pr-merge:
name: PR Merge
needs: [ansible-dry-run]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install tea
uses: supplypike/setup-bin@v4
with:
uri: 'https://gitea.com/gitea/tea/releases/download/v0.9.2/tea-0.9.2-linux-amd64'
name: 'tea'
version: '0.9.2'
- name: PR Merge
id: pr_merge
run: |
tea login add --name gitea-rinoa --url ${{ secrets.RINOA_GITEA_URL }} --user gitea-sonarqube-bot --password "${{ secrets.BOT_GITEA_PASSWORD }}" --token ${{ secrets.BOT_GITEA_TOKEN }}
tea login default gitea-rinoa
pr_index=$(tea pr ls --repo ${{ github.repository }} --state open --fields index,title,head,state --output csv | egrep ${{ github.ref_name }} | awk -F"," '{print $1}' | sed -e 's|"||g')
tea pr m --repo ${{ github.repository }} --title "Auto Merge of PR ${pr_index} - ${{ github.ref_name }}" --message "Merged by ${{ github.actor }}" ${pr_index}
echo "pr_index=${pr_index}" >> $GITHUB_OUTPUT
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: PR Merge Successful'
notification_message: 'PR #${{ steps.pr_merge.outputs.pr_index }} merged.'
ansible-config-deploy:
name: Ansible Config Deployment
needs: [pr-merge]
runs-on: ubuntu-latest
strategy:
matrix:
host: [rinoa, rikku, benedikta]
env:
VAULT_ADDR: ${{ secrets.RINOA_VAULT_ADDR }}
VAULT_TOKEN: ${{ secrets.VAULT_GITEA_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: main
- name: Install Ansible
uses: alex-oleshkevich/setup-ansible@v1.0.1
with:
version: "11.4.0"
- name: Install Vault & hvac
run: |
sudo apt-get update && sudo apt-get install -y unzip
curl -fsSL https://releases.hashicorp.com/vault/1.18.0/vault_1.18.0_linux_amd64.zip -o vault.zip
unzip vault.zip
sudo mv vault /usr/local/bin/
pip install hvac
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: Ansible Config Deployment'
notification_message: 'Deploying configs to ${{ matrix.host }}...'
- name: Run Ansible Config Deployment
uses: dawidd6/action-ansible-playbook@v3
with:
directory: ansible/
playbook: homelab_config_deploy.yml
key: ${{ secrets.RINOA_ANSIBLE_PRIVATE_KEY }}
vault_password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
requirements: collections/requirements.yml
options: |
--inventory inventory/hosts.yml
--limit ${{ matrix.host }}
- name: Gotify Notification
uses: eikendev/gotify-action@master
with:
gotify_api_base: '${{ secrets.RINOA_GOTIFY_URL }}'
gotify_app_token: '${{ secrets.RINOA_RUNNER_GOTIFY_TOKEN }}'
notification_title: 'GITEA: Deployment Completed'
notification_message: 'Deployment to ${{ matrix.host }} completed successfully.'
+177
View File
@@ -0,0 +1,177 @@
.logs/*
*.retry
*.vault
collections/*
!collections/ansible_collections
!collections/requirements.yml
collections/ansible_collections/*
!collections/ansible_collections/aperature_ansible
collections/ansible_collections/aperature_ansible/*
!collections/ansible_collections/aperature_ansible/aperature
# https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# MacOS
.DS_Store
# Ansible
.ansible/
+5
View File
@@ -0,0 +1,5 @@
[defaults]
inventory = inventory/hosts.yml
collections_paths = ./collections
host_key_checking = False
retry_files_enabled = False
@@ -0,0 +1,3 @@
{
"first_boot": false
}
+4
View File
@@ -0,0 +1,4 @@
XKB_DEFAULT_MODEL=pc105
XKB_DEFAULT_LAYOUT=us
XKB_DEFAULT_VARIANT=
XKB_DEFAULT_OPTIONS=
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,14 @@
{
"__mycroft_skill_firstrun": false,
"type": "ovos_common_play",
"preferred_audio_services": [
"mpv",
"vlc",
"simple"
],
"disable_mpris": true,
"dbus_type": "session",
"manage_external_players": false,
"active": true,
"mode": "auto"
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,80 @@
{
"system_unit": "metric",
"time_format": "full",
"date_format": "DMY",
"intents": {
"persona": {
"handle_fallback": true,
"default_persona": "Benedikta"
},
"pipeline": [
"stop_high",
"converse",
"ocp_high",
"padatious_high",
"adapt_high",
"ocp_medium",
"ovos-persona-pipeline-plugin-high",
"fallback_high",
"stop_medium",
"adapt_medium",
"padatious_medium",
"fallback_medium",
"adapt_low",
"common_qa",
"ovos-persona-pipeline-plugin-low",
"fallback_low"
],
"padatious": {
"stem": false,
"cast_to_ascii": true,
"domain_engine": false
}
},
"tts": {
"module": "ovos-tts-plugin-server",
"ovos-tts-plugin-server": {
"voice": "alba-medium"
},
"fallback_module": "ovos-tts-plugin-piper",
"ovos-tts-plugins-piper": {
"voice": "alba-medium"
}
},
"stt": {
"ovos-stt-plugin-server": {},
"module": "ovos-stt-plugin-server",
"fallback_module": "ovos-stt-plugin-fasterwhisper",
"ovos-stt-plugin-fasterwhisper": {
"model": "tiny.en"
}
},
"listener": {
"wake_word": "hey_benedikta",
"VAD": {
"module": "ovos-vad-plugin-silero"
}
},
"hotwords": {
"hey_benedikta": {
"module": "ovos-ww-plugin-vosk",
"listen": true,
"debug": false,
"rule": "equals",
"samples": [
"hey benedicta",
"hey benedikta",
"benedikta",
"benedicta"
],
"fallback_ww": "hey_benedikta_pocketsphinx"
},
"hey_benedikta_pocketsphinx": {
"module": "ovos-ww-plugin-pocketsphinx",
"phonemes": "HH EY . B EH N AH D IY K T AH",
"threshold": 1e-10,
"lang": "en-us",
"listen": true
}
}
}
@@ -0,0 +1,38 @@
{
"system_unit": "metric",
"time_format": "full",
"date_format": "DMY",
"intents": {
"persona": {
"handle_fallback": true,
"default_persona": "Remote LLama"
},
"pipeline": [
"ocp_high",
"stop_high",
"converse",
"padatious_high",
"adapt_high",
"stop_medium",
"adapt_medium",
"ovos-persona-pipeline-plugin-high",
"fallback_medium",
"ovos-persona-pipeline-plugin-low",
"fallback_low"
],
"padatious": {
"stem": false,
"cast_to_ascii": true,
"domain_engine": false
}
},
"tts": {
"module": "ovos-tts-plugin-server",
"ovos-tts-plugin-server": {}
},
"stt": {
"module": "ovos-stt-plugin-server",
"fallback_module": "",
"ovos-stt-plugin-server": {}
}
}
@@ -0,0 +1,776 @@
{
// Definition and documentation of all variables used by mycroft-core.
//
// Settings seen here are considered DEFAULT. Settings can also be
// overridden at the REMOTE level (set by the user via
// https://home.mycroft.ai), at the SYSTEM level (typically in the file
// '/etc/mycroft/mycroft.conf'), or at the USER level (typically in the
// file '~/.config/mycroft/mycroft.conf').
//
// The load order of settings is:
// DEFAULT
// REMOTE
// SYSTEM
// USER
//
// The Override: comments below indicates where these settings are generally
// set outside of this file. The load order is always followed, so an
// individual systems can still apply changes at the SYSTEM or USER levels.
// Language used for speech-to-text and text-to-speech.
// Code is a BCP-47 identifier (https://tools.ietf.org/html/bcp47), lowercased
// TODO: save unmodified, lowercase upon demand
"lang": "en-us",
// Secondary languages will also have their resource files loaded into memory
// but intents will only be considered if that lang is tagged with the utterance at STT step
"secondary_langs": [],
// Measurement units, either 'metric' or 'imperial'
"system_unit": "metric",
// Temperature units, either 'celsius' or 'fahrenheit'
"temperature_unit": "celsius",
// Windspeed units, either 'km/h', 'm/s', 'mph' or 'kn' (knots)
"windspeed_unit": "m/s",
// Precipitation units, either 'mm' or 'inch'
"precipitation_unit": "mm",
// Time format, either 'half' (e.g. "11:37 pm") or 'full' (e.g. "23:37")
"time_format": "half",
"spoken_time_format": "full",
// Date format, either 'MDY' (e.g. "11-29-1978") or 'DMY' (e.g. "29-11-1978")
"date_format": "MDY",
// Play a beep when system begins to listen?
"confirm_listening": true,
// opendata servers can be hosted by users or OVOS team, either for analyzing your usage patterns or to contribute to a open dataset
// any number of server urls can be added here, this is EXCLUSIVELY OPT-IN
// source: https://github.com/OpenVoiceOS/ovos-opendata-server
"open_data": {
// NOTE: consider enabling tigregotico.pt servers to help collecting datasets!
// this is a server hosted by @JarbasAi and will directly help improving the intent pipeline
// a dashboard is provided at https://opendata.tigregotico.pt
// more test data is needed to complement https://gitlocalize-bench.tigregotico.pt and to identity areas of improvement
"intent_urls": [
// "https://metrics.tigregotico.pt/intents"
]
},
// File locations of sounds to play for system events
"sounds": {
"start_listening": "snd/start_listening.wav",
"end_listening": "snd/end_listening.wav",
"acknowledge": "snd/acknowledge.mp3",
"error": "snd/error.mp3"
},
// Mechanism used to play WAV audio files
// by default ovos-utils will try to detect best player
//"play_wav_cmdline": "paplay %1 --stream-name=mycroft-voice",
// Mechanism used to play MP3 audio files
// by default ovos-utils will try to detect best player
//"play_mp3_cmdline": "mpg123 %1",
// Mechanism used to play OGG audio files
// by default ovos-utils will try to detect best player
//"play_ogg_cmdline": "ogg123 -q %1",
// Location where the system resides
// NOTE: Although this is set here, an Enclosure can override the value.
// For example a mycroft-core running in a car could use the GPS.
"location": {
"city": {
"code": "Lawrence",
"name": "Lawrence",
"state": {
"code": "KS",
"name": "Kansas",
"country": {
"code": "US",
"name": "United States"
}
}
},
"coordinate": {
"latitude": 38.971669,
"longitude": -95.23525
},
"timezone": {
"code": "America/Chicago",
"name": "Central Standard Time",
"dstOffset": 3600000,
"offset": -21600000
}
},
// default to $XDG_DATA_DIRS/mycroft
// "data_dir": "/opt/mycroft",
// by default, files persist across reboots, but be careful with space usage!
// TIP: use "/dev/shm/mycroft/cache" if you want to keep the cache in RAM or
// use "/tmp/mycroft/cache" to remove files upon reboot
// default to $XDG_DATA_DIRS/$BASE_FOLDER where BASE_FOLDER is read from ovos.conf (default "mycroft")
// "cache_path": "/tmp/mycroft/cache",
// To enable a utterance transformer plugin just add it's name with any relevant config
// these plugins can mutate the utterance between STT and the Intent stage
// they may also modify message.context with metadata
// plugins only load if they are installed and enabled in this section
"utterance_transformers": {
"ovos-utterance-normalizer": {},
// cancel utterances mid command
"ovos-utterance-plugin-cancel": {},
// define utterance fixes via fuzzy match ~/.local/share/mycroft/corrections.json
// define unconditional replacements at word level ~/.local/share/mycroft/word_corrections.json
"ovos-utterance-corrections-plugin": {}
},
// To enable a metadata transformer plugin just add it's name with any relevant config
// these plugins can mutate the message.context between STT and the Intent stage
"metadata_transformers": {},
// Intent Pipeline / plugins config
"intents" : {
// the position in the pipeline where each engine matches can be configured in "pipeline" section below
// adjust confidence thresholds for adapt pipeline
// adapt intents with conf lower than these values will be ignored
"adapt": {
"conf_high": 0.65,
"conf_med": 0.45,
"conf_low": 0.25
},
// adjust confidence thresholds for padatious pipeline
// padatious/padacioso intents with conf lower than these values will be ignored
"padatious": {
"conf_high": 0.95,
"conf_med": 0.8,
"conf_low": 0.5,
// use snowball stemmer, helps with grammatical gender and verb tenses
"stem": false,
// normalization step to remove punctuation and punctuation
"cast_to_ascii": false,
// padaos is an internal regex matcher that ensures exact matches
"disable_padaos": false,
// domain engine will classify intents first and then the intent for that skill
// if false intents are classified directly
"domain_engine": false,
// path to save trained intent files
// by default uses XDG directories if not set
//"intent_cache": "~/.local/share/mycroft/intent_cache/",
// set to false if multithreading is wanted
"single_thread": true
},
// Common Query gathers responses to questions from various skills and chooses the best answer
"common_query": {
// maximum seconds to wait for skill responses before giving up
"max_response_wait": 6,
// how much extra seconds to allow a skill to search if it requests so
"extension_time": 3,
// reranker plugins are responsible for selecting the best answer among skill responses in case of ties
// the default is a BM25 implementation from ovos-classifiers
"reranker": "ovos-choice-solver-bm25"
},
// OVOS Common Play - handle media requests
"OCP": {
// enable usage of a pretrained classifier for MediaType matching
// if disabled a simple .voc match is used
// NOTE: classifiers currently are english only
"experimental_media_classifier": false,
"experimental_binary_classifier": false,
// legacy forces old audio service instead of OCP
"legacy": false,
// min confidence (0.0 - 1.0) to accept MediaType
"classifier_threshold": 0.4,
// min conf for each result (0 - 100)
"min_score": 40,
// filter results from "wrong" MediaType
"filter_media": true,
// filter results we lack plugins to play
"filter_SEI": true,
// playback mode
// 0 - auto
// 10 - audio results only
// 20 - video results only
"playback_mode": 0,
// if MediaType query fails, try Generic query
"search_fallback": true
},
// the pipeline is a ordered set of frameworks to send an utterance too
// if one of the frameworks fails the next one is used, until an answer is found
// NOTE: if padatious is not installed, it will be replaced with padacioso (much slower)
// in the future these will become plugins, and new pipeline stages can be added by end users
"pipeline": [
"stop_high",
"converse",
"ocp_high",
"padatious_high",
"adapt_high",
"ocp_medium",
"fallback_high",
"stop_medium",
"adapt_medium",
"adapt_low",
"common_qa",
"fallback_medium",
"fallback_low"
]
},
// General skill values
"skills": {
// relative to "data_dir"
"directory": "skills",
// blacklisted skills to not load
// NB: This is skill_id of the skill, usually defined in the skills setup.py
"blacklisted_skills": [
// stop skill has been replaced with native core functionality
"skill-ovos-stop.openvoiceos"
],
// fallback skill configuration
"fallbacks": {
// you can add skill_id: priority to override the developer defined
// priority of those skills, this allows customization
// of unknown intent handling for default_skills + user preferences
"fallback_priorities": {
// "skill_id": 10
},
// fallback skill handling has 3 modes of operations:
// - "accept_all" # default mycroft-core behavior
// - "whitelist" # only call fallback for skills in "fallback_whitelist"
// - "blacklist" # only call fallback for skills NOT in "fallback_blacklist"
"fallback_mode": "accept_all",
"fallback_whitelist": [],
"fallback_blacklist": []
},
// converse stage configuration
"converse": {
// the default number of seconds a skill remains active
// if the user does not interact with the skill in this timespan it
// will be deactivated, default 5 minutes (same as mycroft)
"timeout": 300,
// override of "skill_timeouts" per skill_id
"skill_timeouts": {},
// conversational mode has 3 modes of operations:
// - "accept_all" # default mycroft-core behavior
// - "whitelist" # only call converse for skills in "converse_whitelist"
// - "blacklist" # only call converse for skills NOT in "converse_blacklist"
"converse_mode": "accept_all",
"converse_whitelist": [],
"converse_blacklist": [],
// converse activation has 4 modes of operations:
// - "accept_all" # default mycroft-core behavior, any skill can
// # activate itself unconditionally
// - "priority" # skills can only activate themselves if no skill with
// # higher priority is active
// - "whitelist" # only skills in "converse_whitelist" can activate themselves
// - "blacklist" # only skills NOT in converse "converse_blacklist" can activate themselves
// NOTE: this does not apply for regular skill activation, only to skill
// initiated activation requests
"converse_activation": "accept_all",
// number of consecutive times a skill is allowed to activate itself
// per minute, -1 for no limit (default), 0 to disable self-activation
"max_activations": -1,
// override of "max_activations" per skill_id
"skill_activations": {},
// if false only skills can activate themselves
// if true any skill can activate any other skill
"cross_activation": true,
// if false only skills can deactivate themselves
// if true any skill can deactivate any other skill
// NOTE: skill deactivation is not yet implemented
"cross_deactivation": true,
// you can add skill_id: priority to override the developer defined
// priority of those skills, currently there is no api for skills to
// define their default priority, it is assumed to be 50, the only current
// canonical source for converse priorities is this setting
"converse_priorities": {
// "skill_id": 10
}
}
},
// system administrators can define different constraints in how configurations are loaded
// this is a mechanism to require root to change these config options
"system": {
// do not allow users to tamper with settings at all
"disable_user_config": false,
// do not allow remote backend to tamper with settings at all
"disable_remote_config": false,
// protected keys are individual settings that can not be changed at remote/user level
// nested dictionary keys can be defined with "key1:key2" syntax,
// eg. {"a": {"b": True, "c": False}}
// to protect "c" you would enter "a:c" in the section below
"protected_keys": {
"remote": [
"system",
"websocket",
"gui_websocket",
"network_tests",
// NOTE: selene returns listener settings as part of ww config
// they are protected because selene has no clue about your mic setup
"listener:channels",
"listener:sample_rate"
],
"user": []
}
},
// The ovos-core messagebus websocket
"websocket": {
"host": "127.0.0.1",
"port": 8181,
"route": "/core",
"ssl": false,
// in mycroft-core all skills share a bus, this allows malicious skills
// to manipulate it and affect other skills, this option ensures each skill
// gets its own websocket connection
"shared_connection": true,
// any bus messages over this size in MB will be refused
"max_msg_size": 25
},
// The GUI messagebus websocket. Once port is created per connected GUI
"gui_websocket": {
"host": "0.0.0.0",
"base_port": 18181,
"route": "/gui",
"ssl": false
},
// URIs to use for testing network connection.
"network_tests": {
"ip_url": "https://api.ipify.org",
"dns_primary": "1.1.1.1",
"dns_secondary": "8.8.8.8",
"web_url": "http://nmcheck.gnome.org/check_network_status.txt",
"web_url_secondary": "https://checkonline.home-assistant.io/online.txt",
"captive_portal_url": "http://nmcheck.gnome.org/check_network_status.txt",
"captive_portal_text": "NetworkManager is online"
},
// Settings used by the wake-up-word listener
"listener": {
"sample_rate": 16000,
// mute global audio output volume while microphone is recording
"fake_barge_in": true,
// TODO - these names are confusing, update dinkum listener to give them more descriptive names
// min speech seconds for audio to be considered speech
"speech_begin": 0.3,
// min silence seconds before speech is considered ended
"silence_end": 0.7,
// hard limit on max recording time before audio is sent to STT
"recording_timeout": 10,
// size of buffer kept for STT
"utterance_chunks_to_rewind": 2,
// sound chunks sent to ww callback (eg, saving recordings)
"wakeword_chunks_to_save": 15,
// Set 'save_path' to configure the location of files stored if
// 'record_wake_words' and/or 'save_utterances' are set to 'true'.
// WARNING: Make sure that user 'mycroft' has write-access on the
// directory!
// "save_path": "/tmp",
// Set 'record_wake_words' to save a copy of wake word triggers
// as .wav files under: /'save_path'/mycroft_wake_words
"record_wake_words": false,
// Set 'save_utterances' to save each sentence sent to STT -- by default
// they are only kept briefly in-memory. This can be useful for for
// debugging or other custom purposes. Recordings are saved
// under: /'save_path'/utterances/'utterance_filename'.wav
"save_utterances": false,
// Set 'utterance_filename' based on how you would like to save utterances.
// Special keys surrouned by curly braces will be replaces as follows
// "{md5}" - the md5 hash of the transcription
// "{uuid4}" - a random uuid4
// "{now:%Y-%m-%dT%H%M%S%z}" - the local ISO datetime
// "{utcnow:%Y-%m-%dT%H%M%S%z}" - the UTC ISO datetime
"utterance_filename": "{md5}-{uuid4}",
"wake_word_upload": {
"disable": true,
// official mycroft endpoint disabled, enable if you want to collect your own
// eg, eltocino localcroft or personal backend
"url": ""
},
// Microphone plugin to be read audio
"microphone": {
"module": "ovos-microphone-plugin-alsa",
"ovos-microphone-plugin-alsa": {"fallback_module": "ovos-microphone-plugin-sounddevice"},
"ovos-microphone-plugin-sounddevice": {"fallback_module": "ovos-microphone-plugin-pyaudio"}
},
// if true, will remove silence from both ends of audio before sending it to STT
"remove_silence": true,
// Voice Activity Detection is used to determine when speech ended
"VAD": {
// silence method defined the main vad strategy
// valid values:
// VAD_ONLY - Only use vad
// RATIO_ONLY - Only use max/current energy ratio threshold
// CURRENT_ONLY - Only use current energy threshold
// VAD_AND_RATIO - Use vad and max/current energy ratio threshold
// VAD_AND_CURRENT - Use vad and current energy threshold
// ALL - Use vad, max/current energy ratio, and current energy threshold
// NOTE: if a vad plugin is not available method will fallback to RATIO_ONLY
"silence_method": "vad_and_ratio",
// Seconds of speech before voice command has begun
"speech_seconds": 0.1,
// Seconds of silence before a voice command has finished
"silence_seconds": 0.5,
// Seconds of audio to keep before voice command has begun
"before_seconds": 0.5,
// Minimum length of voice command (seconds)
// NOTE: max_seconds uses recording_timeout listener setting
"min_seconds": 1,
// Ratio of max/current energy below which audio is considered speech
"max_current_ratio_threshold": 2,
// Energy threshold above which audio is considered speech
// NOTE: this is dynamic, only defining start value
"initial_energy_threshold": 1000.0,
// recommended plugin: "ovos-vad-plugin-silero"
"module": "ovos-vad-plugin-silero",
"ovos-vad-plugin-silero": {
"threshold": 0.2,
"fallback_module": "ovos-vad-plugin-precise"
},
"ovos-vad-plugin-precise": {
"fallback_module": "ovos-vad-plugin-webrtcvad"
},
"ovos-vad-plugin-webrtcvad": {
"vad_mode": 3,
"fallback_module": "ovos-vad-plugin-noise"
},
"ovos-vad-plugin-noise": {
"method": "all",
"max_current_ratio_threshold": 2.0,
"energy_threshold": 1000.0
}
},
// Override as SYSTEM or USER to select a specific microphone input instead of
// the PortAudio default input.
// "device_name": "somename", // can be regex pattern or substring
// or
// "device_index": 12,
// Retry microphone initialization infinitely on startup
"retry_mic_init" : true,
// Stop listing to the microphone during playback to prevent accidental triggering
// This is enabled by default, but instances with good microphone noise cancellation
// can disable this to listen all the time, allowing 'barge in' functionality.
"mute_during_output" : false,
// In milliseconds
"phoneme_duration": 120,
"multiplier": 1.0,
"energy_ratio": 1.5,
// NOTE, multiple hotwords are supported now, these fields define the main wake_word,
// this is equivalent to setting "active": true in the "hotwords" section below IF "active" is missing
// this field is also used to get a speakable string of main wake word, ie, mycrofts name
// this is set by selene and used in naptime skill
"wake_word": "hey_mycroft",
"stand_up_word": "wake_up",
// Settings used by microphone to set recording timeout
"recording_timeout": 10.0,
"recording_timeout_with_silence": 3.0,
// Skips all checks (eg. audio service confirmation) after the wake_word is recognized and
// instantly continues to listen for a command
"instant_listen": true,
// continuous listen is an experimental setting, it removes the need for
// wake words and uses VAD only, a streaming STT is strongly recommended
// this setting might downgrade STT accuracy depending on plugins used
"continuous_listen": false,
// hybrid listen is an experimental setting,
// it will not require a wake word for X seconds after a user interaction
// this means you dont need to say "hey mycroft" for follow up questions
// NOTE: depending on hardware this may cause mycroft to hear its own TTS responses as questions,
// in devices like the mark2 this should be safe to turn on
"hybrid_listen": false,
// number of seconds to wait for an interaction before requiring wake word again
"listen_timeout": 45
},
// Hotword configurations
"hotwords": {
"hey_mycroft": {
"module": "ovos-ww-plugin-precise-lite",
"model": "https://github.com/OpenVoiceOS/precise-lite-models/raw/master/wakewords/en/hey_mycroft.tflite",
"expected_duration": 3,
"trigger_level": 3,
"sensitivity": 0.5,
"listen": true,
"fallback_ww": "hey_mycroft_precise"
},
// in case precise-lite is not installed, attempt to use classic precise
"hey_mycroft_precise": {
"module": "ovos-ww-plugin-precise",
"version": "0.3",
"model": "https://github.com/MycroftAI/precise-data/raw/models-dev/hey-mycroft.tar.gz",
"expected_duration": 3,
"trigger_level": 3,
"sensitivity": 0.5,
"listen": true,
"fallback_ww": "hey_mycroft_vosk"
},
// in case classic precise is not installed, attempt to use vosk
"hey_mycroft_vosk": {
"module": "ovos-ww-plugin-vosk",
"samples": ["hey mycroft", "hey microsoft", "hey mike roft", "hey minecraft"],
"rule": "fuzzy",
"listen": true,
"fallback_ww": "hey_mycroft_pocketsphinx"
},
// in case vosk is not installed, attempt to use pocketsphinx
"hey_mycroft_pocketsphinx": {
"module": "ovos-ww-plugin-pocketsphinx",
"phonemes": "HH EY . M AY K R AO F T",
"threshold": 1e-90,
"lang": "en-us",
"listen": true
},
// default wakeup word to take ovos out of SLEEPING mode,
"wake_up": {
"module": "ovos-ww-plugin-vosk",
"rule": "fuzzy",
"samples": ["wake up"],
"lang": "en-us",
// makes this a wakeup word for usage in SLEEPING mode
"wakeup": true,
"fallback_ww": "wake_up_pocketsphinx"
},
// in case vosk plugin is not installed, attempt to use pocketsphinx
"wake_up_pocketsphinx": {
"module": "ovos-ww-plugin-pocketsphinx",
"phonemes": "W EY K . AH P",
"threshold": 1e-20,
"lang": "en-us",
"wakeup": true
}
},
"gui": {
// Override: SYSTEM (set by specific enclosures)
// set skill_id of initial homescreen
"idle_display_skill": "skill-ovos-homescreen.openvoiceos",
// GUI plugins / Extensions provide additional GUI platform support for specific devices
"extension": "generic",
// Generic extension can additionaly provide homescreen functionality
"generic": {
// enable/disable homescreen
"homescreen_supported": true
},
// in headless devices you can set this flag to not emit any GUI related bus messages
// this will reduce messagebus usage
// NOTE: this may be undesired, GUI clients can also be cli based and
// do not necessarily require a desktop environment or to be running 24/7
"disable_gui": false
},
// Level of logs to store, one of "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"
// NOTE: This configuration setting is special and can only be changed in the
// SYSTEM or USER configuration file, it will not be read if defined in the
// DEFAULT (here) or in the REMOTE mycroft config.
// If not defined, the default log level is INFO.
//"log_level": "INFO",
// Messagebus types that will NOT be output to logs
// DEPRECATED: this was consumed by ovos-cli-client which is being retired
"ignore_logs": ["enclosure.mouth.viseme", "enclosure.mouth.display"],
// Settings related to remote sessions
// Overrride: none
"session": {
// Time To Live, in seconds
"ttl": -1
},
// Speech to Text parameters
"stt": {
// select a STT plugin as described in the respective readme
// ovos-stt-plugin-server default plugin has a bundled list of public whisper instances
"module": "ovos-stt-plugin-server",
// set a offline fallback STT, if your primary STT fails for some reason
"fallback_module": ""
},
// Text to Speech parameters
"tts": {
"pulse_duck": false,
// ovos tts server piper public servers by default, alan pope voice
"module": "ovos-tts-plugin-server",
"fallback_module": "",
"ovos-tts-plugin-mimic": {
"voice": "ap",
// Add every new synth to the persistent cache (not cleared)
"persist_cache": false,
// Path to the persistent cached files, default will be $XDG_DATA_DIRS/mycroft/OVOSServerTTS
// (or respective TTS name)
"preloaded_cache": "",
// Curate the /tmp/tts cache if disk usage is above min %
"min_free_percent": 75,
// How many times a utterance must be seen in order to be added to persistent cache
"persist_thresh": 1
}
},
// NLP plugins
// split utterances into words
// used by downstream tasks, handles lang specific nuances
"tokenization": {
// default plugin comes bundled with ovos-plugin-manager
"module": "ovos-tokenization-plugin-quebrafrases"
},
// split utterances into sub commands
// used to extract multiple intents from a single utterance
"segmentation": {
// default plugin comes bundled with ovos-plugin-manager
"module": "ovos-segmentation-plugin-quebrafrases"
},
// given an utterance extract keywords from it
// can be used to get search terms
"keyword_extract": {
// default plugin comes bundled with ovos-classifiers
"module": "ovos-keyword-extractor-heuristic"
},
// tag pronouns with the word they refer to
// used in normalization step
"coref": {
// default plugin comes bundled with ovos-classifiers
"module": "ovos-coref-solver-heuristic"
},
"postag": {
// default plugin comes bundled with ovos-classifiers
"module": "ovos-classifiers-postag-plugin"
},
// turn utterances into phoneme sequences, used to generate mouth movements
// disabled by default, only relevant in devices such as the Mark 1
"g2p": {
"module": ""
},
// DEPRECATED: this section is in the process of being fully removed,
// only providing default values for clean migration
"padatious": {
// fallback settings for padacioso (pure regex)
// set regex_only to False to re-enable padatious (ovos-core < 0.0.8)
"regex_only": false,
"fuzz": true
},
// Translation plugins
"language": {
//by default uses public servers for translation
// https://github.com/OpenVoiceOS/ovos-translate-server
"detection_module": "ovos-lang-detector-plugin-server",
"translation_module": "ovos-translate-plugin-server",
// define translate fallbacks to use if plugin can not be loaded
"ovos-translate-plugin-server": {"fallback_module": "ovos-google-translate-plugin"},
// define detect fallbacks to use if plugin can not be loaded
"ovos-lang-detector-plugin-server": {"fallback_module": "ovos-google-lang-detector-plugin"},
"ovos-google-lang-detector-plugin": {"fallback_module": "ovos-lang-detector-plugin-cld3"},
"ovos-lang-detector-plugin-cld3": {"fallback_module": "ovos-lang-detector-plugin-cld2"},
"ovos-lang-detector-plugin-cld2": {"fallback_module": "ovos-lang-detector-plugin-langdetect"},
"ovos-lang-detector-plugin-langdetect": {"fallback_module": "ovos-lang-detector-plugin-fastlang"},
"ovos-lang-detector-plugin-fastlang": {"fallback_module": "ovos-lang-detector-plugin-lingua-podre"},
"ovos-lang-detector-plugin-fastlang": {"fallback_module": "ovos-lang-detect-ngram-lm"}
},
// placeholder to help in migration to ovos-media
// if set to False the legacy audio service won't load
"enable_old_audioservice": true,
"Audio": {
// message.context may contains a source and destination
// native audio (playback / TTS) will only be played if a
// message destination is a native_source or if missing (considered a broadcast)
"native_sources": ["debug_cli", "audio", "mycroft-gui"],
// default audio player to be used by old_audioservice
// needs to be set under "backends" section, if not installed the setting is ignored without errors
// DO NOT use "OCP", that is not a valid option and only located in "backends" for legacy reasons
"default-backend": "mpv",
"backends": {
"OCP": {
// LEGACY CONFIG - OCP is in the process of being replaced by ovos-media
// if you are already using ovos-media this config does nothing
"type": "ovos_common_play",
// define order of preference for playback plugins
"preferred_audio_services": ["mpv", "vlc", "simple"],
// allow OCP to be controlled via MPRIS
"disable_mpris": true,
// dbus type for MPRIS, "session" or "system"
"dbus_type": "session",
// if MPRIS is enabled above, also allow OCP to control MPRIS enabled 3rd party applications
// voice enables them (next/prev/stop/resume..)
// and stops them when OCP starts it's own playback
// NOTE: OCP can be controlled itself via MPRIS independentely of this setting
"manage_external_players": false,
"active": true
},
"simple": {
"type": "ovos_audio_simple",
"active": true
},
"vlc": {
"type": "ovos_vlc",
"active": true,
// volume used during audio_ducking
"initial_volume": 100,
"low_volume": 50
},
"mpv": {
"type": "ovos_mpv",
"active": true,
// volume used during audio_ducking
"initial_volume": 100,
"low_volume": 50
}
}
},
"debug": false
}
@@ -0,0 +1 @@
{}
@@ -0,0 +1,15 @@
{
"speak_alarm": false,
"speak_timer": true,
"sound_alarm": "constant_beep.mp3",
"sound_timer": "beep4.mp3",
"snooze_mins": 15,
"timeout_min": 1,
"play_volume": 90,
"escalate_volume": true,
"priority_cutoff": 8,
"services": "",
"frequency": 15,
"sync_ask": false,
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,4 @@
{
"play_sound": true,
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,4 @@
{
"log_level": "WARNING",
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,5 @@
{
"gender": "male",
"haunted": false,
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,6 @@
{
"apiv3": "8a2e8882b465b1cf7cce9ff6b35bdd7e",
"search_depth": 5,
"match_confidence": 0.8,
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,3 @@
{
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,8 @@
{% set vault_addr = 'https://vault.trez.wtf' %}
{% set secrets_path = 'benedikta-config/env' %}
{
"api_key": "{{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='benedikta-config', url=vault_addr, token=vault_token_cleaned)['secret']['HOME_ASSISTANT_LONG_LIVED_TOKEN'] }}",
"host": "192.168.1.250:8123",
"__mycroft_skill_firstrun": false
}
@@ -0,0 +1,24 @@
{
"location": {
"city": {
"code": "Queens",
"name": "Queens",
"state": {
"code": "NY",
"name": "New York",
"country": {
"code": "US",
"name": "United States"
}
}
},
"coordinate": {
"latitude": 40.7513,
"longitude": -73.8244
},
"timezone": {
"code": "America/New_York",
"name": "America/New_York"
}
}
}
@@ -0,0 +1,15 @@
{
"name": "Benedikta",
"solvers": [
"ovos-solver-openai-persona-plugin",
"ovos-wikipedia-solver",
"ovos-skill-ddg",
"ovos-wolfram-alpha-solver",
"ovos-skill-wordnet",
"ovos-solver-failure-plugin"
],
"ovos-solver-openai-persona-plugin": {
"api_url": "http://192.168.1.254:11434/api",
"persona": "helpful, creative, sarcastic, sassy, clever, and very friendly."
}
}
@@ -0,0 +1,12 @@
{
"name": "Phi4",
"solvers": [
"ovos-solver-openai-plugin",
"ovos-solver-failure-plugin"
],
"ovos-solver-openai-plugin": {
"api_url": "https://phi4.tigregotico.pt",
"key": "sk-xxx",
"model": "phi4"
}
}
@@ -0,0 +1,12 @@
{
"name": "Qwen",
"solvers": [
"ovos-solver-openai-plugin",
"ovos-solver-failure-plugin"
],
"ovos-solver-openai-plugin": {
"api_url": "https://qwen.tigregotico.pt",
"key": "sk-xxx",
"model": "qwen2.5"
}
}
@@ -0,0 +1,11 @@
{
"name": "Salamandra",
"solvers": [
"ovos-solver-openai-persona-plugin"
],
"ovos-solver-openai-persona-plugin": {
"api_url": "https://salamandra.tigregotico.pt",
"key": "sk-xxxx",
"persona": "helpful, creative, clever, and very friendly."
}
}
+105
View File
@@ -0,0 +1,105 @@
ovos-adapt-parser==1.0.8
ovos-audio==1.0.2a1
ovos-audio-plugin-mpv==0.2.1
ovos-audio-plugin-simple==0.1.2
ovos-audio-transformer-plugin-ggwave==0.0.5
ovos-bidirectional-translation-plugin==0.1.2
ovos-bus-client==1.3.5a1
ovos-common-query-pipeline-plugin==1.1.8
ovos-config==2.1.2a3
ovos-core==2.0.4a4
ovos-date-parser==0.6.3
ovos-dialog-normalizer-plugin==0.0.1
ovos-dinkum-listener==0.4.2a3
ovos-docs-viewer==0.0.1
ovos-gui==1.3.4a1
ovos-lang-parser==0.0.3a1
ovos-m2v-pipeline==0.0.6
ovos-mark1-utils==0.0.1
ovos-media-plugin-chromecast==0.1.2
ovos-media-plugin-spotify==0.2.6
ovos-microphone-plugin-alsa==0.1.2
ovos-microphone-plugin-sounddevice==0.0.1
ovos-number-parser==0.4.2
ovos-ocp-files-plugin==0.13.1
ovos-ocp-m3u-plugin==0.0.2a2
ovos-ocp-news-plugin==0.1.1
ovos-ocp-pipeline-plugin==1.1.16
ovos-ocp-rss-plugin==0.1.1
ovos-ocp-youtube-plugin==0.0.5
ovos-openai-plugin==2.0.3
ovos-padatious==1.4.2
ovos-persona==0.6.23
ovos-phal==0.2.10
ovos-phal-plugin-alsa==0.1.4
ovos-phal-plugin-balena-wifi==1.2.2
ovos-phal-plugin-camera==0.2.1
ovos-phal-plugin-connectivity-events==0.1.2
ovos-phal-plugin-ipgeo==0.1.6
ovos-phal-plugin-mk1==0.1.3
ovos-phal-plugin-network-manager==1.0.1a2
ovos-phal-plugin-oauth==0.1.3
ovos-phal-plugin-system==1.3.4a1
ovos-phal-plugin-wallpaper-manager==0.2.5
ovos-phal-plugin-wifi-setup==1.1.1a1
ovos-plugin-common-play==1.2.1
ovos-plugin-manager==1.0.4a2
ovos-skill-alerts==0.1.27
ovos-skill-audio-recording==0.2.7
ovos-skill-boot-finished==0.5.0
ovos-skill-camera==1.0.5a3
ovos-skill-config-tool==0.7.0
ovos-skill-confucius-quotes==0.1.13
ovos-skill-date-time==1.1.4
ovos-skill-days-in-history==0.3.11
ovos-skill-ddg==0.3.6a1
ovos-skill-diagnostics==0.0.8
ovos-skill-dictation==0.2.19
ovos-skill-fallback-unknown==0.1.9
ovos-skill-hello-world==0.2.3a2
ovos-skill-icanhazdadjokes==0.3.7
ovos-skill-ip==0.2.8
ovos-skill-iss-location==0.2.16
ovos-skill-laugh==0.2.3
ovos-skill-local-media==0.2.12
ovos-skill-moviemaster==0.0.12
ovos-skill-naptime==0.3.15
ovos-skill-news==0.4.5
ovos-skill-number-facts==0.1.12
ovos-skill-parrot==0.1.25
ovos-skill-personal==0.1.19
ovos-skill-pyradios==0.1.5
ovos-skill-randomness==0.1.2
ovos-skill-somafm==0.1.6a1
ovos-skill-speedtest==0.3.6
ovos-skill-spelling==0.2.6
ovos-skill-volume==0.1.16
ovos-skill-weather==1.0.5a1
ovos-skill-wikihow==0.3.3
ovos-skill-wikipedia==0.8.13
ovos-skill-wolfie==0.5.8
ovos-skill-word-of-the-day==0.2.0
ovos-skill-wordnet==0.2.6a1
ovos-skill-youtube-music==0.1.7
ovos-solver-bm25-plugin==0.0.1
ovos-solver-failure-plugin==0.0.2
ovos-solver-yes-no-plugin==0.2.9a3
ovos-stt-plugin-chromium==0.1.2
ovos-stt-plugin-fasterwhisper==0.0.1a8
ovos-stt-plugin-server==0.1.2
ovos-translate-server-plugin==0.0.4
ovos-tts-plugin-server==0.0.4
ovos-utils==0.8.1
ovos-utterance-corrections-plugin==0.1.1
ovos-utterance-normalizer==0.2.2
ovos-utterance-plugin-cancel==0.2.4
ovos-vad-plugin-noise==0.1.2
ovos-vad-plugin-silero==0.0.5
ovos-wikipedia-solver==0.1.1
ovos-wolfram-alpha-solver==0.0.3
ovos-workshop==7.0.9a1
ovos-ww-plugin-openwakeword==0.4.1
ovos-ww-plugin-precise-lite==0.1.6
ovos-ww-plugin-vosk==0.1.7
ovos-yaml-editor==0.0.2
skill-homeassistant==0.5.1
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/gmrender.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-audio.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-ggwave.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-gui.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-listener.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-messagebus.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-phal.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-skill-settings-ui.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-skills.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos-yaml-editor.service
@@ -0,0 +1 @@
/home/ovos/.config/systemd/user/ovos.service
@@ -0,0 +1,15 @@
[Unit]
Description=DLNA Renderer
Wants=network-online.target sound.target
After=network-online.target sound.target
PartOf=ovos.service
After=ovos.service
[Service]
ExecStartPre=/bin/sleep 15
ExecStart=/usr/bin/gmediarender -f "RaspOVOS" --gstout-audiosink=pipewiresink --logfile=stdout --mime-filter audio
Restart=always
[Install]
WantedBy=default.target
@@ -0,0 +1,19 @@
[Unit]
Description=OVOS Audio
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
[Service]
Type=notify
Group=ovos
UMask=002
ExecStart=%h/.venvs/ovos/bin/python /usr/libexec/ovos-systemd-audio
TimeoutStartSec=10m
TimeoutStopSec=1m
Restart=on-failure
StartLimitInterval=5min
StartLimitBurst=4
[Install]
WantedBy=ovos.service
@@ -0,0 +1,19 @@
[Unit]
Documentation=https://github.com/OpenVoiceOS/ovos-audio-transformer-plugin-ggwave
Description=Open Voice OS - ggwave listener
PartOf=ovos.service
Requires=ovos.service ovos-messagebus.service
[Service]
Group=ovos
UMask=002
WorkingDirectory=%h/.venvs/ovos
ExecStart=%h/.venvs/ovos/bin/ovos-ggwave-listener
ExecReload=/usr/bin/kill -s HUP $MAINPID
ExecStop=/usr/bin/kill -s KILL $MAINPID
Restart=on-failure
RestartSec=5s
StartLimitBurst=0
[Install]
WantedBy=ovos.service
@@ -0,0 +1,20 @@
[Unit]
Description=OVOS GUI Websocket
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
[Service]
Type=notify
Group=ovos
UMask=002
ExecStart=%h/.venvs/ovos/bin/python /usr/libexec/ovos-systemd-gui
TimeoutStartSec=1m
TimeoutStopSec=1m
Restart=on-failure
StartLimitInterval=5min
StartLimitBurst=4
[Install]
WantedBy=ovos.service
@@ -0,0 +1,16 @@
[Unit]
Description=OVOS Librespot OCP Hooks
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
Wants=network-online.target
After=network-online.target
[Service]
Group=ovos
UMask=002
ExecStart=/usr/bin/librespot --name raspOVOS --device-type "speaker" --initial-volume 100 --onevent "/usr/libexec/ovos-librespot"
Restart=on-failure
[Install]
WantedBy=ovos.service
@@ -0,0 +1,20 @@
[Unit]
Description=OVOS Listener
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
[Service]
Type=notify
Group=ovos
UMask=002
ExecStart=%h/.venvs/ovos/bin/python /usr/libexec/ovos-systemd-listener
TimeoutStartSec=5m
TimeoutStopSec=1m
Restart=on-failure
StartLimitInterval=5min
StartLimitBurst=4
[Install]
WantedBy=ovos.service
@@ -0,0 +1,13 @@
[Unit]
Description=OVOS Messagebus (Rust)
PartOf=ovos.service
After=ovos.service
[Service]
Group=ovos
UMask=002
ExecStart=/usr/local/bin/ovos_rust_messagebus
Restart=on-failure
[Install]
WantedBy=ovos.service
@@ -0,0 +1,20 @@
[Unit]
Description=OVOS PHAL
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
[Service]
Group=ovos
Type=notify
UMask=002
ExecStart=%h/.venvs/ovos/bin/python /usr/libexec/ovos-systemd-phal
TimeoutStartSec=1m
TimeoutStopSec=1m
Restart=on-failure
StartLimitInterval=5min
StartLimitBurst=4
[Install]
WantedBy=ovos.service
@@ -0,0 +1,16 @@
[Unit]
Description=OVOS Skill Settings Editor
PartOf=ovos.service
After=ovos.service
[Service]
Group=ovos
UMask=002
Environment="OVOS_CONFIG_USERNAME=ovos"
Environment="OVOS_CONFIG_PASSWORD=ovos"
WorkingDirectory=%h/.venvs/ovos
ExecStart=%h/.venvs/ovos/bin/ovos-skill-config-tool
[Install]
WantedBy=ovos.service
@@ -0,0 +1,20 @@
[Unit]
Description=OVOS Skills
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
[Service]
Type=notify
Group=ovos
UMask=002
ExecStart=%h/.venvs/ovos/bin/python /usr/libexec/ovos-systemd-skills
TimeoutStartSec=10m
TimeoutStopSec=1m
Restart=on-failure
StartLimitInterval=5min
StartLimitBurst=4
[Install]
WantedBy=ovos.service
@@ -0,0 +1,16 @@
[Unit]
Description=OVOS Spotifyd OCP Hooks
PartOf=ovos.service
After=ovos.service
After=ovos-messagebus.service
Wants=network-online.target
After=network-online.target
[Service]
Group=ovos
UMask=002
ExecStart=/usr/bin/spotifyd --name raspOVOS --device-type "speaker" --initial-volume 100 --on-song-change-hook "/home/ovos/.venvs/ovos/bin/python /usr/libexec/ovos-spotifyd"
Restart=on-failure
[Install]
WantedBy=ovos.service
@@ -0,0 +1,16 @@
[Unit]
Description=OVOS Config WebUI
PartOf=ovos.service
After=ovos.service
[Service]
Group=ovos
UMask=002
Environment="EDITOR_USERNAME=ovos"
Environment="EDITOR_PASSWORD=ovos"
WorkingDirectory=%h/.venvs/ovos
ExecStart=%h/.venvs/ovos/bin/ovos-yaml-editor
[Install]
WantedBy=ovos.service
@@ -0,0 +1,11 @@
[Unit]
Description=OVOS A.I. Software stack.
[Service]
Type=oneshot
Group=ovos
ExecStart=/bin/true
RemainAfterExit=yes
[Install]
WantedBy=default.target
@@ -0,0 +1,183 @@
{% set vault_addr = 'https://vault.trez.wtf' %}
{% set secrets_path = 'rikku-docker/env' %}
http:
pprof:
port: 6060
enabled: false
address: 0.0.0.0:80
session_ttl: 720h
users:
- name: admin
password: {{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='rikku-docker', url=vault_addr, token=vault_token_cleaned)['secret']['ADGUARD_BCRYPT'] }}
auth_attempts: 5
block_auth_min: 15
http_proxy: ""
language: ""
theme: auto
dns:
bind_hosts:
- 0.0.0.0
port: 53
anonymize_client_ip: false
ratelimit: 20
ratelimit_subnet_len_ipv4: 24
ratelimit_subnet_len_ipv6: 56
ratelimit_whitelist: []
refuse_any: true
upstream_dns:
- 192.168.1.254
upstream_dns_file: ""
bootstrap_dns:
- 1.1.1.1
fallback_dns: []
upstream_mode: load_balance
fastest_timeout: 1s
allowed_clients: []
disallowed_clients: []
blocked_hosts:
- version.bind
- id.server
- hostname.bind
trusted_proxies:
- 127.0.0.0/8
- ::1/128
cache_enabled: true
cache_size: 4194304
cache_ttl_min: 0
cache_ttl_max: 0
cache_optimistic: false
bogus_nxdomain: []
aaaa_disabled: false
enable_dnssec: false
edns_client_subnet:
custom_ip: ""
enabled: false
use_custom: false
max_goroutines: 300
handle_ddr: true
ipset: []
ipset_file: ""
bootstrap_prefer_ipv6: false
upstream_timeout: 10s
private_networks: []
use_private_ptr_resolvers: true
local_ptr_upstreams: []
use_dns64: false
dns64_prefixes: []
serve_http3: false
use_http3_upstreams: false
serve_plain_dns: true
hostsfile_enabled: true
pending_requests:
enabled: true
tls:
enabled: false
server_name: ""
force_https: false
port_https: 443
port_dns_over_tls: 853
port_dns_over_quic: 853
port_dnscrypt: 0
dnscrypt_config_file: ""
allow_unencrypted_doh: false
certificate_chain: ""
private_key: ""
certificate_path: ""
private_key_path: ""
strict_sni_check: false
querylog:
dir_path: ""
ignored: []
interval: 2160h
size_memory: 1000
enabled: true
file_enabled: true
statistics:
dir_path: ""
ignored: []
interval: 24h
enabled: true
filters:
- enabled: true
url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
name: AdGuard DNS filter
id: 1
- enabled: false
url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt
name: AdAway Default Blocklist
id: 2
whitelist_filters: []
user_rules: []
dhcp:
enabled: true
interface_name: wlan0
local_domain_name: lan
dhcpv4:
gateway_ip: 192.168.1.1
subnet_mask: 255.255.255.0
range_start: 192.168.1.2
range_end: 192.168.1.254
lease_duration: 86400
icmp_timeout_msec: 1000
options: []
dhcpv6:
range_start: ""
lease_duration: 86400
ra_slaac_only: false
ra_allow_slaac: false
filtering:
blocking_ipv4: ""
blocking_ipv6: ""
blocked_services:
schedule:
time_zone: America/New_York
ids: []
protection_disabled_until: null
safe_search:
enabled: false
bing: true
duckduckgo: true
ecosia: true
google: true
pixabay: true
yandex: true
youtube: true
blocking_mode: default
parental_block_host: family-block.dns.adguard.com
safebrowsing_block_host: standard-block.dns.adguard.com
rewrites: []
safe_fs_patterns:
- /opt/adguardhome/work/userfilters/*
safebrowsing_cache_size: 1048576
safesearch_cache_size: 1048576
parental_cache_size: 1048576
cache_time: 30
filters_update_interval: 24
blocked_response_ttl: 10
filtering_enabled: true
parental_enabled: false
safebrowsing_enabled: false
protection_enabled: true
clients:
runtime_sources:
whois: true
arp: true
rdns: true
dhcp: true
hosts: true
persistent: []
log:
enabled: true
file: ""
max_backups: 0
max_size: 100
max_age: 3
compress: false
local_time: false
verbose: false
os:
group: ""
user: ""
rlimit_nofile: 0
schema_version: 30
@@ -0,0 +1,51 @@
- id: '1654749081487'
alias: Vacuum schedule
description: ''
trigger:
- platform: time
at: '11:00:00'
condition: []
action:
- parallel:
- device_id: 4f4ac1838a8d32748da9d2e5aa54c748
domain: vacuum
entity_id: vacuum.johnny_5
type: clean
- service: vacuum.set_fan_speed
data:
fan_speed: Eco
target:
device_id: 4f4ac1838a8d32748da9d2e5aa54c748
entity_id: vacuum.johnny_5
mode: single
- id: '1687209973222'
alias: After Pool
description: Night lights
trigger:
- platform: state
entity_id:
- binary_sensor.front_door_sensor_opening
from: 'off'
to: 'on'
condition:
- condition: and
conditions:
- condition: device
device_id: 8381d589cc257ba882fb80036224e53d
domain: device_tracker
entity_id: device_tracker.pixel_7_3
type: is_home
- condition: sun
before: sunrise
after: sunset
- condition: time
weekday:
- mon
- tue
- thu
action:
- service: scene.turn_on
target:
entity_id: scene.night_time
metadata: {}
mode: single
@@ -0,0 +1,27 @@
homeassistant:
# Name of the location where Home Assistant is running
name: "Rikku"
# Location required to calculate the time the sun rises and sets
latitude: 40
longitude: -73
# 'metric' for Metric, 'us_customary' for US Customary
unit_system: us_customary
# Pick yours from here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
time_zone: "America/New_York"
# Configure a default setup of Home Assistant (frontend, api, etc)
default_config:
# Text to speech
tts:
- platform: google_translate
# automation: !include automations.yaml
# script: !include scripts.yaml
# scene: !include scenes.yaml
http:
use_x_forwarded_for: true
trusted_proxies: 192.168.1.254
browser:
@@ -0,0 +1,27 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Cloudflare Tunnel component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Cloudflare Tunnel from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor")
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
else:
_LOGGER.error("Failed to unload entry: %s", entry.entry_id)
return unload_ok
@@ -0,0 +1,76 @@
import aiohttp
import async_timeout
import voluptuous as vol
from homeassistant import config_entries, exceptions
from .const import DOMAIN, CONF_API_KEY, CONF_ACCOUNT_ID, LABEL_API_KEY, LABEL_ACCOUNT_ID, PLACEHOLDER_API_KEY, PLACEHOLDER_ACCOUNT_ID
# Constants
URL = "https://api.cloudflare.com/client/v4/user/tokens/verify"
TIMEOUT = 10
# Custom exceptions
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
DATA_SCHEMA = vol.Schema({
vol.Required(CONF_ACCOUNT_ID, description={LABEL_ACCOUNT_ID}): str,
vol.Required(CONF_API_KEY, description={LABEL_API_KEY}): str
})
async def validate_credentials(hass, data):
"""Validate the provided credentials are correct."""
api_key = data["api_key"]
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
try:
async with aiohttp.ClientSession() as session:
async with async_timeout.timeout(TIMEOUT):
async with session.get(URL, headers=headers) as response:
if response.status == 200:
return True
elif response.status == 401:
raise InvalidAuth
else:
raise CannotConnect
except aiohttp.ClientError:
raise CannotConnect
except async_timeout.TimeoutError:
raise CannotConnect
class CloudflareConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Cloudflare config flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is not None:
try:
await validate_credentials(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
errors["base"] = "unknown"
else:
return self.async_create_entry(title="Cloudflare Tunnel Monitor", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
CONF_API_KEY: PLACEHOLDER_API_KEY,
CONF_ACCOUNT_ID: PLACEHOLDER_ACCOUNT_ID,
}
)
@@ -0,0 +1,14 @@
DOMAIN = "cloudflare_tunnel_monitor"
# Constants for user data keys
CONF_ACCOUNT_ID = "account_id"
CONF_API_KEY = "api_key"
# Constants for labels displayed in the user interface
LABEL_ACCOUNT_ID = "Account ID"
LABEL_API_KEY = "API Token"
# Constants for placeholders or suggested values
PLACEHOLDER_API_KEY = "api-token"
PLACEHOLDER_ACCOUNT_ID = "your-account-id"
@@ -0,0 +1,249 @@
import logging
import aiohttp
import asyncio
import async_timeout
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.entity import Entity
from homeassistant.exceptions import PlatformNotReady
from datetime import timedelta
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Constants
URL = "https://api.cloudflare.com/client/v4/accounts/{}/cfd_tunnel?is_deleted=false"
TIMEOUT = 10
RETRY_DELAY = 20
MAX_RETRIES = 5
def create_headers(api_key):
"""Create headers for API requests."""
return {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
async def fetch_tunnels(api_key, account_id, hass, entry_id, retries=0):
"""Retrieve Cloudflare tunnel status using aiohttp."""
headers = create_headers(api_key)
url = URL.format(account_id)
_LOGGER.debug(f"Attempt {retries + 1} to fetch tunnels from URL: {url}")
try:
async with aiohttp.ClientSession() as session:
async with async_timeout.timeout(TIMEOUT):
async with session.get(url, headers=headers) as response:
_LOGGER.debug(f"Response status: {response.status}")
if response.status == 200:
json_response = await response.json()
_LOGGER.debug(f"Received data: {json_response}")
return json_response['result']
elif response.status == 401:
raise UpdateFailed("Unauthorized access - check your API key")
else:
raise UpdateFailed(f"Error fetching Cloudflare tunnels: {response.status}, {response.reason}")
except aiohttp.ClientError as err:
_LOGGER.error(f"Client error fetching data: {err}")
raise UpdateFailed("Client error occurred while fetching data") from err
except asyncio.TimeoutError:
_LOGGER.error("Timeout error fetching data")
raise UpdateFailed("Timeout error occurred while fetching data")
except Exception as err:
_LOGGER.error(f"Unexpected error fetching data: {err}")
if retries < MAX_RETRIES:
_LOGGER.info(f"Retrying in {RETRY_DELAY} seconds...")
await asyncio.sleep(RETRY_DELAY)
return await fetch_tunnels(api_key, account_id, hass, entry_id, retries + 1)
else:
_LOGGER.error("Maximum number of retries reached, scheduling integration reload")
await schedule_integration_reload(hass, entry_id)
raise UpdateFailed("Maximum retries reached, integration reload scheduled")
class CloudflareTunnelsDevice(Entity):
"""Representation of the Cloudflare Tunnels device."""
def __init__(self, account_id, domain):
"""Initialize the Cloudflare Tunnels device."""
self._account_id = account_id
self._domain = domain
@property
def unique_id(self):
"""Return a unique ID."""
return f"{self._domain}_cloudflare_tunnels_{self._account_id}"
@property
def name(self):
"""Return the name of the device."""
return "Cloudflare Tunnels"
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(self._domain, self.unique_id)},
"name": self.name,
"manufacturer": "Cloudflare",
}
class CloudflareTunnelSensor(SensorEntity):
"""Representation of a Cloudflare tunnel sensor."""
def __init__(self, tunnel, coordinator, device):
"""Initialize the Cloudflare tunnel sensor."""
self.coordinator = coordinator
self._tunnel = tunnel
self._device = device
@property
def name(self):
"""Return the name of the sensor."""
return f"Cloudflare Tunnel {self._tunnel['name']}"
@property
def unique_id(self):
"""Return a unique ID."""
return f"{self._device._domain}_{self._tunnel['id']}"
@property
def state(self):
"""Return the state of the sensor."""
return self._tunnel['status']
@property
def icon(self):
"""Return the icon of the sensor."""
return 'mdi:cloud-check' if self._tunnel['status'] == 'healthy' else 'mdi:cloud-off-outline'
@property
def options(self):
"""Return the possible values of the sensor."""
return ["inactive", "degraded", "healthy", "down"]
@property
def device_class(self):
"""Return the device class of the sensor."""
return SensorDeviceClass.ENUM
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(self._device._domain, self._device.unique_id)},
"name": self._device.name,
"manufacturer": "Cloudflare",
}
async def async_update(self):
"""Update the state of the sensor."""
_LOGGER.debug(f"Requesting refresh for tunnel {self._tunnel['id']}")
await self.coordinator.async_request_refresh()
if self.coordinator.data is not None:
_LOGGER.debug(f"Coordinator data is not None. Searching for updated tunnel data for {self._tunnel['id']}")
updated_tunnel = next((tunnel for tunnel in self.coordinator.data if tunnel.get('id') == self._tunnel.get('id')), None)
if updated_tunnel is not None:
_LOGGER.debug(f"Found updated data for tunnel {self._tunnel['id']}")
self._tunnel = updated_tunnel
_LOGGER.debug("Tunnel updated data: %s", self._tunnel)
else:
_LOGGER.error("Tunnel with ID %s not found in the updated data", self._tunnel.get('id'))
else:
_LOGGER.error("No data received in coordinator during update, maintaining previous state")
class CloudflareTunnelManager:
"""Manages Cloudflare Tunnel Sensor entities."""
def __init__(self, hass, async_add_entities, coordinator, device):
self._hass = hass
self._async_add_entities = async_add_entities
self._coordinator = coordinator
self._device = device
self._sensors = {}
async def update_sensors(self, new_tunnels, removed_tunnels):
"""Update sensor entities based on the tunnel changes."""
_LOGGER.debug(f"Updating sensors. New: {new_tunnels}, Removed: {removed_tunnels}")
for tunnel in new_tunnels:
sensor_id = f"{self._device._domain}_{tunnel['id']}"
if sensor_id not in self._sensors:
_LOGGER.info(f"Adding new sensor for tunnel: {tunnel['id']}")
sensor = CloudflareTunnelSensor(tunnel, self._coordinator, self._device)
self._sensors[sensor_id] = sensor
self._async_add_entities([sensor], True)
for tunnel in removed_tunnels:
sensor_id = f"{self._device._domain}_{tunnel['id']}"
if sensor_id in self._sensors:
_LOGGER.info(f"Removing sensor for tunnel: {sensor_id}")
try:
sensor = self._sensors.pop(sensor_id)
await sensor.async_remove()
except Exception as e:
_LOGGER.error(f"Error removing sensor for tunnel {sensor_id}: {e}")
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Cloudflare tunnel sensor."""
api_key = config_entry.data["api_key"]
account_id = config_entry.data["account_id"]
device = CloudflareTunnelsDevice(account_id, DOMAIN)
async def async_update_data():
"""Fetch data from API endpoint and detect changes in tunnels."""
_LOGGER.debug("Fetching new tunnel data from Cloudflare")
new_data = await fetch_tunnels(api_key, account_id, hass, config_entry.entry_id)
if new_data is None:
new_data = []
if coordinator.data is None:
coordinator.data = []
current_ids = {tunnel['id'] for tunnel in coordinator.data}
new_ids = {tunnel['id'] for tunnel in new_data}
added_tunnels = [tunnel for tunnel in new_data if tunnel['id'] not in current_ids]
removed_tunnels = [tunnel for tunnel in coordinator.data if tunnel['id'] not in new_ids]
_LOGGER.debug(f"Added tunnels: {added_tunnels}, Removed tunnels: {removed_tunnels}")
if added_tunnels or removed_tunnels:
await tunnel_manager.update_sensors(added_tunnels, removed_tunnels)
return new_data
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="cloudflare_tunnel",
update_method=async_update_data,
update_interval=timedelta(minutes=1),
)
tunnel_manager = CloudflareTunnelManager(hass, async_add_entities, coordinator, device)
await coordinator.async_config_entry_first_refresh()
if not hasattr(tunnel_manager, 'initialized') or not tunnel_manager.initialized:
_LOGGER.debug("Creating initial sensor entities")
for tunnel in coordinator.data:
sensor_id = f"{device._domain}_{tunnel['id']}"
if sensor_id not in tunnel_manager._sensors:
sensor = CloudflareTunnelSensor(tunnel, coordinator, device)
tunnel_manager._sensors[sensor_id] = sensor
async_add_entities([sensor], True)
tunnel_manager.initialized = True
hass.bus.async_listen_once("homeassistant_stop", async_shutdown)
async def async_shutdown(event):
"""Close aiohttp session when Home Assistant stops."""
_LOGGER.debug("Cloudflare Tunnel Monitor - aiohttp session closed")
async def schedule_integration_reload(hass, entry_id):
"""Schedule a reload of the integration."""
_LOGGER.info(f"Scheduling reload of integration with entry_id {entry_id}")
await hass.config_entries.async_reload(entry_id)
@@ -0,0 +1,38 @@
"""The Cync Room Lights integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .cync_hub import CyncHub
PLATFORMS: list[str] = ["light","binary_sensor","switch","fan"]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Cync Room Lights from a config entry."""
hass.data.setdefault(DOMAIN, {})
remove_options_update_listener = entry.add_update_listener(options_update_listener)
hub = CyncHub(entry.data, entry.options, remove_options_update_listener)
hass.data[DOMAIN][entry.entry_id] = hub
hub.start_tcp_client()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def options_update_listener(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hub = hass.data[DOMAIN][entry.entry_id]
hub.remove_options_update_listener()
hub.disconnect()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
@@ -0,0 +1,123 @@
"""Platform for binary sensor integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass, BinarySensorEntity)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
hub = hass.data[DOMAIN][config_entry.entry_id]
new_devices = []
for sensor in hub.cync_motion_sensors:
if not hub.cync_motion_sensors[sensor]._update_callback and sensor in config_entry.options["motion_sensors"]:
new_devices.append(CyncMotionSensorEntity(hub.cync_motion_sensors[sensor]))
for sensor in hub.cync_ambient_light_sensors:
if not hub.cync_ambient_light_sensors[sensor]._update_callback and sensor in config_entry.options["ambient_light_sensors"]:
new_devices.append(CyncAmbientLightSensorEntity(hub.cync_ambient_light_sensors[sensor]))
if new_devices:
async_add_entities(new_devices)
class CyncMotionSensorEntity(BinarySensorEntity):
"""Representation of a Cync Motion Sensor."""
should_poll = False
def __init__(self, motion_sensor) -> None:
"""Initialize the sensor."""
self.motion_sensor = motion_sensor
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.motion_sensor.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.motion_sensor.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.motion_sensor.room.name} ({self.motion_sensor.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.motion_sensor.room.name} ({self.motion_sensor.home_name})",
suggested_area = f"{self.motion_sensor.room.name}",
)
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
return 'cync_motion_sensor_' + self.motion_sensor.device_id
@property
def name(self) -> str:
"""Return the name of the motion_sensor."""
return self.motion_sensor.name + " Motion"
@property
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.motion_sensor.motion
@property
def device_class(self) -> str | None:
"""Return the device class"""
return BinarySensorDeviceClass.MOTION
class CyncAmbientLightSensorEntity(BinarySensorEntity):
"""Representation of a Cync Ambient Light Sensor."""
should_poll = False
def __init__(self, ambient_light_sensor) -> None:
"""Initialize the sensor."""
self.ambient_light_sensor = ambient_light_sensor
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.ambient_light_sensor.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.ambient_light_sensor.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.ambient_light_sensor.room.name} ({self.ambient_light_sensor.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.ambient_light_sensor.room.name} ({self.ambient_light_sensor.home_name})",
suggested_area = f"{self.ambient_light_sensor.room.name}",
)
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
return 'cync_ambient_light_sensor_' + self.ambient_light_sensor.device_id
@property
def name(self) -> str:
"""Return the name of the ambient_light_sensor."""
return self.ambient_light_sensor.name + " Ambient Light"
@property
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.ambient_light_sensor.ambient_light
@property
def device_class(self) -> str | None:
"""Return the device class"""
return BinarySensorDeviceClass.LIGHT
@@ -0,0 +1,289 @@
"""Config flow for Cync Room Lights integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from typing import Any
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.core import callback
from .const import DOMAIN
from .cync_hub import CyncUserData
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("username"): str,
vol.Required("password"): str,
}
)
STEP_TWO_FACTOR_CODE = vol.Schema(
{
vol.Required("two_factor_code"): str,
}
)
async def cync_login(hub, user_input: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input"""
response = await hub.authenticate(user_input["username"], user_input["password"])
if response['authorized']:
return {'title':'cync_lights_'+ user_input['username'],'data':{'cync_credentials': hub.auth_code, 'user_input':user_input}}
else:
if response['two_factor_code_required']:
raise TwoFactorCodeRequired
else:
raise InvalidAuth
async def submit_two_factor_code(hub, user_input: dict[str, Any]) -> dict[str, Any]:
"""Validate the two factor code"""
response = await hub.auth_two_factor(user_input["two_factor_code"])
if response['authorized']:
return {'title':'cync_lights_'+ hub.username,'data':{'cync_credentials': hub.auth_code, 'user_input': {'username':hub.username,'password':hub.password}}}
else:
raise InvalidAuth
class CyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cync Room Lights."""
def __init__(self):
self.cync_hub = CyncUserData()
self.data ={}
self.options = {}
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user and password for Cync account."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors = {}
try:
info = await cync_login(self.cync_hub, user_input)
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
except TwoFactorCodeRequired:
return await self.async_step_two_factor_code()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception as e: # pylint: disable=broad-except
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
errors["base"] = "unknown"
else:
self.data = info
return await self.async_step_finish_setup()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_two_factor_code(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle two factor authentication for Cync account."""
if user_input is None:
return self.async_show_form(
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE
)
errors = {}
try:
info = await submit_two_factor_code(self.cync_hub, user_input)
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception as e: # pylint: disable=broad-except
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
errors["base"] = "unknown"
else:
self.data = info
return await self.async_step_select_switches()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_select_switches(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Select rooms and individual switches for entity creation"""
if user_input is not None:
self.options = user_input
return await self._async_finish_setup()
switches_data_schema = vol.Schema(
{
vol.Optional(
"rooms",
description = {"suggested_value" : [room for room in self.data["data"]["cync_config"]["rooms"].keys() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']]},
): cv.multi_select({room : f'{room_info["name"]} ({room_info["home_name"]})' for room,room_info in self.data["data"]["cync_config"]["rooms"].items() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
vol.Optional(
"subgroups",
description = {"suggested_value" : [room for room in self.data["data"]["cync_config"]["rooms"].keys() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']]},
): cv.multi_select({room : f'{room_info["name"]} ({room_info.get("parent_room","")}:{room_info["home_name"]})' for room,room_info in self.data["data"]["cync_config"]["rooms"].items() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
vol.Optional(
"switches",
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['FAN']]},
): cv.multi_select({switch_id : f'{sw_info["name"]} ({sw_info["room_name"]}:{sw_info["home_name"]})' for switch_id,sw_info in self.data["data"]["cync_config"]["devices"].items() if sw_info.get('ONOFF',False) and sw_info.get('MULTIELEMENT',1) == 1}),
vol.Optional(
"motion_sensors",
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['MOTION']]},
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info.get('MOTION',False)}),
vol.Optional(
"ambient_light_sensors",
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['AMBIENT_LIGHT']]},
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info.get('AMBIENT_LIGHT',False)}),
}
)
return self.async_show_form(step_id="select_switches", data_schema=switches_data_schema)
async def _async_finish_setup(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Finish setup and create entry"""
existing_entry = await self.async_set_unique_id(self.data['title'])
if not existing_entry:
return self.async_create_entry(title=self.data["title"], data=self.data["data"], options=self.options)
else:
self.hass.config_entries.async_update_entry(existing_entry, data=self.data['data'], options=self.options)
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.hass.config_entries.async_abort(reason="reauth_successful")
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return CyncOptionsFlowHandler(config_entry)
class CyncOptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.entry = config_entry
self.cync_hub = CyncUserData()
self.data = {}
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
if user_input['re-authenticate'] == "No":
return await self.async_step_select_switches()
else:
return await self.async_step_auth()
data_schema = vol.Schema(
{
vol.Required(
"re-authenticate",default="No"): vol.In(["Yes","No"]),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Attempt to re-authenticate"""
errors = {}
try:
info = await cync_login(self.cync_hub, self.entry.data['user_input'])
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
except TwoFactorCodeRequired:
return await self.async_step_two_factor_code()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception as e: # pylint: disable=broad-except
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
errors["base"] = "unknown"
else:
self.data = info
return await self.async_step_select_switches()
async def async_step_two_factor_code(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle two factor authentication for Cync account."""
if user_input is None:
return self.async_show_form(
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE
)
errors = {}
try:
info = await submit_two_factor_code(self.cync_hub, user_input)
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception as e: # pylint: disable=broad-except
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
errors["base"] = "unknown"
else:
self.data = info
return await self.async_step_select_switches()
return self.async_show_form(
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE, errors=errors
)
async def async_step_select_switches(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if "data" in self.data and self.data["data"] != self.entry.data:
self.hass.config_entries.async_update_entry(self.entry, data = self.data["data"])
if user_input is not None:
return self.async_create_entry(title="",data=user_input)
switches_data_schema = vol.Schema(
{
vol.Optional(
"rooms",
description = {"suggested_value" : [room for room in self.entry.options["rooms"] if room in self.entry.data["cync_config"]["rooms"].keys()]},
): cv.multi_select({room : f'{room_info["name"]} ({room_info["home_name"]})' for room,room_info in self.entry.data["cync_config"]["rooms"].items() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
vol.Optional(
"subgroups",
description = {"suggested_value" : [room for room in self.entry.options["subgroups"] if room in self.entry.data["cync_config"]["rooms"].keys()]},
): cv.multi_select({room : f'{room_info["name"]} ({room_info.get("parent_room","")}:{room_info["home_name"]})' for room,room_info in self.entry.data["cync_config"]["rooms"].items() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
vol.Optional(
"switches",
description = {"suggested_value" : [sw for sw in self.entry.options["switches"] if sw in self.entry.data["cync_config"]["devices"].keys()]},
): cv.multi_select({switch_id : f'{sw_info["name"]} ({sw_info["room_name"]}:{sw_info["home_name"]})' for switch_id,sw_info in self.entry.data["cync_config"]["devices"].items() if sw_info.get('ONOFF',False) and sw_info.get('MULTIELEMENT',1) == 1}),
vol.Optional(
"motion_sensors",
description = {"suggested_value" : [sensor for sensor in self.entry.options["motion_sensors"] if sensor in self.entry.data["cync_config"]["devices"].keys()]},
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.entry.data["cync_config"]["devices"].items() if device_info.get('MOTION',False)}),
vol.Optional(
"ambient_light_sensors",
description = {"suggested_value" : [sensor for sensor in self.entry.options["ambient_light_sensors"] if sensor in self.entry.data["cync_config"]["devices"].keys()]},
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.entry.data["cync_config"]["devices"].items() if device_info.get('AMBIENT_LIGHT',False)}),
}
)
return self.async_show_form(step_id="select_switches", data_schema=switches_data_schema)
class TwoFactorCodeRequired(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
@@ -0,0 +1,3 @@
"""Constants for the Cync Room Lights integration."""
DOMAIN = "cync_lights"
@@ -0,0 +1,870 @@
import logging
import threading
import asyncio
import struct
import aiohttp
import math
import ssl
from typing import Any
_LOGGER = logging.getLogger(__name__)
API_AUTH = "https://api.gelighting.com/v2/user_auth"
API_REQUEST_CODE = "https://api.gelighting.com/v2/two_factor/email/verifycode"
API_2FACTOR_AUTH = "https://api.gelighting.com/v2/user_auth/two_factor"
API_DEVICES = "https://api.gelighting.com/v2/user/{user}/subscribe/devices"
API_DEVICE_INFO = "https://api.gelighting.com/v2/product/{product_id}/device/{device_id}/property"
Capabilities = {
"ONOFF":[1,5,6,7,8,9,10,11,13,14,15,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,48,49,51,52,53,54,55,56,57,58,59,61,62,63,64,65,66,67,68,80,81,82,83,85,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
"BRIGHTNESS":[1,5,6,7,8,9,10,11,13,14,15,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,48,49,55,56,80,81,82,83,85,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
"COLORTEMP":[5,6,7,8,10,11,14,15,19,20,21,22,23,25,26,28,29,30,31,32,33,34,35,80,82,83,85,129,130,131,132,133,135,136,137,138,139,140,141,142,143,144,145,146,147,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
"RGB":[6,7,8,21,22,23,30,31,32,33,34,35,131,132,133,137,138,139,140,141,142,143,146,147,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
"MOTION":[37,49,54],
"AMBIENT_LIGHT":[37,49,54],
"WIFICONTROL":[36,37,38,39,40,48,49,51,52,53,54,55,56,57,58,59,61,62,63,64,65,66,67,68,80,81,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
"PLUG":[64,65,66,67,68],
"FAN":[81],
"MULTIELEMENT":{'67':2}
}
class CyncHub:
def __init__(self, user_data, options, remove_options_update_listener):
self.thread = None
self.loop = None
self.reader = None
self.writer = None
self.login_code = bytearray(user_data['cync_credentials'])
self.logged_in = False
self.home_devices = user_data['cync_config']['home_devices']
self.home_controllers = user_data['cync_config']['home_controllers']
self.switchID_to_homeID = user_data['cync_config']['switchID_to_homeID']
self.connected_devices = {home_id:[] for home_id in self.home_controllers.keys()}
self.shutting_down = False
self.remove_options_update_listener = remove_options_update_listener
self.cync_rooms = {room_id:CyncRoom(room_id,room_info,self) for room_id,room_info in user_data['cync_config']['rooms'].items()}
self.cync_switches = {device_id:CyncSwitch(device_id,switch_info,self.cync_rooms.get(switch_info['room'], None),self) for device_id,switch_info in user_data['cync_config']['devices'].items() if switch_info.get("ONOFF",False)}
self.cync_motion_sensors = {device_id:CyncMotionSensor(device_id,device_info,self.cync_rooms.get(device_info['room'], None)) for device_id,device_info in user_data['cync_config']['devices'].items() if device_info.get("MOTION",False)}
self.cync_ambient_light_sensors = {device_id:CyncAmbientLightSensor(device_id,device_info,self.cync_rooms.get(device_info['room'], None)) for device_id,device_info in user_data['cync_config']['devices'].items() if device_info.get("AMBIENT_LIGHT",False)}
self.switchID_to_deviceIDs = {device_info.switch_id:[dev_id for dev_id, dev_info in self.cync_switches.items() if dev_info.switch_id == device_info.switch_id] for device_id, device_info in self.cync_switches.items() if int(device_info.switch_id) > 0}
self.connected_devices_updated = False
self.options = options
self._seq_num = 0
self.pending_commands = {}
[room.initialize() for room in self.cync_rooms.values() if room.is_subgroup]
[room.initialize() for room in self.cync_rooms.values() if not room.is_subgroup]
def start_tcp_client(self):
self.thread = threading.Thread(target=self._start_tcp_client,daemon=True)
self.thread.start()
def _start_tcp_client(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self._connect())
def disconnect(self):
self.shutting_down = True
for home_controllers in self.home_controllers.values(): #send packets to server to generate data to be read which will initiate shutdown
for controller in home_controllers:
seq = self.get_seq_num()
state_request = bytes.fromhex('7300000018') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('007e00000000f85206000000ffff0000567e')
self.loop.call_soon_threadsafe(self.send_request,state_request)
async def _connect(self):
while not self.shutting_down:
try:
context = ssl.create_default_context()
try:
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23779, ssl = context)
except Exception as e:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
try:
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23779, ssl = context)
except Exception as e:
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23778)
except Exception as e:
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
await asyncio.sleep(5)
else:
read_tcp_messages = asyncio.create_task(self._read_tcp_messages(), name = "Read TCP Messages")
maintain_connection = asyncio.create_task(self._maintain_connection(), name = "Maintain Connection")
update_state = asyncio.create_task(self._update_state(), name = "Update State")
update_connected_devices = asyncio.create_task(self._update_connected_devices(), name = "Update Connected Devices")
read_write_tasks = [read_tcp_messages, maintain_connection, update_state, update_connected_devices]
try:
done, pending = await asyncio.wait(read_write_tasks,return_when=asyncio.FIRST_EXCEPTION)
for task in done:
name = task.get_name()
exception = task.exception()
try:
result = task.result()
except Exception as e:
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
for task in pending:
task.cancel()
if not self.shutting_down:
_LOGGER.error("Connection to Cync server reset, restarting in 15 seconds")
await asyncio.sleep(15)
else:
_LOGGER.debug("Cync client shutting down")
except Exception as e:
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
async def _read_tcp_messages(self):
self.writer.write(self.login_code)
await self.writer.drain()
await self.reader.read(1000)
self.logged_in = True
while not self.shutting_down:
data = await self.reader.read(1000)
if len(data) == 0:
self.logged_in = False
raise LostConnection
while len(data) >= 12:
packet_type = int(data[0])
packet_length = struct.unpack(">I", data[1:5])[0]
packet = data[5:packet_length+5]
try:
if packet_length == len(packet):
if packet_type == 115:
switch_id = str(struct.unpack(">I", packet[0:4])[0])
home_id = self.switchID_to_homeID[switch_id]
#send response packet
response_id = struct.unpack(">H", packet[4:6])[0]
response_packet = bytes.fromhex('7300000007') + int(switch_id).to_bytes(4,'big') + response_id.to_bytes(2,'big') + bytes.fromhex('00')
self.loop.call_soon_threadsafe(self.send_request, response_packet)
if packet_length >= 33 and int(packet[13]) == 219:
#parse state and brightness change packet
deviceID = self.home_devices[home_id][int(packet[21])]
state = int(packet[27]) > 0
brightness = int(packet[28]) if state else 0
if deviceID in self.cync_switches:
self.cync_switches[deviceID].update_switch(state,brightness,self.cync_switches[deviceID].color_temp_kelvin,self.cync_switches[deviceID].rgb)
elif packet_length >= 25 and int(packet[13]) == 84:
#parse motion and ambient light sensor packet
deviceID = self.home_devices[home_id][int(packet[16])]
motion = int(packet[22]) > 0
ambient_light = int(packet[24]) > 0
if deviceID in self.cync_motion_sensors:
self.cync_motion_sensors[deviceID].update_motion_sensor(motion)
if deviceID in self.cync_ambient_light_sensors:
self.cync_ambient_light_sensors[deviceID].update_ambient_light_sensor(ambient_light)
elif packet_length > 51 and int(packet[13]) == 82:
#parse initial state packet
switch_id = str(struct.unpack(">I", packet[0:4])[0])
home_id = self.switchID_to_homeID[switch_id]
self._add_connected_devices(switch_id, home_id)
packet = packet[22:]
while len(packet) > 24:
deviceID = self.home_devices[home_id][int(packet[0])]
if deviceID in self.cync_switches:
if self.cync_switches[deviceID].elements > 1:
for i in range(self.cync_switches[deviceID].elements):
device_id = self.home_devices[home_id][(i+1)*256 + int(packet[0])]
state = int((int(packet[12]) >> i) & int(packet[8])) > 0
brightness = 100 if state else 0
self.cync_switches[device_id].update_switch(state,brightness,self.cync_switches[device_id].color_temp_kelvin,self.cync_switches[device_id].rgb)
else:
state = int(packet[8]) > 0
brightness = int(packet[12]) if state else 0
color_temp = int(packet[16])
rgb = {'r':int(packet[20]),'g':int(packet[21]),'b':int(packet[22]),'active':int(packet[16])==254}
self.cync_switches[deviceID].update_switch(state,brightness,color_temp,rgb)
packet = packet[24:]
elif packet_type == 131:
switch_id = str(struct.unpack(">I", packet[0:4])[0])
home_id = self.switchID_to_homeID[switch_id]
if packet_length >= 33 and int(packet[13]) == 219:
#parse state and brightness change packet
deviceID = self.home_devices[home_id][int(packet[21])]
state = int(packet[27]) > 0
brightness = int(packet[28]) if state else 0
if deviceID in self.cync_switches:
self.cync_switches[deviceID].update_switch(state,brightness,self.cync_switches[deviceID].color_temp_kelvin,self.cync_switches[deviceID].rgb)
elif packet_length >= 25 and int(packet[13]) == 84:
#parse motion and ambient light sensor packet
deviceID = self.home_devices[home_id][int(packet[16])]
motion = int(packet[22]) > 0
ambient_light = int(packet[24]) > 0
if deviceID in self.cync_motion_sensors:
self.cync_motion_sensors[deviceID].update_motion_sensor(motion)
if deviceID in self.cync_ambient_light_sensors:
self.cync_ambient_light_sensors[deviceID].update_ambient_light_sensor(ambient_light)
elif packet_type == 67 and packet_length >= 26 and int(packet[4]) == 1 and int(packet[5]) == 1 and int(packet[6]) == 6:
#parse state packet
switch_id = str(struct.unpack(">I", packet[0:4])[0])
home_id = self.switchID_to_homeID[switch_id]
packet = packet[7:]
while len(packet) >= 19:
if int(packet[3]) < len(self.home_devices[home_id]):
deviceID = self.home_devices[home_id][int(packet[3])]
if deviceID in self.cync_switches:
if self.cync_switches[deviceID].elements > 1:
for i in range(self.cync_switches[deviceID].elements):
device_id = self.home_devices[home_id][(i+1)*256 + int(packet[3])]
state = int((int(packet[5]) >> i) & int(packet[4])) > 0
brightness = 100 if state else 0
self.cync_switches[device_id].update_switch(state,brightness,self.cync_switches[device_id].color_temp_kelvin,self.cync_switches[device_id].rgb)
else:
state = int(packet[4]) > 0
brightness = int(packet[5]) if state else 0
color_temp = int(packet[6])
rgb = {'r':int(packet[7]),'g':int(packet[8]),'b':int(packet[9]),'active':int(packet[6])==254}
self.cync_switches[deviceID].update_switch(state,brightness,color_temp,rgb)
packet = packet[19:]
elif packet_type == 171:
switch_id = str(struct.unpack(">I", packet[0:4])[0])
home_id = self.switchID_to_homeID[switch_id]
self._add_connected_devices(switch_id, home_id)
elif packet_type == 123:
seq = str(struct.unpack(">H", packet[4:6])[0])
command_received = self.pending_commands.get(seq,None)
if command_received is not None:
command_received(seq)
except Exception as e:
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
data = data[packet_length+5:]
raise ShuttingDown
async def _maintain_connection(self):
while not self.shutting_down:
await asyncio.sleep(180)
self.writer.write(bytes.fromhex('d300000000'))
await self.writer.drain()
raise ShuttingDown
def _add_connected_devices(self,switch_id, home_id):
for dev in self.switchID_to_deviceIDs[switch_id]:
#update list of WiFi connected devices
if dev not in self.connected_devices[home_id]:
self.connected_devices[home_id].append(dev)
if self.connected_devices_updated:
for dev in self.cync_switches.values():
dev.update_controllers()
for room in self.cync_rooms.values():
room.update_controllers()
async def _update_connected_devices(self):
while not self.shutting_down:
self.connected_devices_updated = False
for devices in self.connected_devices.values():
devices.clear()
while not self.logged_in:
await asyncio.sleep(2)
attempts = 0
while True in [len(devices) < len(self.home_controllers[home_id]) * 0.5 for home_id,devices in self.connected_devices.items()] and attempts < 10:
for home_id, home_controllers in self.home_controllers.items():
for controller in home_controllers:
seq = self.get_seq_num()
ping = bytes.fromhex('a300000007') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('00')
self.loop.call_soon_threadsafe(self.send_request, ping)
await asyncio.sleep(0.15)
await asyncio.sleep(2)
attempts += 1
for dev in self.cync_switches.values():
dev.update_controllers()
for room in self.cync_rooms.values():
room.update_controllers()
self.connected_devices_updated = True
await asyncio.sleep(3600)
raise ShuttingDown
async def _update_state(self):
while not self.connected_devices_updated:
await asyncio.sleep(2)
for connected_devices in self.connected_devices.values():
if len(connected_devices) > 0:
controller = self.cync_switches[connected_devices[0]].switch_id
seq = self.get_seq_num()
state_request = bytes.fromhex('7300000018') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('007e00000000f85206000000ffff0000567e')
self.loop.call_soon_threadsafe(self.send_request,state_request)
while False in [self.cync_switches[dev_id]._update_callback is not None for dev_id in self.options["switches"]] and False in [self.cync_rooms[dev_id]._update_callback is not None for dev_id in self.options["rooms"]]:
await asyncio.sleep(2)
for dev in self.cync_switches.values():
dev.publish_update()
for room in self.cync_rooms.values():
dev.publish_update()
def send_request(self,request):
async def send():
self.writer.write(request)
await self.writer.drain()
self.loop.create_task(send())
def combo_control(self,state,brightness,color_tone,rgb,switch_id,mesh_id,seq):
combo_request = bytes.fromhex('7300000022') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8f010000000000000') + mesh_id + bytes.fromhex('f00000') + (1 if state else 0).to_bytes(1,'big') + brightness.to_bytes(1,'big') + color_tone.to_bytes(1,'big') + rgb[0].to_bytes(1,'big') + rgb[1].to_bytes(1,'big') + rgb[2].to_bytes(1,'big') + ((496 + int(mesh_id[0]) + int(mesh_id[1]) + (1 if state else 0) + brightness + color_tone + sum(rgb))%256).to_bytes(1,'big') + bytes.fromhex('7e')
self.loop.call_soon_threadsafe(self.send_request,combo_request)
def turn_on(self,switch_id,mesh_id,seq):
power_request = bytes.fromhex('730000001f') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8d00d000000000000') + mesh_id + bytes.fromhex('d00000010000') + ((430 + int(mesh_id[0]) + int(mesh_id[1]))%256).to_bytes(1,'big') + bytes.fromhex('7e')
self.loop.call_soon_threadsafe(self.send_request,power_request)
def turn_off(self,switch_id,mesh_id,seq):
power_request = bytes.fromhex('730000001f') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8d00d000000000000') + mesh_id + bytes.fromhex('d00000000000') + ((429 + int(mesh_id[0]) + int(mesh_id[1]))%256).to_bytes(1,'big') + bytes.fromhex('7e')
self.loop.call_soon_threadsafe(self.send_request,power_request)
def set_color_temp(self,color_temp,switch_id,mesh_id,seq):
color_temp_request = bytes.fromhex('730000001e') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8e20c000000000000') + mesh_id + bytes.fromhex('e2000005') + color_temp.to_bytes(1,'big') + ((469 + int(mesh_id[0]) + int(mesh_id[1]) + color_temp)%256).to_bytes(1,'big') + bytes.fromhex('7e')
self.loop.call_soon_threadsafe(self.send_request,color_temp_request)
def get_seq_num(self):
if self._seq_num == 65535:
self._seq_num = 1
else:
self._seq_num += 1
return self._seq_num
class CyncRoom:
def __init__(self, room_id, room_info, hub):
self.hub = hub
self.room_id = room_id
self.home_id = room_id.split('-')[0]
self.name = room_info.get('name','unknown')
self.home_name = room_info.get('home_name','unknown')
self.parent_room = room_info.get('parent_room', 'unknown')
self.mesh_id = int(room_info.get('mesh_id',0)).to_bytes(2,'little')
self.power_state = False
self.brightness = 0
self.color_temp_kelvin = 0
self.rgb = {'r':0, 'g':0, 'b':0, 'active': False}
self.switches = room_info.get('switches',[])
self.subgroups = room_info.get('subgroups',[])
self.is_subgroup = room_info.get('isSubgroup', False)
self.all_room_switches = self.switches
self.controllers = []
self.default_controller = room_info.get('room_controller',self.hub.home_controllers[self.home_id][0])
self._update_callback = None
self._update_parent_room = None
self.support_brightness = False
self.support_color_temp = False
self.support_rgb = False
self.switches_support_brightness = False
self.switches_support_color_temp = False
self.switches_support_rgb = False
self.groups_support_brightness = False
self.groups_support_color_temp = False
self.groups_support_rgb = False
self._command_timout = 0.5
self._command_retry_time = 5
def initialize(self):
"""Initialization of supported features and registration of update function for all switches and subgroups in the room"""
self.switches_support_brightness = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_brightness]
self.switches_support_color_temp = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_color_temp]
self.switches_support_rgb = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_rgb]
self.groups_support_brightness = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_brightness]
self.groups_support_color_temp = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_color_temp]
self.groups_support_rgb = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_rgb]
self.support_brightness = (len(self.switches_support_brightness) + len(self.groups_support_brightness)) > 0
self.support_color_temp = (len(self.switches_support_color_temp) + len(self.groups_support_color_temp)) > 0
self.support_rgb = (len(self.switches_support_rgb) + len(self.groups_support_rgb)) > 0
for switch_id in self.switches:
self.hub.cync_switches[switch_id].register_room_updater(self.update_room)
for subgroup in self.subgroups:
self.hub.cync_rooms[subgroup].register_room_updater(self.update_room)
self.all_room_switches = self.all_room_switches + self.hub.cync_rooms[subgroup].switches
for subgroup in self.subgroups:
self.hub.cync_rooms[subgroup].all_room_switches = self.all_room_switches
def register(self, update_callback) -> None:
"""Register callback, called when switch changes state."""
self._update_callback = update_callback
def reset(self) -> None:
"""Remove previously registered callback."""
self._update_callback = None
def register_room_updater(self, parent_updater):
self._update_parent_room = parent_updater
@property
def max_color_temp_kelvin(self) -> int:
"""Return maximum supported color temperature."""
return 7000
@property
def min_color_temp_kelvin(self) -> int:
"""Return minimum supported color temperature."""
return 2000
async def turn_on(self, attr_rgb, attr_br, attr_ct) -> None:
"""Turn on the light."""
attempts = 0
update_received = False
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
seq = str(self.hub.get_seq_num())
if len(self.controllers) > 0:
controller = self.controllers[attempts%len(self.controllers)]
else:
controller = self.default_controller
if attr_rgb is not None and attr_br is not None:
if math.isclose(attr_br, max([self.rgb['r'],self.rgb['g'],self.rgb['b']])*self.brightness/100, abs_tol = 2):
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
else:
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
elif attr_rgb is None and attr_ct is None and attr_br is not None:
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
elif attr_rgb is not None and attr_br is None:
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
elif attr_ct is not None:
self.hub.turn_on(controller, self.mesh_id, seq)
# Sync wants the color temp as a percentage of the range. So we need to
# calculate what percentage of the color temp range is being requested
# before sending it to the server.
color_temp = round(
(
(attr_ct - self.min_color_temp_kelvin) /
self.max_color_temp_kelvin
) * 100
)
self.hub.set_color_temp(color_temp, controller, self.mesh_id, seq)
else:
self.hub.turn_on(controller, self.mesh_id, seq)
self.hub.pending_commands[seq] = self.command_received
await asyncio.sleep(self._command_timout)
if self.hub.pending_commands.get(seq, None) is not None:
self.hub.pending_commands.pop(seq)
attempts += 1
else:
update_received = True
async def turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
attempts = 0
update_received = False
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
seq = str(self.hub.get_seq_num())
if len(self.controllers) > 0:
controller = self.controllers[attempts%len(self.controllers)]
else:
controller = self.default_controller
self.hub.turn_off(controller, self.mesh_id, seq)
self.hub.pending_commands[seq] = self.command_received
await asyncio.sleep(self._command_timout)
if self.hub.pending_commands.get(seq, None) is not None:
self.hub.pending_commands.pop(seq)
attempts += 1
else:
update_received = True
def command_received(self, seq):
"""Remove command from hub.pending_commands when a reply is received from Cync server"""
if self.hub.pending_commands.get(seq) is not None:
self.hub.pending_commands.pop(seq)
def update_room(self):
"""Update the current state of the room"""
_brightness = self.brightness
_color_temp = self.color_temp_kelvin
_rgb = self.rgb
_power_state = True in ([self.hub.cync_switches[device_id].power_state for device_id in self.switches] + [self.hub.cync_rooms[room_id].power_state for room_id in self.subgroups])
if self.support_brightness:
_brightness = round(sum([self.hub.cync_switches[device_id].brightness for device_id in self.switches] + [self.hub.cync_rooms[room_id].brightness for room_id in self.subgroups])/(len(self.switches) + len(self.subgroups)))
else:
_brightness = 100 if _power_state else 0
if self.support_color_temp:
_color_temp = round(sum([self.hub.cync_switches[device_id].color_temp_kelvin for device_id in self.switches_support_color_temp] + [self.hub.cync_rooms[room_id].color_temp_kelvin for room_id in self.groups_support_color_temp])/(len(self.switches_support_color_temp) + len(self.groups_support_color_temp)))
if self.support_rgb:
_rgb['r'] = round(sum([self.hub.cync_switches[device_id].rgb['r'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['r'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
_rgb['g'] = round(sum([self.hub.cync_switches[device_id].rgb['g'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['g'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
_rgb['b'] = round(sum([self.hub.cync_switches[device_id].rgb['b'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['b'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
_rgb['active'] = True in ([self.hub.cync_switches[device_id].rgb['active'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['active'] for room_id in self.groups_support_rgb])
if _power_state != self.power_state or _brightness != self.brightness or _color_temp != self.color_temp_kelvin or _rgb != self.rgb:
self.power_state = _power_state
self.brightness = _brightness
self.color_temp_kelvin = _color_temp
self.rgb = _rgb
self.publish_update()
if self._update_parent_room:
self._update_parent_room()
def update_controllers(self):
"""Update the list of responsive, Wi-Fi connected controller devices"""
connected_devices = self.hub.connected_devices[self.home_id]
controllers = []
if len(connected_devices) > 0:
controllers = [self.hub.cync_switches[dev_id].switch_id for dev_id in self.all_room_switches if dev_id in connected_devices]
others_available = [self.hub.cync_switches[dev_id].switch_id for dev_id in connected_devices]
for controller in controllers:
if controller in others_available:
others_available.remove(controller)
self.controllers = controllers + others_available
else:
self.controllers = [self.default_controller]
def publish_update(self):
if self._update_callback:
self._update_callback()
class CyncSwitch:
def __init__(self, device_id, switch_info, room, hub):
self.hub = hub
self.device_id = device_id
self.switch_id = switch_info.get('switch_id','0')
self.home_id = [home_id for home_id, home_devices in self.hub.home_devices.items() if self.device_id in home_devices][0]
self.name = switch_info.get('name','unknown')
self.home_name = switch_info.get('home_name','unknown')
self.mesh_id = switch_info.get('mesh_id',0).to_bytes(2,'little')
self.room = room
self.power_state = False
self.brightness = 0
self.color_temp_kelvin = 0
self.rgb = {'r':0, 'g':0, 'b':0, 'active':False}
self.default_controller = switch_info.get('switch_controller',self.hub.home_controllers[self.home_id][0])
self.controllers = []
self._update_callback = None
self._update_parent_room = None
self.support_brightness = switch_info.get('BRIGHTNESS',False)
self.support_color_temp = switch_info.get('COLORTEMP',False)
self.support_rgb = switch_info.get('RGB',False)
self.plug = switch_info.get('PLUG',False)
self.fan = switch_info.get('FAN',False)
self.elements = switch_info.get('MULTIELEMENT',1)
self._command_timout = 0.5
self._command_retry_time = 5
def register(self, update_callback) -> None:
"""Register callback, called when switch changes state."""
self._update_callback = update_callback
def reset(self) -> None:
"""Remove previously registered callback."""
self._update_callback = None
def register_room_updater(self, parent_updater):
self._update_parent_room = parent_updater
@property
def max_color_temp_kelvin(self) -> int:
"""Return maximum supported color temperature."""
return 7000
@property
def min_color_temp_kelvin(self) -> int:
"""Return minimum supported color temperature."""
return 2000
async def turn_on(self, attr_rgb, attr_br, attr_ct) -> None:
"""Turn on the light."""
attempts = 0
update_received = False
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
seq = str(self.hub.get_seq_num())
if len(self.controllers) > 0:
controller = self.controllers[attempts%len(self.controllers)]
else:
controller = self.default_controller
if attr_rgb is not None and attr_br is not None:
if math.isclose(attr_br, max([self.rgb['r'],self.rgb['g'],self.rgb['b']])*self.brightness/100, abs_tol = 2):
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
else:
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
elif attr_rgb is None and attr_ct is None and attr_br is not None:
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
elif attr_rgb is not None and attr_br is None:
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
elif attr_ct is not None:
# Sync wants the color temp as a percentage of the range. So we need to
# calculate what percentage of the color temp range is being requested
# before sending it to the server.
color_temp = round(
(
(attr_ct - self.min_color_temp_kelvin) /
self.max_color_temp_kelvin
) * 100
)
self.hub.set_color_temp(color_temp, controller, self.mesh_id, seq)
else:
self.hub.turn_on(controller, self.mesh_id, seq)
self.hub.pending_commands[seq] = self.command_received
await asyncio.sleep(self._command_timout)
if self.hub.pending_commands.get(seq, None) is not None:
self.hub.pending_commands.pop(seq)
attempts += 1
else:
update_received = True
async def turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
attempts = 0
update_received = False
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
seq = str(self.hub.get_seq_num())
if len(self.controllers) > 0:
controller = self.controllers[attempts%len(self.controllers)]
else:
controller = self.default_controller
self.hub.turn_off(controller, self.mesh_id, seq)
self.hub.pending_commands[seq] = self.command_received
await asyncio.sleep(self._command_timout)
if self.hub.pending_commands.get(seq, None) is not None:
self.hub.pending_commands.pop(seq)
attempts += 1
else:
update_received = True
def command_received(self, seq):
"""Remove command from hub.pending_commands when a reply is received from Cync server"""
if self.hub.pending_commands.get(seq) is not None:
self.hub.pending_commands.pop(seq)
def update_switch(self,state,brightness,color_temp,rgb):
"""Update the state of the switch as updates are received from the Cync server"""
self.update_received = True
# Cync sends the color temp as a percentage from 0-100 based on the max and min
# color temp. Most Cync bulbs support 2000K-7000K, so we have to calculate what
# is actually being requested.
_color_temp = round(
(self.max_color_temp_kelvin - self.min_color_temp_kelvin) *
(color_temp / 100) +
self.min_color_temp_kelvin
)
if self.power_state != state or self.brightness != brightness or self.color_temp_kelvin != _color_temp or self.rgb != rgb:
self.power_state = state
self.brightness = brightness if self.support_brightness and state else 100 if state else 0
# Cync sends the color temp as a percentage from 0-100 based on the max and
# min color temp. Most Cync bulbs support 2000K-7000K, so we have to
# calculate what is actually being requested.
self.color_temp_kelvin = _color_temp
self.rgb = rgb
self.publish_update()
if self._update_parent_room:
self._update_parent_room()
def update_controllers(self):
"""Update the list of responsive, Wi-Fi connected controller devices"""
connected_devices = self.hub.connected_devices[self.home_id]
controllers = []
if len(connected_devices) > 0:
if int(self.switch_id) > 0:
if self.device_id in connected_devices:
#if this device is connected, make this the first available controller
controllers.append(self.switch_id)
if self.room:
controllers = controllers + [self.hub.cync_switches[device_id].switch_id for device_id in self.room.all_room_switches if device_id in connected_devices and device_id != self.device_id]
others_available = [self.hub.cync_switches[device_id].switch_id for device_id in connected_devices]
for controller in controllers:
if controller in others_available:
others_available.remove(controller)
self.controllers = controllers + others_available
else:
self.controllers = [self.default_controller]
def publish_update(self):
if self._update_callback:
self._update_callback()
class CyncMotionSensor:
def __init__(self, device_id, device_info, room):
self.device_id = device_id
self.name = device_info['name']
self.home_name = device_info['home_name']
self.room = room
self.motion = False
self._update_callback = None
def register(self, update_callback) -> None:
"""Register callback, called when switch changes state."""
self._update_callback = update_callback
def reset(self) -> None:
"""Remove previously registered callback."""
self._update_callback = None
def update_motion_sensor(self,motion):
self.motion = motion
self.publish_update()
def publish_update(self):
if self._update_callback:
self._update_callback()
class CyncAmbientLightSensor:
def __init__(self, device_id, device_info, room):
self.device_id = device_id
self.name = device_info['name']
self.home_name = device_info['home_name']
self.room = room
self.ambient_light = False
self._update_callback = None
def register(self, update_callback) -> None:
"""Register callback, called when switch changes state."""
self._update_callback = update_callback
def reset(self) -> None:
"""Remove previously registered callback."""
self._update_callback = None
def update_ambient_light_sensor(self,ambient_light):
self.ambient_light = ambient_light
self.publish_update()
def publish_update(self):
if self._update_callback:
self._update_callback()
class CyncUserData:
def __init__(self):
self.username = ''
self.password = ''
self.auth_code = None
self.user_credentials = {}
async def authenticate(self,username,password):
"""Authenticate with the API and get a token."""
self.username = username
self.password = password
auth_data = {'corp_id': "1007d2ad150c4000", 'email': self.username, 'password': self.password}
async with aiohttp.ClientSession() as session:
async with session.post(API_AUTH, json=auth_data) as resp:
if resp.status == 200:
self.user_credentials = await resp.json()
login_code = bytearray.fromhex('13000000') + (10 + len(self.user_credentials['authorize'])).to_bytes(1,'big') + bytearray.fromhex('03') + self.user_credentials['user_id'].to_bytes(4,'big') + len(self.user_credentials['authorize']).to_bytes(2,'big') + bytearray(self.user_credentials['authorize'],'ascii') + bytearray.fromhex('0000b4')
self.auth_code = [int.from_bytes([byt],'big') for byt in login_code]
return {'authorized':True}
elif resp.status == 400:
request_code_data = {'corp_id': "1007d2ad150c4000", 'email': self.username, 'local_lang': "en-us"}
async with aiohttp.ClientSession() as session:
async with session.post(API_REQUEST_CODE,json=request_code_data) as resp:
if resp.status == 200:
return {'authorized':False,'two_factor_code_required':True}
else:
return {'authorized':False,'two_factor_code_required':False}
else:
return {'authorized':False,'two_factor_code_required':False}
async def auth_two_factor(self, code):
"""Authenticate with 2 Factor Code."""
two_factor_data = {'corp_id': "1007d2ad150c4000", 'email': self.username,'password': self.password, 'two_factor': code, 'resource':"abcdefghijklmnop"}
async with aiohttp.ClientSession() as session:
async with session.post(API_2FACTOR_AUTH,json=two_factor_data) as resp:
if resp.status == 200:
self.user_credentials = await resp.json()
login_code = bytearray.fromhex('13000000') + (10 + len(self.user_credentials['authorize'])).to_bytes(1,'big') + bytearray.fromhex('03') + self.user_credentials['user_id'].to_bytes(4,'big') + len(self.user_credentials['authorize']).to_bytes(2,'big') + bytearray(self.user_credentials['authorize'],'ascii') + bytearray.fromhex('0000b4')
self.auth_code = [int.from_bytes([byt],'big') for byt in login_code]
return {'authorized':True}
else:
return {'authorized':False}
async def get_cync_config(self):
home_devices = {}
home_controllers = {}
switchID_to_homeID = {}
devices = {}
rooms = {}
homes = await self._get_homes()
for home in homes:
home_info = await self._get_home_properties(home['product_id'], home['id'])
if home_info.get('groupsArray',False) and home_info.get('bulbsArray',False) and len(home_info['groupsArray']) > 0 and len(home_info['bulbsArray']) > 0:
home_id = str(home['id'])
bulbs_array_length = max([((device['deviceID'] % home['id']) % 1000) + (int((device['deviceID'] % home['id']) / 1000)*256) for device in home_info['bulbsArray']]) + 1
home_devices[home_id] = [""]*(bulbs_array_length)
home_controllers[home_id] = []
for device in home_info['bulbsArray']:
device_type = device['deviceType']
device_id = str(device['deviceID'])
current_index = ((device['deviceID'] % home['id']) % 1000) + (int((device['deviceID'] % home['id']) / 1000)*256)
home_devices[home_id][current_index] = device_id
devices[device_id] = {'name':device['displayName'],
'mesh_id':current_index,
'switch_id':str(device.get('switchID',0)),
'ONOFF': device_type in Capabilities['ONOFF'],
'BRIGHTNESS': device_type in Capabilities["BRIGHTNESS"],
"COLORTEMP":device_type in Capabilities["COLORTEMP"],
"RGB": device_type in Capabilities["RGB"],
"MOTION": device_type in Capabilities["MOTION"],
"AMBIENT_LIGHT": device_type in Capabilities["AMBIENT_LIGHT"],
"WIFICONTROL": device_type in Capabilities["WIFICONTROL"],
"PLUG" : device_type in Capabilities["PLUG"],
"FAN" : device_type in Capabilities["FAN"],
'home_name':home['name'],
'room':'',
'room_name':''
}
if str(device_type) in Capabilities['MULTIELEMENT'] and current_index < 256:
devices[device_id]['MULTIELEMENT'] = Capabilities['MULTIELEMENT'][str(device_type)]
if devices[device_id].get('WIFICONTROL',False) and 'switchID' in device and device['switchID'] > 0:
switchID_to_homeID[str(device['switchID'])] = home_id
devices[device_id]['switch_controller'] = device['switchID']
home_controllers[home_id].append(device['switchID'])
if len(home_controllers[home_id]) == 0:
for device in home_info['bulbsArray']:
device_id = str(device['deviceID'])
devices.pop(device_id,'')
home_devices.pop(home_id,'')
home_controllers.pop(home_id,'')
else:
for room in home_info['groupsArray']:
if (len(room.get('deviceIDArray',[])) + len(room.get('subgroupIDArray',[]))) > 0:
room_id = home_id + '-' + str(room['groupID'])
room_controller = home_controllers[home_id][0]
available_room_controllers = [(id%1000) + (int(id/1000)*256) for id in room.get('deviceIDArray',[]) if 'switch_controller' in devices[home_devices[home_id][(id%1000)+(int(id/1000)*256)]]]
if len(available_room_controllers) > 0:
room_controller = devices[home_devices[home_id][available_room_controllers[0]]]['switch_controller']
for id in room.get('deviceIDArray',[]):
id = (id % 1000) + (int(id / 1000)*256)
devices[home_devices[home_id][id]]['room'] = room_id
devices[home_devices[home_id][id]]['room_name'] = room['displayName']
if 'switch_controller' not in devices[home_devices[home_id][id]] and devices[home_devices[home_id][id]].get('ONOFF',False):
devices[home_devices[home_id][id]]['switch_controller'] = room_controller
rooms[room_id] = {'name':room['displayName'],
'mesh_id' : room['groupID'],
'room_controller' : room_controller,
'home_name' : home['name'],
'switches' : [home_devices[home_id][(i%1000)+(int(i/1000)*256)] for i in room.get('deviceIDArray',[]) if devices[home_devices[home_id][(i%1000)+(int(i/1000)*256)]].get('ONOFF',False)],
'isSubgroup' : room.get('isSubgroup',False),
'subgroups' : [home_id + '-' + str(subgroup) for subgroup in room.get('subgroupIDArray',[])]
}
for room,room_info in rooms.items():
if not room_info.get("isSubgroup",False) and len(subgroups := room_info.get("subgroups",[]).copy()) > 0:
for subgroup in subgroups:
if rooms.get(subgroup,None):
rooms[subgroup]["parent_room"] = room_info["name"]
else:
room_info['subgroups'].pop(room_info['subgroups'].index(subgroup))
if len(rooms) == 0 or len(devices) == 0 or len(home_controllers) == 0 or len(home_devices) == 0 or len(switchID_to_homeID) == 0:
raise InvalidCyncConfiguration
else:
return {'rooms':rooms, 'devices':devices, 'home_devices':home_devices, 'home_controllers':home_controllers, 'switchID_to_homeID':switchID_to_homeID}
async def _get_homes(self):
"""Get a list of devices for a particular user."""
headers = {'Access-Token': self.user_credentials['access_token']}
async with aiohttp.ClientSession() as session:
async with session.get(API_DEVICES.format(user=self.user_credentials['user_id']), headers=headers) as resp:
response = await resp.json()
return response
async def _get_home_properties(self, product_id, device_id):
"""Get properties for a single device."""
headers = {'Access-Token': self.user_credentials['access_token']}
async with aiohttp.ClientSession() as session:
async with session.get(API_DEVICE_INFO.format(product_id=product_id, device_id=device_id), headers=headers) as resp:
response = await resp.json()
return response
class LostConnection(Exception):
"""Lost connection to Cync Server"""
class ShuttingDown(Exception):
"""Cync client shutting down"""
class InvalidCyncConfiguration(Exception):
"""Cync configuration is not supported"""
@@ -0,0 +1,104 @@
"""Platform for light integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
hub = hass.data[DOMAIN][config_entry.entry_id]
new_devices = []
for switch_id in hub.cync_switches:
if not hub.cync_switches[switch_id]._update_callback and hub.cync_switches[switch_id].fan and switch_id in config_entry.options["switches"]:
new_devices.append(CyncFanEntity(hub.cync_switches[switch_id]))
if new_devices:
async_add_entities(new_devices)
class CyncFanEntity(FanEntity):
"""Representation of a Cync Fan Switch Entity."""
should_poll = False
def __init__(self, cync_switch) -> None:
"""Initialize the light."""
self.cync_switch = cync_switch
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.cync_switch.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.cync_switch.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
suggested_area = f"{self.cync_switch.room.name}",
)
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
return 'cync_switch_' + self.cync_switch.device_id
@property
def name(self) -> str:
"""Return the name of the switch."""
return self.cync_switch.name
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
@property
def is_on(self) -> bool | None:
"""Return true if fan is on."""
return self.cync_switch.power_state
@property
def percentage(self) -> int | None:
"""Return the fan speed percentage of this switch"""
return self.cync_switch.brightness
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return 4
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the light."""
await self.cync_switch.turn_on(None,percentage*255/100 if percentage is not None else None,None)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.cync_switch.turn_off()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
if percentage == 0:
await self.async_turn_off()
else:
await self.cync_switch.turn_on(None,percentage*255/100,None)
@@ -0,0 +1,253 @@
"""Platform for light integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ColorMode, LightEntity)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
hub = hass.data[DOMAIN][config_entry.entry_id]
new_devices = []
for room in hub.cync_rooms:
if not hub.cync_rooms[room]._update_callback and (room in config_entry.options["rooms"] or room in config_entry.options["subgroups"]):
new_devices.append(CyncRoomEntity(hub.cync_rooms[room]))
for switch_id in hub.cync_switches:
if not hub.cync_switches[switch_id]._update_callback and not hub.cync_switches[switch_id].plug and not hub.cync_switches[switch_id].fan and switch_id in config_entry.options["switches"]:
new_devices.append(CyncSwitchEntity(hub.cync_switches[switch_id]))
if new_devices:
async_add_entities(new_devices)
class CyncRoomEntity(LightEntity):
"""Representation of a Cync Room Light Entity."""
should_poll = False
def __init__(self, room) -> None:
"""Initialize the light."""
self.room = room
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.room.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.room.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.room.parent_room if self.room.is_subgroup else self.room.name} ({self.room.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.room.parent_room if self.room.is_subgroup else self.room.name} ({self.room.home_name})",
suggested_area = f"{self.room.parent_room if self.room.is_subgroup else self.room.name}",
)
@property
def icon(self) -> str | None:
"""Icon of the entity."""
if self.room.is_subgroup:
return "mdi:lightbulb-group-outline"
else:
return "mdi:lightbulb-group"
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
uid = 'cync_room_' + '-'.join(self.room.switches) + '_' + '-'.join(self.room.subgroups)
return uid
@property
def name(self) -> str:
"""Return the name of the room."""
return self.room.name
@property
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.room.power_state
@property
def brightness(self) -> int | None:
"""Return the brightness of this room between 0..255."""
return round(self.room.brightness*255/100)
@property
def max_color_temp_kelvin(self) -> int:
"""Return maximum supported color temperature."""
return self.room.max_color_temp_kelvin
@property
def min_color_temp_kelvin(self) -> int:
"""Return minimum supported color temperature."""
return self.room.min_color_temp_kelvin
@property
def color_temp_kelvin(self) -> int:
"""Return color temperature in kelvin."""
return self.room.color_temp_kelvin
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the RGB color tuple of this light switch"""
return (self.room.rgb['r'],self.room.rgb['g'],self.room.rgb['b'])
@property
def supported_color_modes(self) -> set[str] | None:
"""Return list of available color modes."""
modes: set[ColorMode | str] = set()
if self.room.support_color_temp:
modes.add(ColorMode.COLOR_TEMP)
if self.room.support_rgb:
modes.add(ColorMode.RGB)
if self.room.support_brightness:
modes.add(ColorMode.BRIGHTNESS)
if not modes:
modes.add(ColorMode.ONOFF)
return modes
@property
def color_mode(self) -> str | None:
"""Return the active color mode."""
if self.room.support_color_temp:
if self.room.support_rgb and self.room.rgb['active']:
return ColorMode.RGB
else:
return ColorMode.COLOR_TEMP
if self.room.support_brightness:
return ColorMode.BRIGHTNESS
else:
return ColorMode.ONOFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
await self.room.turn_on(kwargs.get(ATTR_RGB_COLOR),kwargs.get(ATTR_BRIGHTNESS),kwargs.get(ATTR_COLOR_TEMP_KELVIN))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.room.turn_off()
class CyncSwitchEntity(LightEntity):
"""Representation of a Cync Switch Light Entity."""
should_poll = False
def __init__(self, cync_switch) -> None:
"""Initialize the light."""
self.cync_switch = cync_switch
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.cync_switch.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.cync_switch.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
suggested_area = f"{self.cync_switch.room.name}",
)
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
return 'cync_switch_' + self.cync_switch.device_id
@property
def name(self) -> str:
"""Return the name of the switch."""
return self.cync_switch.name
@property
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.cync_switch.power_state
@property
def brightness(self) -> int | None:
"""Return the brightness of this switch between 0..255."""
return round(self.cync_switch.brightness*255/100)
@property
def max_color_temp_kelvin(self) -> int:
"""Return maximum supported color temperature."""
return self.cync_switch.max_color_temp_kelvin
@property
def min_color_temp_kelvin(self) -> int:
"""Return minimum supported color temperature."""
return self.cync_switch.min_color_temp_kelvin
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature of this light for HA."""
return self.cync_switch.color_temp_kelvin
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the RGB color tuple of this light switch"""
return (self.cync_switch.rgb['r'],self.cync_switch.rgb['g'],self.cync_switch.rgb['b'])
@property
def supported_color_modes(self) -> set[str] | None:
"""Return list of available color modes."""
modes: set[ColorMode | str] = set()
if self.cync_switch.support_color_temp:
modes.add(ColorMode.COLOR_TEMP)
if self.cync_switch.support_rgb:
modes.add(ColorMode.RGB)
if self.cync_switch.support_brightness:
modes.add(ColorMode.BRIGHTNESS)
if not modes:
modes.add(ColorMode.ONOFF)
return modes
@property
def color_mode(self) -> str | None:
"""Return the active color mode."""
if self.cync_switch.support_color_temp:
if self.cync_switch.support_rgb and self.cync_switch.rgb['active']:
return ColorMode.RGB
else:
return ColorMode.COLOR_TEMP
if self.cync_switch.support_brightness:
return ColorMode.BRIGHTNESS
else:
return ColorMode.ONOFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
await self.cync_switch.turn_on(kwargs.get(ATTR_RGB_COLOR),kwargs.get(ATTR_BRIGHTNESS),kwargs.get(ATTR_COLOR_TEMP_KELVIN))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self.cync_switch.turn_off()
@@ -0,0 +1,82 @@
"""Platform for light integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
hub = hass.data[DOMAIN][config_entry.entry_id]
new_devices = []
for switch_id in hub.cync_switches:
if not hub.cync_switches[switch_id]._update_callback and hub.cync_switches[switch_id].plug and switch_id in config_entry.options["switches"]:
new_devices.append(CyncPlugEntity(hub.cync_switches[switch_id]))
if new_devices:
async_add_entities(new_devices)
class CyncPlugEntity(SwitchEntity):
"""Representation of a Cync Switch Light Entity."""
should_poll = False
def __init__(self, cync_switch) -> None:
"""Initialize the light."""
self.cync_switch = cync_switch
async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
self.cync_switch.register(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
self.cync_switch.reset()
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
manufacturer = "Cync by Savant",
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
suggested_area = f"{self.cync_switch.room.name}",
)
@property
def unique_id(self) -> str:
"""Return Unique ID string."""
return 'cync_switch_' + self.cync_switch.device_id
@property
def name(self) -> str:
"""Return the name of the switch."""
return self.cync_switch.name
@property
def device_class(self) -> str | None:
"""Return the device class"""
return SwitchDeviceClass.OUTLET
@property
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.cync_switch.power_state
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the outlet."""
await self.cync_switch.turn_on(None, None, None)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the outlet."""
await self.cync_switch.turn_off()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
_LOGGER = logging.getLogger(__name__)
# Configuration:
SIDEPANEL_TITLE = "sidepanel_title"
SIDEPANEL_ICON = "sidepanel_icon"
@config_entries.HANDLERS.register("dwains_dashboard")
class DwainsDashboardConfigFlow(config_entries.ConfigFlow):
async def async_step_user(self, user_input=None):
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
return self.async_create_entry(title="", data={})
@staticmethod
@callback
def async_get_options_flow(config_entry):
return DwainsDashboardEditFlow(config_entry)
class DwainsDashboardEditFlow(config_entries.OptionsFlow):
def __init__(self, config_entry):
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
schema = {
vol.Optional(SIDEPANEL_TITLE, default=self.config_entry.options.get("sidepanel_title", "Dwains Dashboard")): str,
vol.Optional(SIDEPANEL_ICON, default=self.config_entry.options.get("sidepanel_icon", "mdi:alpha-d-box")): str,
}
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema)
)
@@ -0,0 +1,2 @@
DOMAIN = "dwains_dashboard"
VERSION = "3.8.0"
@@ -0,0 +1,4 @@
node_modules/
src/*
!src/translations.js
dwains-dashboard.js.map
File diff suppressed because one or more lines are too long

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