diff --git a/.cz.toml b/.cz.toml index 1191704b..88c3282d 100644 --- a/.cz.toml +++ b/.cz.toml @@ -1,6 +1,6 @@ [tool.commitizen] name = "cz_conventional_commits" -version = "4.6.0" +version = "4.7.0b0" tag_format = "v$major.$minor.$patch$prerelease" version_files = [ "apps/controllerx/cx_version.py", diff --git a/.github/ISSUE_TEMPLATE/new_device.md b/.github/ISSUE_TEMPLATE/new_device.md index 8532480d..516b085b 100644 --- a/.github/ISSUE_TEMPLATE/new_device.md +++ b/.github/ISSUE_TEMPLATE/new_device.md @@ -12,24 +12,23 @@ assignees: xaviml ## Device Information -* Device Model: [ eg. E1743 ] -* Device Description: [ eg. IKEA TRADFRI E1743 wireless dimmer ] -* Device Manufacturer: [ eg. IKEA ] +- Device Model: [ eg. E1743 ] +- Device Description: [ eg. IKEA TRADFRI E1743 wireless dimmer ] +- Device Manufacturer: [ eg. IKEA ] ## Integrations -If possible, provide the event mappings for the different actions that can be performed on the controller. Specify the integration. + ### Integration: [ Choose from `z2m | deconz | zha` ] #### Actions -* `button_xyz_press`: Sent when button xyz is pressed -* `button_xyz_hold`: Sent when button xyz is held +- `button_xyz_press`: Sent when button xyz is pressed +- `button_xyz_hold`: Sent when button xyz is held #### Notes -(Optional) Additional notes for the integration, eg. known bugs, issues or limitations of the device for the specified integration. - + diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml index 28173202..0da9b0be 100644 --- a/.github/workflows/hacs.yml +++ b/.github/workflows/hacs.yml @@ -3,12 +3,12 @@ name: HACS Compliant # Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch +# events but only for the main branch on: push: - branches: [dev, master] + branches: [dev, main] pull_request: - branches: [dev, master] + branches: [dev, main] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: @@ -19,10 +19,10 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - name: HACS validation - uses: hacs/integration/action@main - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CATEGORY: appdaemon + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: HACS validation + uses: hacs/integration/action@main + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CATEGORY: appdaemon diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f47b6476..5141c1d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,7 @@ _Note: I recommend working with Python 3.6 since is the minimum version supporte New controllers need to be added into the `apps/controllerx/devices/` and you will need to define the mapping for the integration you are adding support to. Also, the controller will need to be added to the documentation. You will need to create: + - YAML file in `docs/_data/controllers` - MarkDown file in `docs/controllers` - JPEG image in `docs/assets/img` @@ -121,7 +122,7 @@ git checkout -b - / ## Deployment -Thanks to the Azure Pipelines, we are able to deploy by just creating a new tag on git. So first, we will need to bump version with `commitizen` by running the following line in the `master` branch: +Thanks to the Azure Pipelines, we are able to deploy by just creating a new tag on git. So first, we will need to bump version with `commitizen` by running the following line in the `main` branch: ```shell cz bump --no-verify @@ -130,7 +131,7 @@ cz bump --no-verify `--prerelease beta` tag can be added to create a pre-release. Note that you can also add `--dry-run` to see which version will bump without commiting anything. Then, we can directly push the tags: ```shell -git push origin master --tags +git push origin main --tags ``` This will automatically generate a GitHub release with the changes for that release. diff --git a/Pipfile b/Pipfile index 82520269..1e1b695f 100644 --- a/Pipfile +++ b/Pipfile @@ -12,8 +12,8 @@ pytest-mock = "==3.5.1" pytest-timeout = "==1.4.2" mock = "==4.0.3" pre-commit = "==2.10.1" -commitizen = "==2.14.2" -mypy = "==0.800" +commitizen = "==2.15.2" +mypy = "==0.812" flake8 = "==3.8.4" isort = "==5.7.0" controllerx = {path = ".", editable = true} diff --git a/README.md b/README.md index 32e65d1f..1e0d2853 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,10 @@ [![logo](https://github.com/xaviml/controllerx/raw/dev/docs/android-chrome-192x192.png)](https://github.com/xaviml/controllerx/releases) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) -[![azure-pipelines-build](https://img.shields.io/azure-devops/build/xaviml93/ControllerX/1/master.svg?style=for-the-badge)](https://dev.azure.com/xaviml93/ControllerX/_build/latest?definitionId=1&branchName=master) +[![azure-pipelines-build](https://img.shields.io/azure-devops/build/xaviml93/ControllerX/1/main.svg?style=for-the-badge)](https://dev.azure.com/xaviml93/ControllerX/_build/latest?definitionId=1&branchName=main) [![last-release](https://img.shields.io/github/v/release/xaviml/controllerx.svg?style=for-the-badge)](https://github.com/xaviml/controllerx/releases) [![downloads-latest](https://img.shields.io/github/downloads/xaviml/controllerx/latest/total?style=for-the-badge)](http://github.com/xaviml/controllerx/releases/latest) -[![azure-pipelines-coverage](https://img.shields.io/azure-devops/coverage/xaviml93/ControllerX/1/master.svg?style=for-the-badge)](https://dev.azure.com/xaviml93/ControllerX/_build/latest?definitionId=1&branchName=master) -[![Codacy Badge](https://img.shields.io/codacy/grade/542f29ab55a449099488601ec7400563/master?style=for-the-badge)](https://app.codacy.com/manual/xaviml/controllerx?utm_source=github.com&utm_medium=referral&utm_content=xaviml/controllerx&utm_campaign=Badge_Grade_Dashboard) +[![azure-pipelines-coverage](https://img.shields.io/azure-devops/coverage/xaviml93/ControllerX/1/main.svg?style=for-the-badge)](https://dev.azure.com/xaviml93/ControllerX/_build/latest?definitionId=1&branchName=main) [![community-topic](https://img.shields.io/badge/community-topic-blue?style=for-the-badge)](https://community.home-assistant.io/t/controllerx-bring-full-functionality-to-light-and-media-player-controllers/148855) [![buy-me-a-beer](https://img.shields.io/badge/sponsor-Buy%20me%20a%20beer-orange?style=for-the-badge)](https://www.buymeacoffee.com/xaviml) @@ -40,4 +39,4 @@ If you like this project, don't forget to star it :) ## Contributing -If you want to contribute to this project, check [CONTRIBUTING.md](https://github.com/xaviml/controllerx/blob/master/CONTRIBUTING.md). +If you want to contribute to this project, check [CONTRIBUTING.md](https://github.com/xaviml/controllerx/blob/main/CONTRIBUTING.md). diff --git a/apps/controllerx/cx_core/controller.py b/apps/controllerx/cx_core/controller.py index 7c1daa0f..c2bfe4e4 100644 --- a/apps/controllerx/cx_core/controller.py +++ b/apps/controllerx/cx_core/controller.py @@ -488,5 +488,13 @@ def get_zha_action(self, data: EventData) -> Optional[str]: """ return None + def get_lutron_caseta_actions_mapping(self) -> Optional[DefaultActionsMapping]: + """ + Controllers can implement this function. It should return a dict + with the command that a controller can take and the functions as values. + This is used for Lutron support. + """ + return None + def get_predefined_actions_mapping(self) -> PredefinedActionsMapping: return {} diff --git a/apps/controllerx/cx_core/custom_controller.py b/apps/controllerx/cx_core/custom_controller.py index 92f8b63c..a54143c3 100644 --- a/apps/controllerx/cx_core/custom_controller.py +++ b/apps/controllerx/cx_core/custom_controller.py @@ -12,7 +12,7 @@ async def init(self) -> None: level="WARNING", ascii_encode=False, ) - await super().init() + await super().init() # pragma: no cover class CustomMediaPlayerController(MediaPlayerController): @@ -22,7 +22,7 @@ async def init(self) -> None: level="WARNING", ascii_encode=False, ) - await super().init() + await super().init() # pragma: no cover class CustomSwitchController(SwitchController): @@ -32,7 +32,7 @@ async def init(self) -> None: level="WARNING", ascii_encode=False, ) - await super().init() + await super().init() # pragma: no cover class CustomCoverController(CoverController): @@ -42,7 +42,7 @@ async def init(self) -> None: level="WARNING", ascii_encode=False, ) - await super().init() + await super().init() # pragma: no cover class CallServiceController(Controller): @@ -52,4 +52,4 @@ async def init(self) -> None: level="WARNING", ascii_encode=False, ) - await super().init() + await super().init() # pragma: no cover diff --git a/apps/controllerx/cx_core/integration/lutron_caseta.py b/apps/controllerx/cx_core/integration/lutron_caseta.py new file mode 100644 index 00000000..5a2592a3 --- /dev/null +++ b/apps/controllerx/cx_core/integration/lutron_caseta.py @@ -0,0 +1,26 @@ +from typing import Optional + +from appdaemon.plugins.hass.hassapi import Hass # type: ignore +from cx_const import DefaultActionsMapping +from cx_core.integration import EventData, Integration + + +class LutronIntegration(Integration): + name = "lutron_caseta" + + def get_default_actions_mapping(self) -> Optional[DefaultActionsMapping]: + return self.controller.get_lutron_caseta_actions_mapping() + + def listen_changes(self, controller_id: str) -> None: + Hass.listen_event( + self.controller, + self.callback, + "lutron_caseta_button_event", + serial=controller_id, + ) + + async def callback(self, event_name: str, data: EventData, kwargs: dict) -> None: + button = data["button_number"] + action_type = data["action"] + action = f"button_{button}_{action_type}" + await self.controller.handle_action(action, extra=data) diff --git a/apps/controllerx/cx_devices/ikea.py b/apps/controllerx/cx_devices/ikea.py index 5687d8e2..c10ea693 100644 --- a/apps/controllerx/cx_devices/ikea.py +++ b/apps/controllerx/cx_devices/ikea.py @@ -486,3 +486,17 @@ def get_zha_actions_mapping(self) -> DefaultActionsMapping: "down_close": Cover.TOGGLE_CLOSE, "stop": Cover.STOP, } + + +class E1812LightController(LightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "on": Light.TOGGLE, + "brightness_move_up": Light.HOLD_BRIGHTNESS_TOGGLE, + "brightness_stop": Light.RELEASE, + } + + +class E1812SwitchController(SwitchController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return {"on": Switch.TOGGLE} diff --git a/apps/controllerx/cx_devices/lutron.py b/apps/controllerx/cx_devices/lutron.py index 94b2e867..19e26b12 100644 --- a/apps/controllerx/cx_devices/lutron.py +++ b/apps/controllerx/cx_devices/lutron.py @@ -2,109 +2,153 @@ from cx_core import LightController, MediaPlayerController -class LutronCasetaProPicoLightController(LightController): - # This requires the LutronCasetaPro CUSTOM integration by upsert - # https://github.com/upsert/lutron-caseta-pro - # THIS WILL NOT WORK with the default Lutron Caseta integration - # Pico remotes using this integration report 6 states from their sensor: - # top button = "1", up button = "8", middle round = "2", down arrow = "16", - # bottom button = "4", no button pressed = "0" +class LZL4BWHL01LightController(LightController): + # Each button press fires an event but no separate + # hold event. Press of up or down generates a stop event + # when released. + + def get_deconz_actions_mapping(self) -> DefaultActionsMapping: + return { + 1002: Light.ON_FULL_BRIGHTNESS, + 2001: Light.HOLD_BRIGHTNESS_UP, + 2003: Light.RELEASE, + 3001: Light.HOLD_BRIGHTNESS_DOWN, + 3003: Light.RELEASE, + 4002: Light.OFF, + } + def get_zha_actions_mapping(self) -> DefaultActionsMapping: + return { + "move_to_level_with_on_off_254_4": Light.ON_FULL_BRIGHTNESS, + "step_with_on_off_0_30_6": Light.HOLD_BRIGHTNESS_UP, + "step_1_30_6": Light.HOLD_BRIGHTNESS_DOWN, + "move_to_level_with_on_off_0_4": Light.OFF, + "stop": Light.RELEASE, + } + + +class Z31BRLLightController(LightController): + def get_deconz_actions_mapping(self) -> DefaultActionsMapping: + return { + 1002: Light.TOGGLE, + 2002: Light.CLICK_BRIGHTNESS_UP, + 3002: Light.CLICK_BRIGHTNESS_DOWN, + } + + +class LutronPJ22BLightController(LightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "1": Light.ON_FULL_BRIGHTNESS, + "4": Light.OFF, + } + + def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping: + return { + "button_2_press": Light.ON, + "button_4_press": Light.OFF, + } + + +class LutronPJ22BMediaPlayerController(MediaPlayerController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "1": MediaPlayer.PLAY_PAUSE, + "4": MediaPlayer.NEXT_TRACK, + } + + def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping: + return { + "button_2_press": MediaPlayer.PLAY_PAUSE, + "button_4_press": MediaPlayer.NEXT_TRACK, + } + + +class LutronPJ22BRLLightController(LightController): def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { "1": Light.ON_FULL_BRIGHTNESS, "8": Light.HOLD_BRIGHTNESS_UP, - "2": Light.SET_HALF_BRIGHTNESS, "16": Light.HOLD_BRIGHTNESS_DOWN, "4": Light.OFF, "0": Light.RELEASE, } -class LutronCasetaProPicoMediaPlayerController(MediaPlayerController): - # This requires the LutronCasetaPro CUSTOM integration by upsert - # https://github.com/upsert/lutron-caseta-pro - # THIS WILL NOT WORK with the default Lutron Caseta integration - # Pico remotes using this integration report 6 states from their sensor: - # top button = "1", up button = "8", middle round = "2", down arrow = "16", - # bottom button = "4", no button pressed = "0" - +class LutronPJ22BRLMediaPlayerController(MediaPlayerController): def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { "1": MediaPlayer.PLAY_PAUSE, "8": MediaPlayer.HOLD_VOLUME_UP, - "2": MediaPlayer.NEXT_SOURCE, "16": MediaPlayer.HOLD_VOLUME_DOWN, "4": MediaPlayer.NEXT_TRACK, "0": MediaPlayer.RELEASE, } -class LutronCasetaProPJ24BLightController(LightController): - # This requires the LutronCasetaPro CUSTOM integration by upsert - # https://github.com/upsert/lutron-caseta-pro - # THIS WILL NOT WORK with the default Lutron Caseta integration - # Pico remotes using this integration report 5 states from their sensor: - # top button = "1", second button = "2", third button = "4", - # bottom button = "8", no button pressed = "0" - +class LutronPJ23BRLLightController(LightController): def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { "1": Light.ON_FULL_BRIGHTNESS, - "2": Light.HOLD_BRIGHTNESS_UP, - "4": Light.HOLD_BRIGHTNESS_DOWN, - "8": Light.OFF, + "8": Light.HOLD_BRIGHTNESS_UP, + "2": Light.SET_HALF_BRIGHTNESS, + "16": Light.HOLD_BRIGHTNESS_DOWN, + "4": Light.OFF, "0": Light.RELEASE, } + def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping: + return { + "button_2_press": Light.ON_FULL_BRIGHTNESS, + "button_4_press": Light.OFF, + "button_3_press": Light.SET_HALF_BRIGHTNESS, + "button_5_press": Light.HOLD_BRIGHTNESS_UP, + "button_5_release": Light.RELEASE, + "button_6_press": Light.HOLD_BRIGHTNESS_DOWN, + "button_6_release": Light.RELEASE, + } -class LutronCasetaProPJ24BMediaPlayerController(MediaPlayerController): - # This requires the LutronCasetaPro CUSTOM integration by upsert - # https://github.com/upsert/lutron-caseta-pro - # THIS WILL NOT WORK with the default Lutron Caseta integration - # Pico remotes using this integration report 5 states from their sensor: - # top button = "1", second button = "2", third button = "4", - # bottom button = "8", no button pressed = "0" +class LutronPJ23BRLMediaPlayerController(MediaPlayerController): def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { "1": MediaPlayer.PLAY_PAUSE, - "2": MediaPlayer.HOLD_VOLUME_UP, - "4": MediaPlayer.HOLD_VOLUME_DOWN, - "8": MediaPlayer.NEXT_TRACK, + "8": MediaPlayer.HOLD_VOLUME_UP, + "2": MediaPlayer.NEXT_SOURCE, + "16": MediaPlayer.HOLD_VOLUME_DOWN, + "4": MediaPlayer.NEXT_TRACK, "0": MediaPlayer.RELEASE, } - -class LZL4BWHL01LightController(LightController): - # Each button press fires an event but no separate - # hold event. Press of up or down generates a stop event - # when released. - - def get_deconz_actions_mapping(self) -> DefaultActionsMapping: + def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping: return { - 1002: Light.ON_FULL_BRIGHTNESS, - 2001: Light.HOLD_BRIGHTNESS_UP, - 2003: Light.RELEASE, - 3001: Light.HOLD_BRIGHTNESS_DOWN, - 3003: Light.RELEASE, - 4002: Light.OFF, + "button_2_press": MediaPlayer.PLAY_PAUSE, + "button_4_press": MediaPlayer.NEXT_TRACK, + "button_3_press": MediaPlayer.NEXT_SOURCE, + "button_5_press": MediaPlayer.HOLD_VOLUME_UP, + "button_5_release": MediaPlayer.RELEASE, + "button_6_press": MediaPlayer.HOLD_VOLUME_DOWN, + "button_6_release": MediaPlayer.RELEASE, } - def get_zha_actions_mapping(self) -> DefaultActionsMapping: + +class LutronPJ24BLightController(LightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { - "move_to_level_with_on_off_254_4": Light.ON_FULL_BRIGHTNESS, - "step_with_on_off_0_30_6": Light.HOLD_BRIGHTNESS_UP, - "step_1_30_6": Light.HOLD_BRIGHTNESS_DOWN, - "move_to_level_with_on_off_0_4": Light.OFF, - "stop": Light.RELEASE, + "1": Light.ON_FULL_BRIGHTNESS, + "2": Light.HOLD_BRIGHTNESS_UP, + "4": Light.HOLD_BRIGHTNESS_DOWN, + "8": Light.OFF, + "0": Light.RELEASE, } -class Z31BRLLightController(LightController): - def get_deconz_actions_mapping(self) -> DefaultActionsMapping: +class LutronPJ24BMediaPlayerController(MediaPlayerController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: return { - 1002: Light.TOGGLE, - 2002: Light.CLICK_BRIGHTNESS_UP, - 3002: Light.CLICK_BRIGHTNESS_DOWN, + "1": MediaPlayer.PLAY_PAUSE, + "2": MediaPlayer.HOLD_VOLUME_UP, + "4": MediaPlayer.HOLD_VOLUME_DOWN, + "8": MediaPlayer.NEXT_TRACK, + "0": MediaPlayer.RELEASE, } diff --git a/apps/controllerx/cx_devices/muller_licht.py b/apps/controllerx/cx_devices/muller_licht.py index 911bda3a..a6d721ed 100644 --- a/apps/controllerx/cx_devices/muller_licht.py +++ b/apps/controllerx/cx_devices/muller_licht.py @@ -1,5 +1,7 @@ from cx_const import DefaultActionsMapping, Light from cx_core import LightController +from cx_core.controller import Controller +from cx_core.integration import EventData class MLI404011LightController(LightController): @@ -42,3 +44,39 @@ def get_deconz_actions_mapping(self) -> DefaultActionsMapping: # 11002: "", # fire button # 12002: "", # heart button } + + +class MLI404002Controller(Controller): + def get_zha_action(self, data: EventData) -> str: + command = data["command"] + if command not in ("move", "step"): + return command + args = data["args"] + direction_mapping = {0: "up", 1: "down"} + return f"{command}_{direction_mapping[args[0]]}" + + +class MLI404002LightController(MLI404002Controller, LightController): + def get_z2m_actions_mapping(self) -> DefaultActionsMapping: + return { + "on": Light.TOGGLE, + "off": Light.TOGGLE, + "brightness_step_up": Light.CLICK_BRIGHTNESS_UP, + "brightness_step_down": Light.CLICK_BRIGHTNESS_DOWN, + "brightness_move_up": Light.HOLD_BRIGHTNESS_UP, + "brightness_move_down": Light.HOLD_BRIGHTNESS_DOWN, + "brightness_stop": Light.RELEASE, + "recall_1": Light.ON_FULL_BRIGHTNESS, + } + + def get_zha_actions_mapping(self) -> DefaultActionsMapping: + return { + "on": Light.TOGGLE, + "off": Light.TOGGLE, + "move_up": Light.HOLD_BRIGHTNESS_UP, + "move_down": Light.HOLD_BRIGHTNESS_DOWN, + "stop": Light.RELEASE, + "step_up": Light.CLICK_BRIGHTNESS_UP, + "step_down": Light.CLICK_BRIGHTNESS_DOWN, + "recall": Light.ON_FULL_BRIGHTNESS, + } diff --git a/apps/controllerx/cx_version.py b/apps/controllerx/cx_version.py index ac7979a2..924f4698 100644 --- a/apps/controllerx/cx_version.py +++ b/apps/controllerx/cx_version.py @@ -1 +1 @@ -__version__ = "v4.6.0" +__version__ = "v4.7.0b0" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ce746d88..a6500b12 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -4,75 +4,78 @@ # https://docs.microsoft.com/azure/devops/pipelines/languages/python trigger: -- master -- dev -- refs/tags/* + - main + - dev + - refs/tags/* pr: -- master -- dev + - main + - dev pool: vmImage: ubuntu-latest stages: -- stage: Build - displayName: Build Stage - jobs: - - job: Build - displayName: Build job - strategy: - matrix: - Python36: - python.version: '3.6' - Python37: - python.version: '3.7' - Python38: - python.version: '3.8' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: $(python.version) - displayName: Prepare Python $(python.version) - - script: pip install pipenv - displayName: Install pipenv - - script: pipenv lock --dev - displayName: Lock dependencies - - script: pipenv install --system --deploy --dev - displayName: Install dependencies - - script: isort apps/controllerx/ tests/ --check - displayName: Organize imports (isort) - - script: black apps/controllerx/ tests/ --check - displayName: Formatter (black) - - script: flake8 apps/controllerx/ tests/ - displayName: Styling (flake8) - - script: mypy apps/controllerx/ tests/ - displayName: Typing (mypy) - - script: pytest tests --doctest-modules --junitxml=junit/test-results.xml --cov=apps --cov-report=xml --cov-report=html - displayName: Tests (pytest) - - task: PublishCodeCoverageResults@1 - displayName: Publish code coverage - inputs: - codeCoverageTool: Cobertura - summaryFileLocation: $(System.DefaultWorkingDirectory)/**/coverage.xml -- stage: Deploy - displayName: Deploy Stage - condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) - jobs: - - job: Deploy - displayName: Deploy job - steps: - - task: ArchiveFiles@2 - displayName: Zipping apps/controllerx folder - inputs: - rootFolderOrFile: $(System.DefaultWorkingDirectory)/apps/controllerx - includeRootFolder: false - archiveType: zip - archiveFile: $(Build.ArtifactStagingDirectory)/controllerx.zip - - task: GithubRelease@0 - displayName: Create GitHub Release - inputs: - gitHubConnection: github.com_xaviml - repositoryName: xaviml/controllerx - isPreRelease: ${{ contains(variables['Build.SourceBranchName'], 'b') }} - assets: $(Build.ArtifactStagingDirectory)/controllerx.zip + - stage: Build + displayName: Build Stage + jobs: + - job: Build + displayName: Build job + strategy: + matrix: + Python36: + python.version: "3.6" + Python37: + python.version: "3.7" + Python38: + python.version: "3.8" + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(python.version) + displayName: Prepare Python $(python.version) + - script: pip install pipenv + displayName: Install pipenv + - script: pipenv lock --dev + displayName: Lock dependencies + - script: pipenv install --system --deploy --dev + displayName: Install dependencies + - script: pip install dataclasses + displayName: Installing dataclasses + condition: eq(variables['python.version'], '3.6') + - script: isort apps/controllerx/ tests/ --check + displayName: Organize imports (isort) + - script: black apps/controllerx/ tests/ --check + displayName: Formatter (black) + - script: flake8 apps/controllerx/ tests/ + displayName: Styling (flake8) + - script: mypy apps/controllerx/ tests/ + displayName: Typing (mypy) + - script: pytest tests --doctest-modules --junitxml=junit/test-results.xml --cov=apps --cov-report=xml --cov-report=html + displayName: Tests (pytest) + - task: PublishCodeCoverageResults@1 + displayName: Publish code coverage + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: $(System.DefaultWorkingDirectory)/**/coverage.xml + - stage: Deploy + displayName: Deploy Stage + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/v')) + jobs: + - job: Deploy + displayName: Deploy job + steps: + - task: ArchiveFiles@2 + displayName: Zipping apps/controllerx folder + inputs: + rootFolderOrFile: $(System.DefaultWorkingDirectory)/apps/controllerx + includeRootFolder: false + archiveType: zip + archiveFile: $(Build.ArtifactStagingDirectory)/controllerx.zip + - task: GithubRelease@0 + displayName: Create GitHub Release + inputs: + gitHubConnection: github.com_xaviml + repositoryName: xaviml/controllerx + isPreRelease: ${{ contains(variables['Build.SourceBranchName'], 'b') }} + assets: $(Build.ArtifactStagingDirectory)/controllerx.zip diff --git a/docs/_data/controllers/E1812.yml b/docs/_data/controllers/E1812.yml new file mode 100644 index 00000000..df8c976f --- /dev/null +++ b/docs/_data/controllers/E1812.yml @@ -0,0 +1,21 @@ +name: E1812 (IKEA) +device_support: + - type: Light + domain: light + controller: E1812LightController + delay: 350 + mapping: + - "Click → Toggle" + - "Hold → Brightness up/down with direction changes" + - type: Switch + domain: switch + controller: E1812SwitchController + mapping: + - "Click → Toggle" +integrations: + - name: Zigbee2MQTT + codename: z2m + actions: + - "on → Click" + - "brightness_move_up → Hold" + - "brightness_stop → Released after being held" diff --git a/docs/_data/controllers/MLI-404002.yml b/docs/_data/controllers/MLI-404002.yml new file mode 100644 index 00000000..76b4b7f8 --- /dev/null +++ b/docs/_data/controllers/MLI-404002.yml @@ -0,0 +1,37 @@ +name: MLI-404002 (Müller Licht) +device_support: + - type: Light + domain: light + controller: MLI404002LightController + delay: 500 + mapping: + - "Toggle button → Toggle" + - "Click 🔆 → Brighten up (1 step)" + - "Click 🔅 → Dim down (1 step)" + - "Click cold → Color temp down / Left color wheel (1 step) (not for z2m)" + - "Click warm → Color temp up / Right color wheel (1 step) (not for z2m)" + - "Hold 🔆 → Brighten up" + - "Hold 🔅→ Dim down" +integrations: + - name: Zigbee2MQTT + codename: z2m + actions: + - '"on" → Toggle button' + - '"off" → Toggle button' + - brightness_step_down → Click 🔅 + - brightness_move_down → Hold 🔅 + - brightness_stop → Release 🔅/🔆 + - brightness_step_up → Click 🔆 + - brightness_move_up → Hold 🔆 + - recall_1 → Click arrow back + - name: ZHA + codename: zha + actions: + - '"on" → Toggle button' + - '"off" → Toggle button' + - move_up → Hold 🔆 + - move_down → Hold 🔅 + - stop → Release 🔅/🔆 + - step_up → Click 🔆 + - step_down → Click 🔅 + - recall → Click arrow back diff --git a/docs/_data/controllers/MLI-404011.yml b/docs/_data/controllers/MLI-404011.yml index 6491ff5d..b33c12e2 100644 --- a/docs/_data/controllers/MLI-404011.yml +++ b/docs/_data/controllers/MLI-404011.yml @@ -13,11 +13,11 @@ device_support: - "Hold 🔆 → Brighten up" - "Hold 🔅→ Dim down" integrations: - - name: deCONZ - codename: deconz + - name: Zigbee2MQTT + codename: z2m actions: - - on → Toggle button - - off → Toggle button + - '"on" → Toggle button' + - '"off" → Toggle button' - brightness_down_click → Click 🔅 - brightness_down_hold → Hold 🔅 - brightness_down_release → Release 🔅 diff --git a/docs/_data/controllers/PJ2-2B.yml b/docs/_data/controllers/PJ2-2B.yml index 43705c59..ec3626e9 100644 --- a/docs/_data/controllers/PJ2-2B.yml +++ b/docs/_data/controllers/PJ2-2B.yml @@ -2,20 +2,20 @@ name: PJ2-2B (Lutron Caseta Pro) device_support: - type: Light domain: light - controller: LutronCasetaProPicoLightController + controller: LutronPJ22BLightController delay: 350 mapping: - "Top button → Full brightness" - "Bottom button → Turn off" - type: Media Player domain: media_player - controller: LutronCasetaProPicoMediaPlayerController + controller: LutronPJ22BMediaPlayerController delay: 500 mapping: - "Top button → Play/Pause" - "Bottom button → Next track" note: >- - This requires the LutronCasetaPro + For the State integration, it requires the LutronCasetaPro CUSTOM integration by upsert. THIS WILL NOT WORK with the default Lutron Caseta integration. All Lutron Caseta Pro Pico remotes supported by LutronCasetaPro are supported using this controller type except for the 4-button PJ2-4B remotes which is separate. @@ -24,6 +24,13 @@ integrations: - name: State codename: state actions: - - "\"1\" → Top button" - - "\"4\" → Bottom button" - - "\"0\" → Release/Idle state" + - '"1" → Top button' + - '"4" → Bottom button' + - '"0" → Release/Idle state' + - name: Lutron Caseta + codename: lutron_caseta + actions: + - button_2_press → Top button + - button_4_press → Bottom button + - button_2_release → Release top button + - button_4_release → Release bottom button diff --git a/docs/_data/controllers/PJ2-2BRL.yml b/docs/_data/controllers/PJ2-2BRL.yml index 67bbdcc3..d7a56b66 100644 --- a/docs/_data/controllers/PJ2-2BRL.yml +++ b/docs/_data/controllers/PJ2-2BRL.yml @@ -2,7 +2,7 @@ name: PJ2-2BRL (Lutron Caseta Pro) device_support: - type: Light domain: light - controller: LutronCasetaProPicoLightController + controller: LutronPJ22BRLLightController delay: 350 mapping: - "Top button → Full brightness" @@ -11,7 +11,7 @@ device_support: - "Bottom button → Turn off" - type: Media Player domain: media_player - controller: LutronCasetaProPicoMediaPlayerController + controller: LutronPJ22BRLMediaPlayerController delay: 500 mapping: - "Top button → Play/Pause" @@ -19,7 +19,7 @@ device_support: - "Arrow down button → Volume down" - "Bottom button → Next track" note: >- - This requires the LutronCasetaPro + For the State integration, it requires the LutronCasetaPro CUSTOM integration by upsert. THIS WILL NOT WORK with the default Lutron Caseta integration. All Lutron Caseta Pro Pico remotes supported by LutronCasetaPro are supported using this controller type except for the 4-button PJ2-4B remotes which is separate. @@ -28,8 +28,8 @@ integrations: - name: State codename: state actions: - - "\"1\" → Top button" - - "\"8\" → Arrow up button" - - "\"16\" → Arrow down button" - - "\"4\" → Bottom button" - - "\"0\" → Release/Idle state" + - '"1" → Top button' + - '"8" → Arrow up button' + - '"16" → Arrow down button' + - '"4" → Bottom button' + - '"0" → Release/Idle state' diff --git a/docs/_data/controllers/PJ2-3BRL.yml b/docs/_data/controllers/PJ2-3BRL.yml index 28f68381..d401bb07 100644 --- a/docs/_data/controllers/PJ2-3BRL.yml +++ b/docs/_data/controllers/PJ2-3BRL.yml @@ -2,7 +2,7 @@ name: PJ2-3BRL (Lutron Caseta Pro) device_support: - type: Light domain: light - controller: LutronCasetaProPicoLightController + controller: LutronPJ23BRLLightController delay: 350 mapping: - "Top button → Full brightness" @@ -12,7 +12,7 @@ device_support: - "Bottom button → Turn off" - type: Media Player domain: media_player - controller: LutronCasetaProPicoMediaPlayerController + controller: LutronPJ23BRLMediaPlayerController delay: 500 mapping: - "Top button → Play/Pause" @@ -21,7 +21,7 @@ device_support: - "Arrow down button → Volume down" - "Bottom button → Next track" note: >- - This requires the LutronCasetaPro + For the State integration, it requires the LutronCasetaPro CUSTOM integration by upsert. THIS WILL NOT WORK with the default Lutron Caseta integration. All Lutron Caseta Pro Pico remotes supported by LutronCasetaPro are supported using this controller type except for the 4-button PJ2-4B remotes which is separate. @@ -30,9 +30,22 @@ integrations: - name: State codename: state actions: - - "\"1\" → Top button" - - "\"8\" → Arrow up button" - - "\"2\" → Favourite/Middle round" - - "\"16\" → Arrow down button" - - "\"4\" → Bottom button" - - "\"0\" → Release/Idle state" + - '"1" → Top button' + - '"8" → Arrow up button' + - '"2" → Favourite/Middle round' + - '"16" → Arrow down button' + - '"4" → Bottom button' + - '"0" → Release/Idle state' + - name: Lutron Caseta + codename: lutron_caseta + actions: + - button_2_press → Top button + - button_2_release → Release top button + - button_4_press → Bottom button + - button_4_release → Release bottom button + - button_3_press → Favourite/Middle round + - button_3_release → Release Favourite/Middle round + - button_5_press → Arrow up button + - button_5_release → Release arrow up button + - button_6_press → Arrow down button + - button_6_release → Release arrow down button diff --git a/docs/_data/controllers/PJ2-4B.yml b/docs/_data/controllers/PJ2-4B.yml index 7b8501f9..7e05b211 100644 --- a/docs/_data/controllers/PJ2-4B.yml +++ b/docs/_data/controllers/PJ2-4B.yml @@ -2,7 +2,7 @@ name: PJ2-4B (Lutron Caseta Pro) device_support: - type: Light domain: light - controller: LutronCasetaProPJ24BLightController + controller: LutronPJ24BLightController delay: 350 mapping: - "Top button → Full brightness" @@ -11,7 +11,7 @@ device_support: - "Bottom button → Turn off" - type: Media Player domain: media_player - controller: LutronCasetaProPicoPJ24BMediaPlayerController + controller: LutronPJ24BMediaPlayerController delay: 500 mapping: - "Top button → Play/Pause" @@ -19,15 +19,15 @@ device_support: - "Third button → Volume down" - "Bottom button → Next track" note: >- - This requires the LutronCasetaPro + For the State integration, it requires the LutronCasetaPro CUSTOM integration by upsert. THIS WILL NOT WORK with the default Lutron Caseta integration. integrations: - name: State codename: state actions: - - "\"1\" → Top button" - - "\"2\" → Second button" - - "\"4\" → Third button" - - "\"8\" → Bottom button" - - "\"0\" → Release/Idle state" + - '"1" → Top button' + - '"2" → Second button' + - '"4" → Third button' + - '"8" → Bottom button' + - '"0" → Release/Idle state' diff --git a/docs/assets/img/E1812.jpeg b/docs/assets/img/E1812.jpeg new file mode 100644 index 00000000..cf1dfefa Binary files /dev/null and b/docs/assets/img/E1812.jpeg differ diff --git a/docs/assets/img/MLI-404002.jpeg b/docs/assets/img/MLI-404002.jpeg new file mode 100644 index 00000000..7d6de30b Binary files /dev/null and b/docs/assets/img/MLI-404002.jpeg differ diff --git a/docs/controllers/E1812.md b/docs/controllers/E1812.md new file mode 100644 index 00000000..fd6d85eb --- /dev/null +++ b/docs/controllers/E1812.md @@ -0,0 +1,5 @@ +--- +layout: controller +title: E1812 (IKEA) +device: E1812 +--- diff --git a/docs/controllers/MLI-404002.md b/docs/controllers/MLI-404002.md new file mode 100644 index 00000000..ee6ac2b3 --- /dev/null +++ b/docs/controllers/MLI-404002.md @@ -0,0 +1,5 @@ +--- +layout: controller +title: MLI-404002 (Müller Licht) +device: MLI-404002 +--- diff --git a/docs/examples/sonos-display.md b/docs/examples/sonos-display.md index 8e3314c0..a70b9741 100644 --- a/docs/examples/sonos-display.md +++ b/docs/examples/sonos-display.md @@ -2,6 +2,7 @@ title: SONOS/SYMFONISK Display example layout: page --- + {% raw %} ![Three different Sonos displays](/controllerx/assets/img/sonos_displays_2.jpg) @@ -13,14 +14,15 @@ All it takes is an ESP8266 with ESPHome software, an appropriate display, a hand **Current v1.1 display code uses newly merged display on/off and brightness commands. Use ESPHome version [1.15.0b4](https://github.com/esphome/esphome/releases/tag/v1.15.0b4) or newer !** -YAML has been tested on both NodeMCUv2 , Wemos D1 Mini and NodeMCU with integrated display using both SSD1306 & SSD1309 displays (I2C connected). +YAML has been tested on both NodeMCUv2 , Wemos D1 Mini and NodeMCU with integrated display using both SSD1306 & SSD1309 displays (I2C connected). ### Hardware: + I initially used the simple and inexpensive (less than 2 US$ ) SSD1306 0,96" OLED display for this build. Resolution is only 128x64. But still enough, when using several pages to be displayed continously. The SSD1306 has a 'big brother' in the SSD1309 display. This display has identical resolution as SSD1306, is priced at some 14 US$, can use same drivers/library as SSD1306 but is much, much larger at 2,42". I really like this good sized and simple I2C display and ended up using this display in the final build, as it's much easier to read from a distance 🙂 An optional PIR sensor or microwave radar sensor can be added for automatic dimming (brightness control) and turning the display on/off completely. The RCWL 0516 sensor is cheap, but can be somewhat difficult to use in 'tight' builds as it's somewhat sensitive to many things - WiFi in particular. So you could experience some false triggers using this sensor if fitted very close to the ESP8266. AM 312 is a cheap and simple 'no nonsense' PIR sensor that just always works as expected. Sensor can also be used for other purposes as well in HA 🙂 Sensor is configured in YAML using pin D5 (GPIO14). Display is set to dim down after 5 minutes with no PIR triggers and completely off after additional 10 minutes without registered movement. -Please note that (at least on my display version) SSD1309 display can't be turned completely off with `id(display_id).set_brightness(0)` command, but this works perfectly on my SSD1306 display. Instead SSD1309 has to be turned on/off with specific `id(display_id).turn_on()`/`id(display_id).turn_off()` commands. Check what works on **your** display and revise implementation method/ESPHome yaml config code accordingly. +Please note that (at least on my display version) SSD1309 display can't be turned completely off with `id(display_id).set_brightness(0)` command, but this works perfectly on my SSD1306 display. Instead SSD1309 has to be turned on/off with specific `id(display_id).turn_on()`/`id(display_id).turn_off()` commands. Check what works on **your** display and revise implementation method/ESPHome yaml config code accordingly. I've collected some hardware link examples below. These are just some random sellers I've picked. Not necessarily the cheapest or best sellers. @@ -55,61 +57,63 @@ In order to get display to work with I2C instead of SPI, you need to do a bit of CS : NC (No Connection - 'floating'. Default I2C address 0x3c) DC : NC (No Connection - 'floating') - ### Display setup: + My current display setup consists of four pages that all are displayed for 5 seconds. Following information is displayed on the screen: **All pages:** Source (if not present, display ‘Sonos/Playlist’), mute sign, - volume setting and play/pause/idle status. Also displays shuffle - sign when active for playlists +volume setting and play/pause/idle status. Also displays shuffle +sign when active for playlists -**Page 1:** Active master/slave speakers. +**Page 1:** Active main/passive speakers. **Page 2:** Media artist/media title - (if not available from stream, display time instead) +(if not available from stream, display time instead) **Page 3:** Time **Page 4:** Outdoor temperature sensor value ### True Type Fonts: + Three 'standard' Calibri TT fonts are used plus a 'special' version of Heydings Icons font in which I've included some Heydings Controls icons as well. -If you experience some strange characters on the display, you probably need to edit the glyphs in ESPHome YAML and add whatever language specific characters you find are missing. +If you experience some strange characters on the display, you probably need to edit the glyphs in ESPHome YAML and add whatever language specific characters you find are missing. Calibri TTF fonts [link](https://www.fontdload.com/dl/calibri-font/) -Heydings Icons special file [link](https://github.com/xaviml/controllerx/blob/master/docs/assets/img/HeydingsIconsSymbols.ttf) +Heydings Icons special file [link](https://github.com/xaviml/controllerx/blob/main/docs/assets/img/HeydingsIconsSymbols.ttf) Copy Calibri Bold, Calibri Regular, Calibri Light fonts plus the special Heydings Icons Symbols font file to the ESPHome folder `/config/esphome/` ### Home Assistant sensors: + Below you’ll find the HA template sensors needed in `configuration.yaml `for ESPHome display to work. ESPHome will establish some four HA sensors as well, presented on HA frontend: PIR sensor, connection status, WiFi strength and display on/off sensor. If display on/off is turned off from HA, then triggering PIR will not turn on display or alter brightness. -Note: `media_artist` and `media_title` attributes from HA's Sonos integration *could* be swapped for some radio stations, as these attributes are split from one combined string in the stream. Some radio stations have artist - title order, others use title - artist. You really can't tell... -My danish radio stations (source list) all use the 'swapped' version, so my templates below swap these two attributes for radio stations. +Note: `media_artist` and `media_title` attributes from HA's Sonos integration _could_ be swapped for some radio stations, as these attributes are split from one combined string in the stream. Some radio stations have artist - title order, others use title - artist. You really can't tell... +My danish radio stations (source list) all use the 'swapped' version, so my templates below swap these two attributes for radio stations. -Enter your master speaker as trigger entity ID for all templates but the first two (search for `media_player.office` and replace with your master speaker entity). Without this specific hardcoded trigger entity, templates simply doesn't always update correctly. So unfortunately they're needed for now, until I hopefully find a 'cleaner' and more dynamic solution. +Enter your main speaker as trigger entity ID for all templates but the first two (search for `media_player.office` and replace with your main speaker entity). Without this specific hardcoded trigger entity, templates simply doesn't always update correctly. So unfortunately they're needed for now, until I hopefully find a 'cleaner' and more dynamic solution. ```yaml # Sonos sensors sensor: - platform: template sensors: - sonos_master_friendly: - friendly_name: "Sonos Master Friendly" + sonos_main_friendly: + friendly_name: "Sonos main Friendly" entity_id: group.sonos_all value_template: "{{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'friendly_name') }}" - sonos_slaves_friendly: - friendly_name: "Sonos Slaves Friendly" + sonos_passives_friendly: + friendly_name: "Sonos passives Friendly" entity_id: group.sonos_all - value_template: >- - {% for entity_id in state_attr("group.sonos_all", "entity_id")[1:] -%} - {% set friendly_name = state_attr(entity_id, "friendly_name") %} - {%- if loop.last %}{{ friendly_name }}{% else %}{{ friendly_name }}, {% endif -%} - {%- endfor %} + value_template: >- + {% for entity_id in state_attr("group.sonos_all", "entity_id")[1:] -%} + {% set friendly_name = state_attr(entity_id, "friendly_name") %} + {%- if loop.last %}{{ friendly_name }}{% else %}{{ friendly_name }}, {% endif -%} + {%- endfor %} media_title: # Swap title/artist if 'source' attribute is not present = radio - entity_id: media_player.office # Sonos master speaker + entity_id: media_player.office # Sonos main speaker value_template: >- {% if is_state('sensor.media_source' , "no source") %} {{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'media_title') }} @@ -117,7 +121,7 @@ sensor: {{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'media_artist') }} {% endif %} media_artist: # Swap title/artist if 'source' attribute is not present = radio - entity_id: media_player.office # Sonos master speaker + entity_id: media_player.office # Sonos main speaker value_template: >- {% if is_state('sensor.media_source' , "no source") %} {{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'media_artist') }} @@ -125,10 +129,10 @@ sensor: {{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'media_title') }} {% endif %} media_album_name: - entity_id: media_player.office # Sonos master speaker + entity_id: media_player.office # Sonos main speaker value_template: "{{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'media_album_name') }}" media_source: # Remove all after 'DR P4 Fyn' as source (to fit on display) - entity_id: media_player.office # Sonos master speaker + entity_id: media_player.office # Sonos main speaker value_template: >- {% if state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'source') %} {{states.media_player.office.attributes.source.split('96.8')[0]}} @@ -136,28 +140,31 @@ sensor: no source {% endif %} volume: - entity_id: media_player.office # Sonos master speaker + entity_id: media_player.office # Sonos main speaker value_template: "{{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'volume_level')|float * 100 }}" - sonos_master_group_entities: - entity_id: media_player.office # Sonos master speaker + sonos_main_group_entities: + entity_id: media_player.office # Sonos main speaker value_template: "{{ state_attr(state_attr('group.sonos_all', 'entity_id')[0], 'sonos_group') }}" ``` ### Home Assistant group: -Here you define your Sonos speaker entities. Master speaker has to be entered as first entity and all that's actually needed. Active slave speakers will dynamically be added on HA restart or when group configuration is changed (via Sonos app/HA service calls eg.) If you're only using one speaker, you still need to create the group in `groups.yaml` and populate with that single master speaker entity, as the group entity is needed in the code. -**One note on master speaker, slaves and Sonos groups**
-Your defined master speaker actually doesn't need to be ***the*** master speaker. As long as it's part of the group (master ***or*** slave), then display will still show data for the group. But if defined master speaker is removed from the group, it will be a 'single speaker group' on it's own, and display will reflect master speaker data only. +Here you define your Sonos speaker entities. main speaker has to be entered as first entity and all that's actually needed. Active passive speakers will dynamically be added on HA restart or when group configuration is changed (via Sonos app/HA service calls eg.) If you're only using one speaker, you still need to create the group in `groups.yaml` and populate with that single main speaker entity, as the group entity is needed in the code. + +**One note on main speaker, passives and Sonos groups**
+Your defined main speaker actually doesn't need to be **_the_** main speaker. As long as it's part of the group (main **_or_** passive), then display will still show data for the group. But if defined main speaker is removed from the group, it will be a 'single speaker group' on it's own, and display will reflect main speaker data only. + ```yaml sonos_all: name: sonos_all entities: - - media_player.office # This HAS to be your MASTER speaker - # - media_player.kitchen # Optional - SLAVE speaker #1 - # - media_player.livingroom # Optional - SLAVE speaker #2 + - media_player.office # This HAS to be your main speaker + # - media_player.kitchen # Optional - passive speaker #1 + # - media_player.livingroom # Optional - passive speaker #2 ``` ### Home Assistant automations: + First automation is identical with the one I've already used in my ControllerX Sonos group setup example [link](https://xaviml.github.io/controllerx/examples/sonos) Second automation is purely optional, and not really directly related to the display. It's just a quick shortcut to easily reset active speakers within group, volume and source playing to some defaults you've defined in the automation. Really nice when you have teenagers in the house, messing with active speaker entities in the group, playlists and volume all the time... 😉 @@ -168,7 +175,7 @@ The automation is written for an Ikea E1810 remote with z2m ControllerX HA integ alias: Dynamic Sonos groups trigger: platform: state - entity_id: sensor.sonos_master_group_entities # Same as defined in configuration.yaml + entity_id: sensor.sonos_main_group_entities # Same as defined in configuration.yaml platform: homeassistant event: start action: @@ -186,10 +193,10 @@ The automation is written for an Ikea E1810 remote with z2m ControllerX HA integ action: - service: sonos.join data: - master: media_player.office # This HAS to be your MASTER speaker + main: media_player.office # This HAS to be your main speaker entity_id: - - media_player.kitchen # SLAVE speaker #1 - - media_player.livingroom # SLAVE speaker #2 + - media_player.kitchen # passive speaker #1 + - media_player.livingroom # passive speaker #2 - service: media_player.volume_set # Reset volume to 25 for all speakers data_template: entity_id: @@ -199,44 +206,45 @@ The automation is written for an Ikea E1810 remote with z2m ControllerX HA integ volume_level: 0.25 - service: media_player.select_source # Reset to your default choice of source data: - entity_id: media_player.office # This HAS to be your MASTER speaker + entity_id: media_player.office # This HAS to be your main speaker source: 'DR P4 Fyn 96.8 (Nyheder)' - service: media_player.media_play # Start playing - entity_id: media_player.office # This HAS to be your MASTER speaker + entity_id: media_player.office # This HAS to be your main speaker ``` - ### ESPHome YAML configuration: + As ESPHome currently don't support attributes, all data to be displayed has to be in separate HA sensors. Hence the huge amount of sensors. If you're not using a movement sensor in your build, you could (but actually don't need to) revise YAML. If you experience issues with the 'floating' GPIO used for the PIR sensor, just pull pin D5 permanently low or high. -Two entities needs to be entered. Your Sonos master speaker and optional temperature sensor. If temperature sensor is omitted, you can just revise YAML and delete page 3 & 4 from the display lambda configuration. Also remember to revise `interval` page count from 4 to 2. +Two entities needs to be entered. Your Sonos main speaker and optional temperature sensor. If temperature sensor is omitted, you can just revise YAML and delete page 3 & 4 from the display lambda configuration. Also remember to revise `interval` page count from 4 to 2. -Revise `sonos_status` and `outdoor_temp` sensors in YAML below, to match your HA entities for Sonos master speaker and outdoor temperature sensor. Create a new ESPHome node and configure it with your ESP8266 board settings and WiFi credentials. Edit node and copy/paste revised YAML below to your node. Remember to insert your node's autogenerated WiFi ap settings to YAML. Save it, upload and enjoy! 🎉😎 +Revise `sonos_status` and `outdoor_temp` sensors in YAML below, to match your HA entities for Sonos main speaker and outdoor temperature sensor. Create a new ESPHome node and configure it with your ESP8266 board settings and WiFi credentials. Edit node and copy/paste revised YAML below to your node. Remember to insert your node's autogenerated WiFi ap settings to YAML. Save it, upload and enjoy! 🎉😎 **One final note on current YAML configuration** -ESPHome is at **max** with all these included sensors, schedulers running and the quite extensive display lambda. Addding just one extra sensor to current YAML, will make ESPHome crash on boot. Omitting `fast_connect: true`from WiFi configuration in YAML will also send ESPHome into an eternal stack trace error boot loop 🚀💀 +ESPHome is at **max** with all these included sensors, schedulers running and the quite extensive display lambda. Addding just one extra sensor to current YAML, will make ESPHome crash on boot. Omitting `fast_connect: true`from WiFi configuration in YAML will also send ESPHome into an eternal stack trace error boot loop 🚀💀 + +So 'tweak' YAML with care! 😁😉 -So 'tweak' YAML with care! 😁😉 ```yaml substitutions: devicename: sonos_display friendly_name: Sonos Display device_description: Sonos SSD1306/1309 display for Sonos groups - + esphome: name: $devicename comment: ${device_description} platform: ESP8266 board: d1_mini - + wifi: ssid: "your_ssid" password: "your_password" fast_connect: true # Mandatory for fast WiFi connect to avoid stack trace error on boot manual_ip: - static_ip: 192.168.XX.XX # Enter your static IP address. Needed for fast WiFi connect to avoid stack trace error on boot + static_ip: 192.168.XX.XX # Enter your static IP address. Needed for fast WiFi connect to avoid stack trace error on boot gateway: 192.168.XX.XX # Enter your gateway subnet: 255.255.255.0 # Enter your subnet @@ -249,7 +257,7 @@ captive_portal: # Enable logging logger: - + # Enable Home Assistant API api: @@ -282,58 +290,58 @@ switch: id(sonos).turn_off(); sensor: - # Outdoor temperature sensor - only used in display lambda page 4 + # Outdoor temperature sensor - only used in display lambda page 4 - platform: homeassistant id: outdoor_temp entity_id: sensor.your_temperature_sensor internal: true - + - platform: homeassistant id: sonos_volume entity_id: sensor.volume internal: true - - # Create WiFi signal sensor in HA + + # Create WiFi signal sensor in HA - platform: wifi_signal name: "${friendly_name} WiFi Signal" update_interval: 60s text_sensor: - # Sonos master speaker - - platform: homeassistant + # Sonos main speaker + - platform: homeassistant id: sonos_status - entity_id: media_player.your_master_speaker + entity_id: media_player.your_main_speaker internal: true - - - platform: homeassistant + + - platform: homeassistant id: media_source entity_id: sensor.media_source internal: true - + - platform: homeassistant id: media_artist entity_id: sensor.media_artist internal: true - + - platform: homeassistant id: media_title entity_id: sensor.media_title internal: true - + # ** Not yet used - Currently ESPHome can't handle more sensors than already installed *** #- platform: homeassistant # id: media_album_title # entity_id: sensor.media_album_title // Not in use yet # internal: true - + - platform: homeassistant - id: sonos_master - entity_id: sensor.sonos_master_friendly + id: sonos_main + entity_id: sensor.sonos_main_friendly internal: true - + - platform: homeassistant - id: sonos_slaves - entity_id: sensor.sonos_slaves_friendly + id: sonos_passives + entity_id: sensor.sonos_passives_friendly internal: true binary_sensor: @@ -341,62 +349,62 @@ binary_sensor: id: mute entity_id: binary_sensor.is_volume_muted internal: true - + - platform: homeassistant id: shuffle entity_id: binary_sensor.shuffle - internal: true - + internal: true + - platform: gpio pin: D5 name: "${friendly_name} PIR" device_class: motion on_press: then: - - binary_sensor.template.publish: + - binary_sensor.template.publish: id: dim_display state: ON - - binary_sensor.template.publish: + - binary_sensor.template.publish: id: display_off state: ON on_release: then: - - binary_sensor.template.publish: + - binary_sensor.template.publish: id: dim_display state: OFF - - binary_sensor.template.publish: + - binary_sensor.template.publish: id: display_off state: OFF - - # Create HA connected sensor + + # Create HA connected sensor - platform: status - name: "${friendly_name} Status" - + name: "${friendly_name} Status" + - platform: template id: dim_display filters: - delayed_off: 5min # Dim display after 5 minutes on_press: # brightness is float (from 0 to 1). 1 = 100% then: - - lambda: |- - if (id(sonos_display).state == true) { - id(sonos).turn_on(); - id(sonos).set_brightness(1); - } + - lambda: |- + if (id(sonos_display).state == true) { + id(sonos).turn_on(); + id(sonos).set_brightness(1); + } on_release: # brightness is float (from 0 to 1). 0.01 = 1% then: - - lambda: |- - id(sonos).set_brightness(0.01); - + - lambda: |- + id(sonos).set_brightness(0.01); + - platform: template id: display_off filters: - delayed_off: 15min # Turn off display after 15 minutes on_release: then: - - lambda: |- - id(sonos).turn_off(); - + - lambda: |- + id(sonos).turn_off(); + font: - file: "Calibri Bold.ttf" id: font_large @@ -417,25 +425,25 @@ font: - file: "HeydingsIconsSymbols.ttf" id: font_icons_large size: 23 - glyphs: '0679HADJabjsmx' + glyphs: "0679HADJabjsmx" - file: "HeydingsIconsSymbols.ttf" id: font_icons_medium size: 19 - glyphs: '0679HADJabjsmx' + glyphs: "0679HADJabjsmx" - file: "HeydingsIconsSymbols.ttf" id: font_icons_14 size: 14 - glyphs: '0679HADJabjsmx' + glyphs: "0679HADJabjsmx" - file: "HeydingsIconsSymbols.ttf" id: font_icons_small size: 11 - glyphs: '0679HADJabjsmx' - + glyphs: "0679HADJabjsmx" + globals: - id: display_page type: int restore_value: no - initial_value: '0' # On first boot, value=0 initiates display.turn_on() command. Can't run as on_boot command + initial_value: "0" # On first boot, value=0 initiates display.turn_on() command. Can't run as on_boot command interval: - interval: 5s # Show each page for 5 seconds @@ -449,12 +457,12 @@ interval: } else { id(display_page) = 1; } - + i2c: sda: D1 - scl: D2 + scl: D2 frequency: 100khz # Default 50kHz. Min. setting at 100kHz needed. Otherwise lambda is so slow that warnings appear in log - + display: - platform: ssd1306_i2c model: "SSD1306 128x64" @@ -464,56 +472,57 @@ display: update_interval: 1s id: sonos pages: - lambda: |- - if (id(media_source).state != "no source") { - it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "%.12s", id(media_source).state.c_str()); + lambda: |- + if (id(media_source).state != "no source") { + it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "%.12s", id(media_source).state.c_str()); + } else { + if (id(display_page) == 1 or (id(display_page) == 3)) { + it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "Sonos"); // if no source list attribute, display Sonos Playlist instead } else { - if (id(display_page) == 1 or (id(display_page) == 3)) { - it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "Sonos"); // if no source list attribute, display Sonos Playlist instead - } else { - it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "Playlist"); // if no source list attribute, display Sonos Playlist instead - } - if (id(shuffle).state) { - it.printf(127, 17, id(font_icons_large), TextAlign::BOTTOM_RIGHT, "x"); // shuffle playlist sign at top right position - } + it.printf(64, 0, id(font_large), TextAlign::TOP_CENTER, "Playlist"); // if no source list attribute, display Sonos Playlist instead } - if (id(mute).state) { - it.printf(0, 20, id(font_icons_medium), TextAlign::TOP_LEFT, "0"); // speaker mute sign - } else { - it.printf(0, 20, id(font_icons_medium), TextAlign::TOP_LEFT, "m"); // speaker on sign - } - it.printf(21, 22, id(font_medium), TextAlign::TOP_LEFT, "%.f", id(sonos_volume).state); - if (id(sonos_status).state == "playing") { - it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "6"); // pause sign - } else if (id(sonos_status).state == "paused") { - it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "7"); // play sign - } else { - it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "9"); // stop sign - } - it.printf(107, 22, id(font_medium), TextAlign::TOP_RIGHT, "%s", id(sonos_status).state.c_str()); - - if (id(display_page) == 1) { - it.printf(00, 53, id(font_icons_small), TextAlign::BOTTOM_LEFT, "s"); // star sign for master speaker - it.printf(64, 53, id(font_small_bold), TextAlign::BOTTOM_CENTER, "%s", id(sonos_master).state.c_str()); - it.printf(00, 65, id(font_icons_small), TextAlign::BOTTOM_LEFT, "a"); // chain sign for slave speaker(s) - it.printf(64, 65, id(font_small), TextAlign::BOTTOM_CENTER, "%s", id(sonos_slaves).state.c_str()); - } else if (id(display_page) == 2) { - if (id(media_title).state != "None") { - it.printf(73, 53, id(font_small), TextAlign::BOTTOM_CENTER, "%.24s", id(media_title).state.c_str()); - it.printf(73, 65, id(font_small), TextAlign::BOTTOM_CENTER, "%.24s", id(media_artist).state.c_str()); - it.printf(00, 51, id(font_icons_14), TextAlign::BOTTOM_LEFT, "j"); // note sign (title) - it.printf(00, 65, id(font_icons_small), TextAlign::BOTTOM_LEFT, "A"); // person sign (artist) - } else { - it.strftime(64, 42, id(font_large), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now()); - } - } else if (id(display_page) == 3) { - it.strftime(64, 42, id(font_large), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now()); - } else { - it.printf(64, 42, id(font_large), TextAlign::TOP_CENTER, "Out: %.1f°C", id(outdoor_temp).state); + if (id(shuffle).state) { + it.printf(127, 17, id(font_icons_large), TextAlign::BOTTOM_RIGHT, "x"); // shuffle playlist sign at top right position } + } + if (id(mute).state) { + it.printf(0, 20, id(font_icons_medium), TextAlign::TOP_LEFT, "0"); // speaker mute sign + } else { + it.printf(0, 20, id(font_icons_medium), TextAlign::TOP_LEFT, "m"); // speaker on sign + } + it.printf(21, 22, id(font_medium), TextAlign::TOP_LEFT, "%.f", id(sonos_volume).state); + if (id(sonos_status).state == "playing") { + it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "6"); // pause sign + } else if (id(sonos_status).state == "paused") { + it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "7"); // play sign + } else { + it.printf(127, 18, id(font_icons_medium), TextAlign::TOP_RIGHT, "9"); // stop sign + } + it.printf(107, 22, id(font_medium), TextAlign::TOP_RIGHT, "%s", id(sonos_status).state.c_str()); + + if (id(display_page) == 1) { + it.printf(00, 53, id(font_icons_small), TextAlign::BOTTOM_LEFT, "s"); // star sign for main speaker + it.printf(64, 53, id(font_small_bold), TextAlign::BOTTOM_CENTER, "%s", id(sonos_main).state.c_str()); + it.printf(00, 65, id(font_icons_small), TextAlign::BOTTOM_LEFT, "a"); // chain sign for passive speaker(s) + it.printf(64, 65, id(font_small), TextAlign::BOTTOM_CENTER, "%s", id(sonos_passives).state.c_str()); + } else if (id(display_page) == 2) { + if (id(media_title).state != "None") { + it.printf(73, 53, id(font_small), TextAlign::BOTTOM_CENTER, "%.24s", id(media_title).state.c_str()); + it.printf(73, 65, id(font_small), TextAlign::BOTTOM_CENTER, "%.24s", id(media_artist).state.c_str()); + it.printf(00, 51, id(font_icons_14), TextAlign::BOTTOM_LEFT, "j"); // note sign (title) + it.printf(00, 65, id(font_icons_small), TextAlign::BOTTOM_LEFT, "A"); // person sign (artist) + } else { + it.strftime(64, 42, id(font_large), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now()); + } + } else if (id(display_page) == 3) { + it.strftime(64, 42, id(font_large), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now()); + } else { + it.printf(64, 42, id(font_large), TextAlign::TOP_CENTER, "Out: %.1f°C", id(outdoor_temp).state); + } ``` ## Change log + - Intitial version published July, 2020 - v1.1 published September, 2020 - Added optional sensor for brightness control & display on/off @@ -521,19 +530,20 @@ display: - When idle, display 'stop' sign and 'idle' text - Revised display lambda page code - Four ESPHome sensors exposed in HA - - Connected status - - WiFi strength - - Display on/off - - PIR + - Connected status + - WiFi strength + - Display on/off + - PIR Future plans: + - Design 2,9" E-paper display version - Improve/simplify HA sensor templates (if possible) -- Remove need for master entity everywhere in config files +- Remove need for main entity everywhere in config files Thank you Xavi for providing the perfect solution for some of my templating issues 👍😎 September, 2020 _[@htvekov](https://github.com/htvekov)_ -{% endraw %} \ No newline at end of file +{% endraw %} diff --git a/docs/examples/sonos.md b/docs/examples/sonos.md index a4b8bbaa..2de0caef 100644 --- a/docs/examples/sonos.md +++ b/docs/examples/sonos.md @@ -26,7 +26,7 @@ office_sonos_controller: ### SONOS/SYMFONISK groups -ControllerX supports Sonos groups as well. If media_player in app is set to a group, then ControllerX will read the Sonos source list from FIRST entity_id in group. So this has to be your chosen master speaker! This setup will work perfectly, if you only use static groups that are never altered (via Sonos app/HA or otherwise). But if your Sonos group alters through the day (other family members redefines group speakers to their liking), you need a dynamic group setting. +ControllerX supports Sonos groups as well. If media_player in app is set to a group, then ControllerX will read the Sonos source list from FIRST entity_id in group. So this has to be your chosen main speaker! This setup will work perfectly, if you only use static groups that are never altered (via Sonos app/HA or otherwise). But if your Sonos group alters through the day (other family members redefines group speakers to their liking), you need a dynamic group setting. This can easily be achieved by adding only one sensor and one small automation to your HA configuration. @@ -37,8 +37,8 @@ This can easily be achieved by adding only one sensor and one small automation t ```yaml - platform: template sensors: - sonos_master_group_entities: - value_template: "{{ special }}" #MASTER speaker + sonos_main_group_entities: + value_template: "{{ special }}" #main speaker ``` #### HA automation.yaml @@ -50,12 +50,12 @@ id: dynamic_sonos_groups alias: dynamic_sonos_groups trigger: platform: state - entity_id: sensor.sonos_master_group_entities # Same as defined in configuration.yaml + entity_id: sensor.sonos_main_group_entities # Same as defined in configuration.yaml action: - service: group.set data_template: object_id: sonos_all #name of sonos group in groups.yaml - entities: "{{ special }}" #MASTER speaker + entities: "{{ special }}" #main speaker ``` #### HA groups.yaml @@ -63,9 +63,9 @@ action: ```yaml name: sonos_all entities: - - media_player.office #this HAS to be your MASTER speaker - - media_player.kitchen #SLAVE speaker #1 - - media_player.livingroom #SLAVE speaker #2 + - media_player.office #this HAS to be your main speaker + - media_player.kitchen #passive speaker #1 + - media_player.livingroom #passive speaker #2 ``` And with the following ControllerX configuration, you will be able to control the dynamic group in HA, which will be changed immediately if group is altered eg. from Sonos app. This app version below, has 'flipped' the arrow functions. So click will change source and hold will change previous/next song in playlist. This behaviour will most likely fit better for users that primarily uses favourites (radio stations). diff --git a/docs/faq.md b/docs/faq.md index ca1bcbf7..030877e6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -17,7 +17,7 @@ From the zigbee2mqtt documentation is recommended to set the `debounce` attribut #### 4. I have a group of lights and it does not work properly -HA offers different ways to group lights, even each Light integration might have the option of grouping lights (like [Hue](https://www.home-assistant.io/integrations/hue/) integration). This is why ControllerX sticks to just one official way to group lights, which is the [Group](https://www.home-assistant.io/integrations/group/) integration. This means you will need to set up a group with your lights in your `configuration.yaml`. ControllerX will know is a group of lights because it will use the `group.XXXXX` domain. Furthermore, it will take the first light as a master light, so it will take its values (brightness, color) to change the group of lights. +HA offers different ways to group lights, even each Light integration might have the option of grouping lights (like [Hue](https://www.home-assistant.io/integrations/hue/) integration). This is why ControllerX sticks to just one official way to group lights, which is the [Group](https://www.home-assistant.io/integrations/group/) integration. This means you will need to set up a group with your lights in your `configuration.yaml`. ControllerX will know is a group of lights because it will use the `group.XXXXX` domain. Furthermore, it will take the first light as a main light, so it will take its values (brightness, color) to change the group of lights. This does not mean that any other integration will not work, but they might not work as expected, this is why [Group](https://www.home-assistant.io/integrations/group/) integration should be used if you want the expected ControllerX behaviour. diff --git a/docs/others/integrations.md b/docs/others/integrations.md index f9ab2f17..c818a4a5 100644 --- a/docs/others/integrations.md +++ b/docs/others/integrations.md @@ -41,7 +41,7 @@ Three things to clarify when using the `z2m` integration listening to MQTT: #### deCONZ -This integration(**`deconz`**) listens to events and actions gets fired by default with the `event` attribute from the `data` object. However, you can change the attribute to listen to by adding a `type` attribute. This is an example +This integration(**`deconz`**) listens to `deconz_event` events and actions gets fired by default with the `event` attribute from the `data` object. However, you can change the attribute to listen to by adding a `type` attribute. This is an example ```yaml example_app: @@ -56,7 +56,7 @@ example_app: #### ZHA -This integration(**`zha`**) listens to events and concatenates the command with the argument for the action string. It does not have any additional arguments. +This integration(**`zha`**) listens to `zha_event` events and concatenates the command with the argument for the action string. It does not have any additional arguments. #### MQTT @@ -128,4 +128,8 @@ example_app: mapping: 1_click: "on" 2_click: "off" -``` \ No newline at end of file +``` + +#### Lutron Caséta + +This integration(**`lutron_caseta`**) listens to `lutron_caseta_button_event` events. It creates an action like `button__`. It does not have any additional arguments. diff --git a/setup.cfg b/setup.cfg index e45a824a..0e846d87 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,13 +18,14 @@ no_implicit_optional = True mock_use_standalone_module = true timeout = 5 -[report] +[coverage:report] exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain if tests don't hit defensive assertion code: raise NotImplementedError + self.log [mypy-appdaemon.*] ignore_missing_imports = true diff --git a/tests/unit_tests/cx_core/custom_controller_test.py b/tests/unit_tests/cx_core/custom_controller_test.py index e3c8931b..5fcd2f85 100644 --- a/tests/unit_tests/cx_core/custom_controller_test.py +++ b/tests/unit_tests/cx_core/custom_controller_test.py @@ -120,7 +120,7 @@ async def test_custom_controllers( [("homeassistant/test_service2", {})], ), ( - "z2m", + "lutron_caseta", {"service": "homeassistant/test_service2"}, [("homeassistant/test_service2", {})], ), diff --git a/tests/unit_tests/cx_core/integration/deconz_test.py b/tests/unit_tests/cx_core/integration/deconz_test.py new file mode 100644 index 00000000..6c26c0a0 --- /dev/null +++ b/tests/unit_tests/cx_core/integration/deconz_test.py @@ -0,0 +1,38 @@ +from typing import Dict, Optional + +import pytest +from cx_core.controller import Controller +from cx_core.integration.deconz import DeCONZIntegration +from pytest_mock.plugin import MockerFixture + + +@pytest.mark.parametrize( + "data, type, expected", + [ + ( + {"id": 123, "event": 1002}, + None, + 1002, + ), + ( + {"id": 123, "gesture": 2}, + "gesture", + 2, + ), + ], +) +@pytest.mark.asyncio +async def test_callback( + fake_controller: Controller, + mocker: MockerFixture, + data: Dict, + type: Optional[str], + expected: str, +): + handle_action_patch = mocker.patch.object(fake_controller, "handle_action") + kwargs = {} + if type is not None: + kwargs["type"] = type + deconz_integration = DeCONZIntegration(fake_controller, kwargs) + await deconz_integration.event_callback("test", data, {}) + handle_action_patch.assert_called_once_with(expected, extra=data) diff --git a/tests/unit_tests/cx_core/integration/integration_test.py b/tests/unit_tests/cx_core/integration/integration_test.py index 8f3f01c7..98ee2fa0 100644 --- a/tests/unit_tests/cx_core/integration/integration_test.py +++ b/tests/unit_tests/cx_core/integration/integration_test.py @@ -5,4 +5,11 @@ def test_get_integrations(fake_controller: Controller): integrations = integration_module.get_integrations(fake_controller, {}) inteagration_names = {i.name for i in integrations} - assert inteagration_names == {"z2m", "zha", "deconz", "state", "mqtt"} + assert inteagration_names == { + "z2m", + "zha", + "deconz", + "state", + "mqtt", + "lutron_caseta", + } diff --git a/tests/unit_tests/cx_core/integration/lutron_test.py b/tests/unit_tests/cx_core/integration/lutron_test.py new file mode 100644 index 00000000..fd284151 --- /dev/null +++ b/tests/unit_tests/cx_core/integration/lutron_test.py @@ -0,0 +1,46 @@ +from typing import Dict + +import pytest +from cx_core.controller import Controller +from cx_core.integration.lutron_caseta import LutronIntegration +from pytest_mock.plugin import MockerFixture + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + { + "serial": 28786608, + "type": "FourGroupRemote", + "button_number": 4, + "device_name": "Shade Remote", + "area_name": "Upstairs Hall", + "action": "press", + }, + "button_4_press", + ), + ( + { + "serial": 28786608, + "type": "FourGroupRemote", + "button_number": 0, + "device_name": "Shade Remote", + "area_name": "Upstairs Hall", + "action": "hold", + }, + "button_0_hold", + ), + ], +) +@pytest.mark.asyncio +async def test_callback( + fake_controller: Controller, + mocker: MockerFixture, + data: Dict, + expected: str, +): + handle_action_patch = mocker.patch.object(fake_controller, "handle_action") + lutron_integration = LutronIntegration(fake_controller, {}) + await lutron_integration.callback("test", data, {}) + handle_action_patch.assert_called_once_with(expected, extra=data) diff --git a/tests/unit_tests/cx_core/integration/mqtt_test.py b/tests/unit_tests/cx_core/integration/mqtt_test.py new file mode 100644 index 00000000..e2dd3c18 --- /dev/null +++ b/tests/unit_tests/cx_core/integration/mqtt_test.py @@ -0,0 +1,36 @@ +from typing import Dict + +import pytest +from cx_core.controller import Controller +from cx_core.integration.mqtt import MQTTIntegration +from pytest_mock.plugin import MockerFixture + + +@pytest.mark.parametrize( + "data, expected", + [ + ( + {"payload": "click"}, + "click", + ), + ( + {}, + None, + ), + ], +) +@pytest.mark.asyncio +async def test_callback( + fake_controller: Controller, + mocker: MockerFixture, + data: Dict, + expected: str, +): + handle_action_patch = mocker.patch.object(fake_controller, "handle_action") + mqtt_integration = MQTTIntegration(fake_controller, {}) + await mqtt_integration.event_callback("test", data, {}) + + if expected is not None: + handle_action_patch.assert_called_once_with(expected) + else: + handle_action_patch.assert_not_called() diff --git a/tests/unit_tests/cx_core/integration/zha_test.py b/tests/unit_tests/cx_core/integration/zha_test.py index 9ea588cb..ac86a817 100644 --- a/tests/unit_tests/cx_core/integration/zha_test.py +++ b/tests/unit_tests/cx_core/integration/zha_test.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Optional import pytest from cx_core.controller import Controller @@ -38,12 +38,12 @@ ], ) @pytest.mark.asyncio -async def test_get_integrations( +async def test_callback( fake_controller: Controller, mocker: MockerFixture, command: str, args: Dict, - expected_called_with: str, + expected_called_with: Optional[str], ): data = {"command": command, "args": args} handle_action_patch = mocker.patch.object(fake_controller, "handle_action") diff --git a/tests/unit_tests/cx_devices/devices_test.py b/tests/unit_tests/cx_devices/devices_test.py index 5abdca4f..87b6e8b1 100644 --- a/tests/unit_tests/cx_devices/devices_test.py +++ b/tests/unit_tests/cx_devices/devices_test.py @@ -60,6 +60,7 @@ def test_devices(device_class: Type[Controller]): device.get_z2m_actions_mapping, device.get_deconz_actions_mapping, device.get_zha_actions_mapping, + device.get_lutron_caseta_actions_mapping, ] for func in integration_mappings_funcs: mappings = func() diff --git a/tests/unit_tests/cx_devices/muller_licht_test.py b/tests/unit_tests/cx_devices/muller_licht_test.py new file mode 100644 index 00000000..5de42583 --- /dev/null +++ b/tests/unit_tests/cx_devices/muller_licht_test.py @@ -0,0 +1,46 @@ +import pytest +from cx_core.integration import EventData +from cx_devices.muller_licht import MLI404002LightController + + +@pytest.mark.parametrize( + "data, expected_action", + [ + ( + {"command": "on", "args": []}, + "on", + ), + ( + {"command": "off", "args": []}, + "off", + ), + ( + {"command": "step", "args": [0, 43, 3]}, + "step_up", + ), + ( + {"command": "move", "args": [0, 100]}, + "move_up", + ), + ( + {"command": "step", "args": [1, 43, 3]}, + "step_down", + ), + ( + {"command": "move", "args": [1, 100]}, + "move_down", + ), + ( + {"command": "stop", "args": []}, + "stop", + ), + ( + {"command": "recall", "args": [16387, 1]}, + "recall", + ), + ], +) +def test_zha_action_MLI404002(data: EventData, expected_action: str): + sut = MLI404002LightController() # type: ignore + action = sut.get_zha_action(data) + assert action == expected_action