├── .codespellignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── release-drafter.yml ├── stale.yml └── workflows │ ├── codespell.yml │ ├── dockerx-latest.yml │ ├── python_publish.yaml │ ├── pythonpackage.yml │ └── release_drafter.yml ├── .gitignore ├── Dockerfile ├── HASS_INTEGRATION.md ├── LICENSE ├── Makefile ├── README.md ├── example.py ├── example_image.py ├── example_jsonl.py ├── openhasp_config_manager ├── __init__.py ├── cli.py ├── cli │ ├── __init__.py │ ├── cmd.py │ ├── common.py │ ├── deploy.py │ ├── generate.py │ ├── gui.py │ ├── listen.py │ ├── logs.py │ ├── screenshot.py │ ├── shell.py │ ├── state.py │ ├── upload.py │ └── vars.py ├── const.py ├── manager.py ├── openhasp_client │ ├── __init__.py │ ├── image_processor.py │ ├── model │ │ ├── __init__.py │ │ ├── component.py │ │ ├── configuration │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── debug_config.py │ │ │ ├── device_config.py │ │ │ ├── gui_config.py │ │ │ ├── hasp_config.py │ │ │ ├── http_config.py │ │ │ ├── mqtt_config.py │ │ │ ├── screen_config.py │ │ │ ├── telnet_config.py │ │ │ ├── website_config.py │ │ │ └── wifi_config.py │ │ ├── device.py │ │ └── openhasp_config_manager_config.py │ ├── mqtt_client.py │ ├── openhasp.py │ ├── telnet_client.py │ └── webservice_client.py ├── processing │ ├── __init__.py │ ├── device_processor.py │ ├── jsonl │ │ ├── __init__.py │ │ └── jsonl.py │ ├── preprocessor │ │ ├── __init__.py │ │ └── jsonl_preprocessor.py │ ├── template_rendering.py │ └── variables.py ├── ui │ ├── __init__.py │ ├── qt │ │ ├── __init__.py │ │ ├── device_list.py │ │ ├── file_browser.py │ │ ├── main.py │ │ ├── page_layout_editor.py │ │ └── util.py │ └── util.py ├── uploader.py ├── util.py └── validation │ ├── __init__.py │ ├── cmd.py │ ├── device_validator.py │ └── jsonl.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── _test_cfg_root ├── common │ ├── dialog │ │ ├── connected.jsonl │ │ └── offline.jsonl │ ├── offline.cmd │ └── online.cmd ├── devices │ └── test_device │ │ ├── config.json │ │ ├── home.cmd │ │ ├── home │ │ ├── image_50x50.png │ │ ├── page.jsonl │ │ └── test_page.jsonl │ │ ├── vars.yaml │ │ └── vars2.yaml └── global.vars.yaml ├── manager_analyze_test.py ├── manager_process_test.py ├── openhasp_client ├── __init__.py └── mqtt_client_test.py ├── processing ├── __init__.py ├── device_processor_test.py ├── preprocessor │ └── jsonl_preprocessor_test.py ├── template_rendering_test.py └── variable_manager_test.py ├── pytest.ini ├── util_test.py └── validation ├── cmd_validator_test.py ├── device_validator_test.py └── jsonl_object_validator_test.py /.codespellignore: -------------------------------------------------------------------------------- 1 | hass 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ markusressel ] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: TheAlgorithms 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: ['https://paypal.me/markusressel/1'] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Desktop (please complete the following information):** 25 | 26 | - OS: [e.g. Linux] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | registries: 3 | python-index-pypi-python-org-simple: 4 | type: python-index 5 | url: https://pypi.python.org/simple/ 6 | username: "${{secrets.PYTHON_INDEX_PYPI_PYTHON_ORG_SIMPLE_USERNAME}}" 7 | password: "${{secrets.PYTHON_INDEX_PYPI_PYTHON_ORG_SIMPLE_PASSWORD}}" 8 | 9 | updates: 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | # Check for updates to GitHub Actions every week 14 | interval: "weekly" 15 | - package-ecosystem: pip 16 | insecure-external-code-execution: allow 17 | directory: "/" 18 | schedule: 19 | interval: daily 20 | time: "16:00" 21 | timezone: Europe/Berlin 22 | open-pull-requests-limit: 10 23 | ignore: 24 | - dependency-name: prometheus-client 25 | versions: 26 | - 0.10.0 27 | registries: 28 | - python-index-pypi-python-org-simple 29 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: 🚀 Features and ✨ Enhancements 3 | label: enhancement 4 | - title: 🐛 Bugfixes 5 | label: bug 6 | change-template: "* $TITLE (#$NUMBER) by @$AUTHOR" 7 | template: | 8 | ## What’s Changed 9 | 10 | $CHANGES 11 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 60 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 14 9 | 10 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 11 | exemptLabels: 12 | - bug 13 | - feature 14 | - enhancement 15 | - pinned 16 | - security 17 | 18 | # Set to true to ignore issues in a project (defaults to false) 19 | exemptProjects: false 20 | 21 | # Set to true to ignore issues in a milestone (defaults to false) 22 | exemptMilestones: false 23 | 24 | # Set to true to ignore issues with an assignee (defaults to false) 25 | exemptAssignees: true 26 | 27 | # Label to use when marking as stale 28 | staleLabel: wontfix 29 | 30 | # Comment to post when marking as stale. Set to `false` to disable 31 | markComment: > 32 | This issue has been automatically marked as stale because it has not had 33 | recent activity. It will be closed if no further activity occurs. Thank you 34 | for your contributions. 35 | 36 | # Comment to post when removing the stale label. 37 | # unmarkComment: > 38 | # Your comment here. 39 | 40 | # Comment to post when closing a stale Issue or Pull Request. 41 | closeComment: > 42 | There has been no incentive by contributors or maintainers to revive this stale issue and it will now be closed. 43 | 44 | # Limit the number of actions per hour, from 1-30. Default is 30 45 | limitPerRun: 30 46 | 47 | # Limit to only `issues` or `pulls` 48 | only: issues 49 | 50 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 51 | # pulls: 52 | # daysUntilStale: 30 53 | # markComment: > 54 | # This pull request has been automatically marked as stale because it has not had 55 | # recent activity. It will be closed if no further activity occurs. Thank you 56 | # for your contributions. 57 | 58 | # issues: 59 | # exemptLabels: 60 | # - confirmed 61 | -------------------------------------------------------------------------------- /.github/workflows/codespell.yml: -------------------------------------------------------------------------------- 1 | # Codespell configuration is within pyproject.toml 2 | --- 3 | name: Codespell 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | codespell: 16 | name: Check for spelling errors 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Annotate locations with typos 23 | uses: codespell-project/codespell-problem-matcher@v1 24 | - name: Codespell 25 | uses: codespell-project/actions-codespell@v2 26 | with: 27 | ignore_words_file: .codespellignore 28 | skip: "*.svg" -------------------------------------------------------------------------------- /.github/workflows/dockerx-latest.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "*.*.*" 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | buildx: 18 | runs-on: ubuntu-latest 19 | #strategy: 20 | # fail-fast: false 21 | # max-parallel: 2 22 | # matrix: 23 | # # Not all for time waste reasons 24 | # platform: [ "linux/arm64", "linux/amd64" ] 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | # this writes the tag name into GIT_TAG_NAME 30 | - name: Get tag name 31 | uses: little-core-labs/get-git-tag@v3.0.2 32 | 33 | # https://github.com/docker/setup-qemu-action 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | # https://github.com/docker/setup-buildx-action 37 | - name: Set up Docker Buildx 38 | id: buildx 39 | uses: docker/setup-buildx-action@v3 40 | #with: 41 | # install: true 42 | 43 | - name: Inspect builder 44 | run: | 45 | echo "Name: ${{ steps.buildx.outputs.name }}" 46 | echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" 47 | echo "Status: ${{ steps.buildx.outputs.status }}" 48 | echo "Flags: ${{ steps.buildx.outputs.flags }}" 49 | echo "Platforms: ${{ steps.buildx.outputs.platforms }}" 50 | 51 | - name: Figure out release tag 52 | id: prep 53 | run: | 54 | if [ $GITHUB_REF_TYPE == "tag" ]; then 55 | RELEASE_VERSION=${GITHUB_REF_NAME#*/} 56 | else 57 | RELEASE_VERSION='latest' 58 | fi 59 | echo ::set-output name=version::${RELEASE_VERSION} 60 | 61 | - name: Log into registry ${{ env.REGISTRY }} 62 | if: github.event_name != 'pull_request' 63 | uses: docker/login-action@v3 64 | with: 65 | registry: ${{ env.REGISTRY }} 66 | username: ${{ github.actor }} 67 | password: ${{ secrets.GITHUB_TOKEN }} 68 | 69 | - name: Extract Docker metadata 70 | id: meta 71 | uses: docker/metadata-action@v5 72 | with: 73 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 74 | tags: | 75 | type=raw,value=${{ steps.prep.outputs.version }},enable=${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 76 | type=raw,value=latest,enable={{is_default_branch}} 77 | 78 | - name: Build and push Docker image 79 | id: build-and-push 80 | uses: docker/build-push-action@v6 81 | with: 82 | context: . 83 | # linux/arm64 doesn't currently work because cffi doesn't want to build :( 84 | platforms: linux/amd64 85 | push: ${{ github.event_name != 'pull_request' }} 86 | tags: ${{ steps.meta.outputs.tags }} 87 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/python_publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | env: 9 | GITHUB_RELEASE: True 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel poetry 24 | - name: Build and publish 25 | run: | 26 | poetry build 27 | - name: Publish a Python distribution to PyPI 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_PASSWORD }} 32 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | max-parallel: 4 11 | matrix: 12 | # latest AppDaemon runs on Python 3.10 13 | python-version: [ '3.10', '3.11', '3.12' ] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install poetry 25 | poetry install 26 | - name: Lint with flake8 27 | run: | 28 | pip install flake8 29 | # stop the build if there are Python syntax errors or undefined names 30 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 31 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 32 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 33 | - name: Test with pytest 34 | run: | 35 | cd tests 36 | poetry run pytest 37 | -------------------------------------------------------------------------------- /.github/workflows/release_drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v6 15 | with: 16 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 17 | config-name: release-drafter.yml 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | 4 | output 5 | openhasp-configs 6 | 7 | tests/test_output 8 | *.pclprof -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # dont use alpine for python builds: https://pythonspeed.com/articles/alpine-docker-python/ 2 | FROM python:3.12-slim-bookworm 3 | 4 | ENV PYTHONUNBUFFERED=1 5 | ENV POETRY_VERSION="2.1.2" 6 | ENV PIP_DISABLE_PIP_VERSION_CHECK=on 7 | ENV VENV_HOME=/opt/poetry 8 | WORKDIR /app 9 | 10 | COPY README.md README.md 11 | COPY openhasp_config_manager openhasp_config_manager 12 | COPY poetry.lock pyproject.toml ./ 13 | 14 | RUN apt-get update \ 15 | && apt-get -y install python3-pip \ 16 | && apt-get clean && rm -rf /var/lib/apt/lists/* \ 17 | && python3 -m venv ${VENV_HOME} \ 18 | && ${VENV_HOME}/bin/pip install --upgrade pip \ 19 | && ${VENV_HOME}/bin/pip install "poetry==${POETRY_VERSION}" \ 20 | && ${VENV_HOME}/bin/poetry check \ 21 | && POETRY_VIRTUALENVS_CREATE=false ${VENV_HOME}/bin/poetry install --no-interaction --no-cache --without dev \ 22 | && ${VENV_HOME}/bin/pip uninstall -y poetry 23 | 24 | # Add Poetry to PATH 25 | ENV PATH="${VENV_HOME}/bin:${PATH}" 26 | 27 | RUN ${VENV_HOME}/bin/pip install . 28 | 29 | ENTRYPOINT [ "openhasp-config-manager" ] 30 | 31 | -------------------------------------------------------------------------------- /HASS_INTEGRATION.md: -------------------------------------------------------------------------------- 1 | # Integrating openhasp-config-manager into Home Assistant 2 | 3 | The "standard" way of configuring actions based on events from your plates 4 | is to use the [OpenHASP Home Assistant integration](https://github.com/HASwitchPlate/openHASP-custom-component). 5 | This integration uses the Home Assistant configuration YAML files, where you can specify 6 | devices and actions based on events received from a device. 7 | 8 | While this works pretty well, it has some limitations: 9 | 10 | 1. Home Assistant needs to be restarted after a configuration change 11 | 2. The integration only supports service calls as actions 12 | 3. Integrating this with [AppDaemon](https://github.com/AppDaemon/appdaemon) is cumbersome because of 13 | the required boilerplate 14 | 15 | To work around these, we can skip the OpenHasp custom integration for Home Assistant 16 | entirely and simply "convert" any MQTT event we receive from any of the OpenHASP plates into an Home Assistant "event". 17 | This event can then be used in automations as well as appdaemon. Updates to the configuration of the plate are 18 | immediately propagated, so there is no need to restart Home Assistant to make changes in automations or appdaemon work. 19 | 20 | ```yaml 21 | alias: OpenHASP MQTT to Event Bridge 22 | description: "Sends an event for each message received on the hasp/# topic, which can then be consumed by other automations or appdaemon." 23 | trigger: 24 | - platform: mqtt 25 | topic: hasp/# 26 | action: 27 | - event: custom.openhasp.mqtt_event 28 | event_data: 29 | topic: "{{ trigger.topic }}" 30 | payload: "{{ trigger.payload }}" 31 | mode: queued 32 | max: 10000 33 | ``` 34 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | test: 3 | cd tests && poetry run pytest 4 | 5 | docker-latest: 6 | docker build . --file Dockerfile --tag ghcr.io/markusressel/openhasp-config-manager:latest 7 | 8 | install-release: 9 | rm -rf /tmp/venv-install 10 | git clone https://github.com/markusressel/venv-install /tmp/venv-install 11 | cd /tmp/venv-install && ./install.sh 12 | venv-install openhasp-config-manager openhasp-config-manager 13 | openhasp-config-manager -h 14 | 15 | uninstall-release: 16 | venv-uninstall openhasp-config-manager openhasp-config-manager 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openhasp-config-manager 2 | 3 | A cli tool to manage all of your [openHASP](https://github.com/HASwitchPlate/openHASP) device configs in a centralized 4 | place. 5 | 6 | # Features 7 | 8 | * [x] unlimited multi-device management 9 | * [x] shared configuration between devices 10 | * [x] jsonl preprocessing, which allows for 11 | * [x] `//` comments within jsonl files 12 | * [x] line breaks wherever you like 13 | * [x] jinja2 templating within object values 14 | * [x] local and globally scoped variables 15 | * [x] default theming for all object types 16 | * [x] validation of common mistakes for 17 | * [x] jsonl objects 18 | * [x] cmd files 19 | * [x] simple configuration upload to the device(s) 20 | * [x] automatic diffing to only update changed configuration files 21 | * [x] git-style diff output for changed lines 22 | * [x] GUI Preview (WIP) 23 | * [x] Inspect individual plate screens before deploying to an actual device 24 | * [x] Speedup prototyping by using the preview to test your changes 25 | * [x] API client (Web + MQTT) 26 | * [x] execute commands on a plate 27 | * [x] listen to events and state updates 28 | 29 | ```shell 30 | > openhasp-config-manager -h 31 | Usage: openhasp-config-manager [OPTIONS] COMMAND [ARGS]... 32 | 33 | Options: 34 | --version Show the version and exit. 35 | -h, --help Show this message and exit. 36 | 37 | Commands: 38 | cmd Sends a command request to a device. 39 | deploy Combines the generation and upload of a configuration. 40 | generate Generates the output files for all devices in the given... 41 | help Show this message and exit. 42 | listen Sends a state update request to a device. 43 | logs Prints the logs of a device. 44 | screenshot Requests a screenshot from the given device and stores it... 45 | shell Connects to the telnet server of a device. 46 | state Sends a state update request to a device. 47 | upload Uploads the previously generated configuration to their... 48 | vars Prints the variables accessible in a given path. 49 | ``` 50 | 51 | # Disclaimer 52 | 53 | **TL;DR: This project is still experimental.** 54 | 55 | I do use openhasp-config-manager exclusively to configure all of my openHASP devices. I am in the 56 | process of adding tests to everything to make it more reliable and have also added lots of features along the way. 57 | However, there are definitely still a couple of things that do not yet work as intended. Error logs 58 | might need some love to be able to figure out what you did wrong. If you like the 59 | project, feel free to open an issue or PR to help me out. 60 | 61 | # How to use 62 | 63 | ## Docker 64 | 65 | ``` 66 | docker run -it --rm \ 67 | --name openhasp-config-manager \ 68 | --user 1000:1000 \ 69 | -v "./openhasp-configs:/app/openhasp-configs" \ 70 | -v "./output:/app/output" \ 71 | ghcr.io/markusressel/openhasp-config-manager 72 | ``` 73 | 74 | ## Installation 75 | 76 | Since openhasp-config-manager needs some dependencies (see [here](/pyproject.toml)) it is 77 | **recommended to install it inside a virtualenv**. 78 | 79 | ### venv-install 80 | 81 | [venv-install](https://github.com/markusressel/venv-install) is a little helper tool to eas the 82 | installation, management and usage of python cli tools in venvs. 83 | 84 | ```bash 85 | venv-install openhasp-config-manager openhasp-config-manager 86 | openhasp-config-manager -h 87 | ``` 88 | 89 | ### Manual 90 | 91 | ```bash 92 | mkdir -p ~/venvs/openhasp-config-manager 93 | python3 -m venv ~/venvs/openhasp-config-manager 94 | source ~/venvs/openhasp-config-manager/bin/activate 95 | pip3 install openhasp-config-manager 96 | ``` 97 | 98 | And to use it: 99 | 100 | ```shell 101 | source ~/venvs/openhasp-config-manager/bin/activate 102 | openhasp-config-manager -h 103 | openhasp-config-manager analyze -c "./openhasp-configs" 104 | ... 105 | ``` 106 | 107 | ### Uninstall 108 | 109 | ```bash 110 | deactivate 111 | rm -rf ~/venvs/openhasp-config-manager 112 | ``` 113 | 114 | ## Configuration 115 | 116 | openhasp-config-manager is first and foremost a configuration 117 | management system. Simply follow the basic folder structure and 118 | config deployment will become trivial. **Please read all of this, 119 | as it is very important to understand the basic structure on 120 | which everything relies.** 121 | 122 | ### Folder Structure 123 | 124 | The following folders should reside inside a single parent 125 | folder, f.ex. named `openhasp-configs`. This folder can be 126 | located anywhere you like, but must be accessible to 127 | openhasp-config-manager when executing. 128 | 129 | * `common`: The `common` subdirectory can be used for files 130 | that should be included on _all_ device. This folder is optional. 131 | * `devices`: The `devices` folder is required. It must contain one 132 | subfolder for each openHASP device you want to configure using 133 | openhasp-config-manager. It is recommended to name subfolders according 134 | to the physical devices associated with them. 135 | * `touch_down_1` (example device folder) 136 | * A device folder contains `*.jsonl`, `*.cmd` and other files which should 137 | only be uploaded to that particular device. 138 | * You can create arbitrary nested folder structures for organizing the files. 139 | There is a limit to the file name length though, 140 | see [FAQ](#output-file-name-length-must-not-exceed-30-characters) 141 | * You must provide a `config.json` file, see [config.json](#config.json) 142 | for more info. 143 | 144 | A more advanced configuration layout could look something like this: 145 | 146 | ```text 147 | openhasp-configs 148 | ├── common 149 | │   ├── content 150 | │   │   └── card.jsonl 151 | │   ├── dialog 152 | │   │   ├── connected.jsonl 153 | │   │   └── offline.jsonl 154 | │   ├── navigation_bar.jsonl 155 | │   └── page_header.jsonl 156 | └── devices 157 | └── touch_down_1 158 | ├── 0_home 159 | │   ├── 0_header.jsonl 160 | │   ├── 1_content.jsonl 161 | │   └── page.cmd 162 | ├── 5_about 163 | │   ├── 0_header.jsonl 164 | │   ├── 1_content.jsonl 165 | │   └── page.cmd 166 | ├── boot.cmd 167 | ├── config.json 168 | ├── offline.cmd 169 | └── online.cmd 170 | ``` 171 | 172 | ### config.json 173 | 174 | openhasp-config-manager makes use of the `config.json` on your plate. It can extract information 175 | from it to detect things like screen orientation, and also allows you to deploy changes within the 176 | `config.json` file. Since [the official API does not support 177 | uploading the full file](https://github.com/HASwitchPlate/openHASP/issues/363), only settings 178 | which can also be set through the web ui on the plate itself are currently supported. 179 | 180 | To retrieve the initial version of the `config.json` file you can use the 181 | built-in file browser integrated into the webserver of your openHASP plate, see 182 | [official docs](https://www.openhasp.com/latest/faq/?h=web#is-there-a-file-browser-built-in). 183 | 184 | The official `config.json` file doesn't provide enough info for openhasp-config-manager 185 | to enable all of its features though. To fix that simply add a section to the 186 | file after downloading it: 187 | 188 | ```json 189 | { 190 | "openhasp_config_manager": { 191 | "device": { 192 | "ip": "192.168.5.134", 193 | "screen": { 194 | "width": 320, 195 | "height": 480 196 | } 197 | } 198 | }, 199 | "wifi": { 200 | "ssid": "Turris IoT", 201 | ... 202 | } 203 | ``` 204 | 205 | ### Config File Preprocessing 206 | 207 | openhasp-config-manager runs all configuration files through various preprocessors, which allow us to use 208 | features the original file formats do not support by themselves, like f.ex. templating. 209 | 210 | #### Multiline JSONL files 211 | 212 | While the JSONL file format requires each object to be on a single line, openhasp-config-manager 213 | allows you to add as many line breaks as you wish. This makes it much easier to edit, since a config 214 | like this: 215 | 216 | ```json 217 | { 218 | "page": 0, 219 | "id": 31, 220 | "obj": "msgbox", 221 | "text": "%ip%", 222 | "auto_close": 5000 223 | } 224 | ``` 225 | 226 | will be deployed like this: 227 | 228 | ```json lines 229 | { 230 | "page": 0, 231 | "id": 31, 232 | "obj": "msgbox", 233 | "text": "%ip%", 234 | "auto_close": 5000 235 | } 236 | ``` 237 | 238 | #### Comments 239 | 240 | Neither JSON nor JSONL allows comments, but openhasp-config-manager does! 241 | You can mark comments by prefixing them with a double forward-slash: 242 | 243 | ```json5 244 | // File description 245 | { 246 | // Object Description 247 | "page": 0, 248 | "id": 31, 249 | // Property Description 250 | "obj": "msgbox", 251 | "text": "%ip%", 252 | "auto_close": 5000 253 | } 254 | ``` 255 | 256 | #### Templating 257 | 258 | You can use Jinja2 templates inside all jsonl object values. To access the value of another object in a 259 | template, you can use the `pXbY` syntax established by openHASP, where `X` is the `page` of an object and 260 | `Y` is its `id`. openhasp-config-manager even tries to resolve templates that lead to other templates. 261 | Be careful not to create loops in this way though. 262 | 263 | You can use the full functionality of Jinja2 like f.ex. math operations, function calls or type conversions. 264 | 265 | ```yaml 266 | { 267 | "page": 1, 268 | "id": 1, 269 | "x": 0, 270 | "y": 0, 271 | ... 272 | } 273 | 274 | { 275 | "page": 1, 276 | "id": 2, 277 | "x": "{{ p1b1.x }}", 278 | "y": "{{ p1b1.y + 10 }}", 279 | ... 280 | } 281 | ``` 282 | 283 | #### Variables 284 | 285 | Besides accessing other objects, you can also define custom variables yourself, which can then 286 | be referenced inside of templates. Variables are defined using `*.yaml` files. If you 287 | decided to use a subfolder structure to organize your configuration files you can use these folders 288 | to also set the scope of variables. More specific variable definitions (longer path) will override 289 | less specific ones. 290 | 291 | ##### Global 292 | 293 | Global variables can be specified by creating `*.yaml` files inside the root config folder (f.ex. `openhasp-configs`). 294 | 295 | Example: 296 | 297 | `openhasp-configs/global.vars.yaml` 298 | 299 | ```yaml 300 | about: 301 | page_title: "About" 302 | ``` 303 | 304 | To access this variable, use a Jinja2 template: 305 | 306 | `openhasp-configs/common/about_page.jsonl` 307 | 308 | ```json lines 309 | { 310 | "page": 9, 311 | "id": 1, 312 | ... 313 | "title": "{{ about.page_title }}", 314 | ... 315 | } 316 | ``` 317 | 318 | ##### Device specific 319 | 320 | Device specific variables can be specified by creating `*.yaml` files inside any of the sub-folders 321 | of a `device` folder. 322 | 323 | > **Note** 324 | > 325 | > Device specific variables will override global variables, given the same name. 326 | 327 | Example: 328 | 329 | `openhasp-configs/device/my_device/device.vars.yaml` 330 | 331 | ```yaml 332 | page_title: "My Device" 333 | ``` 334 | 335 | `openhasp-configs/device/my_device/some_folder/some_page.jsonl` 336 | 337 | ```json lines 338 | { 339 | "page": 1, 340 | "id": 1, 341 | ... 342 | "title": "{{ page_title }}", 343 | ... 344 | } 345 | ``` 346 | 347 | `openhasp-configs/device/my_device/some_other_folder/some_page.jsonl` 348 | 349 | ```json lines 350 | { 351 | "page": 2, 352 | "id": 1, 353 | ... 354 | "title": "{{ page_title }}", 355 | ... 356 | } 357 | ``` 358 | 359 | #### Printing variables 360 | 361 | If you are not sure what variables are accessible in a given path, you can use the `vars` 362 | command, which will give you a copy&paste ready output of all variables for a 363 | given directory: 364 | 365 | ```shell 366 | > openhasp-config-manager vars -c openhasp-configs -p devices/touch_down_1/home 367 | common.navbar.first_page: 1 368 | common.navbar.last_page: 4 369 | ... 370 | header.title: Home 371 | ``` 372 | 373 | #### Theming 374 | 375 | To specify default property values for an object type, simply define them as a variable 376 | under `theme.obj.`, where `` is the value of the `obj` property of the object. 377 | 378 | The keys used must conform to the naming of the object properties as specified in OpenHasp, see: 379 | https://www.openhasp.com/latest/design/objects/ 380 | 381 | F.ex., to make the background color of all buttons red by default, define: 382 | 383 | ```yaml 384 | theme: 385 | obj: 386 | btn: 387 | bg_color: "#FF0000" 388 | ``` 389 | 390 | in a global variable file named `theme.yaml` located at the root of your configurations directory 391 | `openhasp-configs/theme.yaml`. 392 | 393 | ## Deployment 394 | 395 | To deploy your configurations to the already connected openHASP devices, simply use the 396 | `generate`, `upload` or `deploy` commands of `openhasp-config-manager`. 397 | 398 | > **Note** 399 | > openhasp-config-manager needs direct IP access as well as an enabled webservice on the plate 400 | > to be able to deploy files to the device. To enable the webservice 401 | > try: `openhasp-config-manager cmd -d plate35 -C service -p "start http"` 402 | 403 | ## Run commands 404 | 405 | While openhasp-config-manager is first and foremost a config management system, 406 | it also allows you to run commands on a device by issuing MQTT messages without the need to install a separate 407 | MQTT client first. Note that the MQTT _server_ still needs to be running and also has to 408 | be reachable from your local machine for this to work. 409 | 410 | For a list of possible commands to send to a device, take a look at the official 411 | documentation: https://openhasp.haswitchplate.com/latest/commands/ 412 | 413 | ```shell 414 | > openhasp-config-manager cmd -c ./openhasp-configs -d plate35 -C backlight -p "{\"state\":\"on\",\"brightness\":128}" 415 | ``` 416 | 417 | ## API Client 418 | 419 | openhasp-config-manager also provides a simple API client to interact with a plate. 420 | See [example.py](example.py) to see how to use it. 421 | 422 | # FAQ 423 | 424 | ## How do I see device logs? 425 | 426 | Try the `logs` command (this does require network access to the device): 427 | 428 | ```shell 429 | > openhasp-config-manager logs -d plate35 430 | ``` 431 | 432 | If that doesn't work, open a terminal and run the following command with the device connected via USB cable: 433 | 434 | ```shell 435 | bash -c "screen -q -L -Logfile device.log /dev/ttyUSB0 115200 &> /dev/null; tail -F device.log; killall screen" 436 | ``` 437 | 438 | ## Output file name length must not exceed 30 characters 439 | 440 | If you want to organize your files (both common and device-specific ones) you can 441 | simply create subfolders to achieve your desired structure. However, due to a technical 442 | limitation openHASP does not support subfolder on the actual device. To overcome 443 | this limitation openhasp-config-manager will automatically generate a file name for 444 | files in subfolders before uploading them to the device. `.json` or `.cmd` files within subfolders 445 | will be renamed by concatenating their full subpath using an underscore (`_`) as a separator. So f.ex. 446 | the file in the following structure: 447 | 448 | ```text 449 | openhasp-configs 450 | └── devices 451 | └── touch_down_1 452 | └── 0_home 453 | └── 0_header.jsonl 454 | ``` 455 | 456 | would be uploaded to the `touch_down_1` device with the name `0_home_0_header.jsonl`. 457 | 458 | # Contributing 459 | 460 | GitHub is for social coding: if you want to write code, I encourage contributions 461 | through pull requests from forks of this repository. Create GitHub tickets for 462 | bugs and new features and comment on the ones that you are interested in. 463 | 464 | # License 465 | 466 | ```text 467 | openhasp-config-manager is free software: you can redistribute it and/or modify 468 | it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by 469 | the Free Software Foundation, either version 3 of the License, or 470 | (at your option) any later version. 471 | 472 | This program is distributed in the hope that it will be useful, 473 | but WITHOUT ANY WARRANTY; without even the implied warranty of 474 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 475 | GNU General Public License for more details. 476 | 477 | You should have received a copy of the GNU General Public License 478 | along with this program. If not, see . 479 | ``` 480 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import sys 4 | from pathlib import Path 5 | 6 | from openhasp_config_manager.manager import ConfigManager 7 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 8 | from openhasp_config_manager.processing.variables import VariableManager 9 | from openhasp_config_manager.uploader import ConfigUploader 10 | 11 | logger = logging.getLogger("example") 12 | logger.setLevel(logging.DEBUG) 13 | console_handler = logging.StreamHandler(sys.stdout) 14 | console_handler.setLevel(logging.DEBUG) 15 | console_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) 16 | logger.addHandler(console_handler) 17 | 18 | 19 | async def event_callback(topic: str, payload: bytes): 20 | logger.info(f"MQTT Event: {topic} - {payload.decode('utf-8')}") 21 | 22 | async def state_callback(topic: str, payload: bytes): 23 | logger.info(f"State Event: {topic} - {payload.decode('utf-8')}") 24 | 25 | 26 | async def main(): 27 | config_dir = Path("./openhasp-configs") 28 | output_dir = Path("./output") 29 | 30 | variable_manager = VariableManager(cfg_root=config_dir) 31 | config_manager = ConfigManager( 32 | cfg_root=config_dir, 33 | output_root=output_dir, 34 | variable_manager=variable_manager 35 | ) 36 | 37 | devices = config_manager.analyze() 38 | 39 | device = next(filter(lambda x: x.name == "wt32sc01plus_2", devices)) 40 | 41 | client = OpenHaspClient(device) 42 | uploader = ConfigUploader(output_dir, client) 43 | 44 | # deploy the local config to the device 45 | uploader.upload(device=device, purge=False, print_diff=True) 46 | 47 | # subscribe to state changes of object p1b22 (async) 48 | await client.listen_state(obj="p1b22", callback=state_callback) 49 | await client.listen_state(obj="p1b29", callback=state_callback) 50 | # cancel a previously registered callback 51 | # note: this will effect both previously registered lines 52 | await client.cancel_callback(callback=state_callback) 53 | 54 | # subscribe to all MQTT events (async) 55 | await client.listen_event(path="#", callback=event_callback) 56 | 57 | # update an object on the device (via MQTT) 58 | await client.set_text( 59 | obj="p1b10", 60 | text="Hello!", 61 | ) 62 | 63 | await asyncio.sleep(10) 64 | 65 | await client.set_text( 66 | obj="p1b10", 67 | text="World!", 68 | ) 69 | 70 | # wait forever (to let "listen_event" and "listen_state" tasks continue) 71 | await asyncio.Event().wait() 72 | 73 | 74 | if __name__ == '__main__': 75 | asyncio.run(main()) 76 | -------------------------------------------------------------------------------- /example_image.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from openhasp_config_manager.manager import ConfigManager 5 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 6 | from openhasp_config_manager.processing.variables import VariableManager 7 | 8 | 9 | async def main(): 10 | """ 11 | Example to set an image on a device using OpenHaspClient. 12 | """ 13 | config_dir = Path("./openhasp-configs") 14 | output_dir = Path("./output") 15 | 16 | variable_manager = VariableManager(cfg_root=config_dir) 17 | config_manager = ConfigManager( 18 | cfg_root=config_dir, 19 | output_root=output_dir, 20 | variable_manager=variable_manager 21 | ) 22 | 23 | devices = config_manager.analyze() 24 | device = next(filter(lambda x: x.name == "wt32sc01plus_2", devices)) 25 | 26 | client = OpenHaspClient(device) 27 | 28 | home_assistant_camera_snapshot_url = "http://192.168.2.20:8123/api/camera_proxy/camera.x1c_00m09a3c2900999_camera?token=baef380cd0905d90ee0a47b397a6626f4ab1a2e4a087540d99555c2d89ec8783" 29 | await client.set_image( 30 | obj="p2b40", 31 | image=home_assistant_camera_snapshot_url, 32 | # size=(int(108 / 2), int(192 / 2)), 33 | size=(108, 192), 34 | listen_host="0.0.0.0", 35 | listen_port=20000, 36 | access_host="192.168.2.199", 37 | access_port=20000, 38 | ) 39 | 40 | 41 | if __name__ == '__main__': 42 | asyncio.run(main()) 43 | -------------------------------------------------------------------------------- /example_jsonl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | from typing import List, Dict 4 | 5 | from orjson import orjson 6 | 7 | from openhasp_config_manager.manager import ConfigManager 8 | from openhasp_config_manager.openhasp_client.model.device import Device 9 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 10 | from openhasp_config_manager.processing.variables import VariableManager 11 | 12 | 13 | async def main(): 14 | """ 15 | Example to set an image on a device using OpenHaspClient. 16 | """ 17 | config_dir = Path("./openhasp-configs") 18 | output_dir = Path("./output") 19 | 20 | variable_manager = VariableManager(cfg_root=config_dir) 21 | config_manager = ConfigManager( 22 | cfg_root=config_dir, 23 | output_root=output_dir, 24 | variable_manager=variable_manager 25 | ) 26 | 27 | devices = config_manager.analyze() 28 | device: Device = next(filter(lambda x: x.name == "wt32sc01plus_2", devices)) 29 | device_processor = config_manager.create_device_processor(device) 30 | # load jsonl component objects 31 | boot_cmd_component = next(filter(lambda x: x.name == "boot.cmd", device.cmd), None) 32 | ordered_jsonl_components = config_manager.determine_device_jsonl_component_order_for_cmd(device, boot_cmd_component) 33 | loaded_objects: List[Dict] = [] 34 | for jsonl_component in ordered_jsonl_components: 35 | normalized_jsonl_component = device_processor.normalize(device, jsonl_component) 36 | objects_in_jsonl = normalized_jsonl_component.splitlines() 37 | loaded_objects.extend(list(map(orjson.loads, objects_in_jsonl))) 38 | 39 | # apply page 0 objects last (on top) 40 | loaded_objects = sorted(loaded_objects, key=lambda x: x.get("page", 0)) 41 | 42 | client = OpenHaspClient(device) 43 | 44 | await client.clear_page(0) 45 | await client.clear_page(1) 46 | await client.clear_page(2) 47 | await client.clear_page(3) 48 | 49 | for jsonl_object in loaded_objects: 50 | if jsonl_object.get("page", None) not in [0, 1, 2, 3]: 51 | continue 52 | print(f"Sending {jsonl_object}") 53 | await client.command("jsonl", jsonl_object) 54 | 55 | 56 | if __name__ == '__main__': 57 | asyncio.run(main()) 58 | -------------------------------------------------------------------------------- /openhasp_config_manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/cli.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.cli import cli 2 | 3 | 4 | def main(): 5 | import os 6 | import sys 7 | 8 | parent_dir = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..")) 9 | sys.path.append(parent_dir) 10 | 11 | cli() 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | import click 5 | 6 | from openhasp_config_manager.cli.cmd import c_cmd 7 | from openhasp_config_manager.cli.deploy import c_deploy 8 | from openhasp_config_manager.cli.generate import c_generate 9 | from openhasp_config_manager.cli.gui import c_gui 10 | from openhasp_config_manager.cli.listen import c_listen 11 | from openhasp_config_manager.cli.logs import c_logs 12 | from openhasp_config_manager.cli.screenshot import c_screenshot 13 | from openhasp_config_manager.cli.shell import c_shell 14 | from openhasp_config_manager.cli.state import c_state 15 | from openhasp_config_manager.cli.upload import c_upload 16 | from openhasp_config_manager.cli.vars import c_vars 17 | from openhasp_config_manager.ui.util import echo 18 | 19 | PARAM_CFG_DIR = "cfg_dir" 20 | PARAM_OUTPUT_DIR = "output_dir" 21 | PARAM_DEVICE = "device" 22 | PARAM_PURGE = "purge" 23 | PARAM_SHOW_DIFF = "diff" 24 | PARAM_CMD = "cmd" 25 | PARAM_PAYLOAD = "payload" 26 | PARAM_PATH = "path" 27 | PARAM_OBJECT = "object" 28 | PARAM_STATE = "state" 29 | PARAM_MQTT_PATH = "mqtt_path" 30 | 31 | DEFAULT_CONFIG_PATH = Path("./openhasp-configs") 32 | DEFAULT_OUTPUT_PATH = Path("./output") 33 | DEFAULT_SCREENSHOT_OUTPUT_PATH = Path("./") 34 | 35 | CMD_OPTION_NAMES = { 36 | PARAM_CFG_DIR: { 37 | "names": ["--config-dir", "-c"], 38 | "help": """Root directory which contains all of your openHASP configuration files.""", 39 | }, 40 | PARAM_OUTPUT_DIR: { 41 | "names": ["--output-dir", "-o"], 42 | "help": """Target directory to write generated output files to.""", 43 | }, 44 | PARAM_DEVICE: { 45 | "names": ["--device", "-d"], 46 | "help": """ 47 | The name of the device to target. 48 | Must be one of the device specific folders within the configuration. 49 | """ 50 | }, 51 | PARAM_CMD: { 52 | "names": ["--command", "-C"], 53 | "help": """Name of the command to execute, see: https://www.openhasp.com/latest/commands/""", 54 | }, 55 | PARAM_PAYLOAD: { 56 | "names": ["--payload", "-p"], 57 | "help": """Command payload.""", 58 | }, 59 | PARAM_PURGE: { 60 | "names": ["--purge", "-P"], 61 | "help": """Whether to cleanup the target device by removing files which are not part of the generated output.""", 62 | }, 63 | PARAM_SHOW_DIFF: { 64 | "names": ["--diff", "-D"], 65 | "help": """Whether to show a diff for files uploaded to the target device.""", 66 | }, 67 | PARAM_PATH: { 68 | "names": ["--path", "-p"], 69 | "help": """The subpath inside the configuration directory""" 70 | }, 71 | PARAM_OBJECT: { 72 | "names": ["--object", "-o"], 73 | "help": """The object identifier, f.ex. p1b15""" 74 | }, 75 | PARAM_STATE: { 76 | "names": ["--state", "-s"], 77 | "help": """The state to set. Can also be a json object to set multiple properties in one go.""" 78 | }, 79 | PARAM_MQTT_PATH: { 80 | "names": ["--path", "-p"], 81 | "help": """The MQTT sub-path (hasp//) to listen to.""" 82 | } 83 | } 84 | 85 | 86 | def get_option_names(parameter: str) -> list: 87 | """ 88 | Returns a list of all valid console parameter names for a given parameter 89 | :param parameter: the parameter to check 90 | :return: a list of all valid names to use this parameter 91 | """ 92 | return CMD_OPTION_NAMES[parameter]["names"] 93 | 94 | 95 | def get_option_help(parameter: str) -> str: 96 | """ 97 | Returns the help message for a given parameter 98 | :param parameter: the parameter to check 99 | :return: the help message 100 | """ 101 | return CMD_OPTION_NAMES[parameter]["help"] 102 | 103 | 104 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 105 | 106 | 107 | @click.group(context_settings=CONTEXT_SETTINGS) 108 | @click.version_option() 109 | def cli(): 110 | pass 111 | 112 | 113 | @cli.command(name="help") 114 | def c_help(): 115 | """ 116 | Show this message and exit. 117 | """ 118 | with click.Context(cli) as ctx: 119 | echo(ctx.get_help()) 120 | 121 | 122 | @cli.command(name="gui") 123 | @click.option(*get_option_names(PARAM_CFG_DIR), 124 | required=False, 125 | default=DEFAULT_CONFIG_PATH, 126 | type=click.Path(exists=True, path_type=Path), 127 | help=get_option_help(PARAM_CFG_DIR)) 128 | @click.option(*get_option_names(PARAM_OUTPUT_DIR), 129 | required=True, 130 | default=DEFAULT_OUTPUT_PATH, 131 | type=click.Path(path_type=Path), 132 | help=get_option_help(PARAM_OUTPUT_DIR)) 133 | def generate(config_dir: Path, output_dir: Path): 134 | """ 135 | Launches the GUI of openhasp-config-manager. 136 | """ 137 | asyncio.run( 138 | c_gui(config_dir, output_dir) 139 | ) 140 | 141 | @cli.command(name="generate") 142 | @click.option(*get_option_names(PARAM_CFG_DIR), 143 | required=False, 144 | default=DEFAULT_CONFIG_PATH, 145 | type=click.Path(exists=True, path_type=Path), 146 | help=get_option_help(PARAM_CFG_DIR)) 147 | @click.option(*get_option_names(PARAM_OUTPUT_DIR), 148 | required=True, 149 | default=DEFAULT_OUTPUT_PATH, 150 | type=click.Path(path_type=Path), 151 | help=get_option_help(PARAM_OUTPUT_DIR)) 152 | @click.option(*get_option_names(PARAM_DEVICE), required=False, default=None, 153 | help=get_option_help(PARAM_DEVICE)) 154 | def generate(config_dir: Path, output_dir: Path, device: str): 155 | """ 156 | Generates the output files for all devices in the given config directory. 157 | """ 158 | asyncio.run( 159 | c_generate(config_dir, output_dir, device) 160 | ) 161 | 162 | 163 | @cli.command(name="deploy") 164 | @click.option(*get_option_names(PARAM_CFG_DIR), 165 | required=False, 166 | default=DEFAULT_CONFIG_PATH, 167 | type=click.Path(exists=True, path_type=Path), 168 | help=get_option_help(PARAM_CFG_DIR)) 169 | @click.option(*get_option_names(PARAM_OUTPUT_DIR), 170 | required=True, 171 | default=DEFAULT_OUTPUT_PATH, 172 | type=click.Path(path_type=Path), 173 | help=get_option_help(PARAM_OUTPUT_DIR)) 174 | @click.option(*get_option_names(PARAM_DEVICE), required=False, default=None, 175 | help=get_option_help(PARAM_DEVICE)) 176 | @click.option(*get_option_names(PARAM_PURGE), is_flag=True, 177 | help=get_option_help(PARAM_PURGE)) 178 | @click.option(*get_option_names(PARAM_SHOW_DIFF), is_flag=True, 179 | help=get_option_help(PARAM_SHOW_DIFF)) 180 | def deploy(config_dir: Path, output_dir: Path, device: str, purge: bool, diff: bool): 181 | """ 182 | Combines the generation and upload of a configuration. 183 | """ 184 | asyncio.run( 185 | c_deploy(config_dir, output_dir, device, purge, diff) 186 | ) 187 | 188 | 189 | @cli.command(name="upload") 190 | @click.option(*get_option_names(PARAM_CFG_DIR), 191 | required=False, 192 | default=DEFAULT_CONFIG_PATH, 193 | type=click.Path(exists=True, path_type=Path), 194 | help=get_option_help(PARAM_CFG_DIR)) 195 | @click.option(*get_option_names(PARAM_OUTPUT_DIR), 196 | required=True, 197 | default=DEFAULT_OUTPUT_PATH, 198 | type=click.Path(path_type=Path), 199 | help=get_option_help(PARAM_OUTPUT_DIR)) 200 | @click.option(*get_option_names(PARAM_DEVICE), required=False, default=None, 201 | help=get_option_help(PARAM_DEVICE)) 202 | @click.option(*get_option_names(PARAM_PURGE), is_flag=True, 203 | help=get_option_help(PARAM_PURGE)) 204 | @click.option(*get_option_names(PARAM_SHOW_DIFF), is_flag=True, 205 | help=get_option_help(PARAM_SHOW_DIFF)) 206 | def upload(config_dir: Path, output_dir: Path, device: str, purge: bool, diff: bool): 207 | """ 208 | Uploads the previously generated configuration to their corresponding devices. 209 | """ 210 | asyncio.run( 211 | c_upload(config_dir, output_dir, device, purge, diff) 212 | ) 213 | 214 | 215 | @cli.command(name="logs") 216 | @click.option(*get_option_names(PARAM_CFG_DIR), 217 | required=False, 218 | default=DEFAULT_CONFIG_PATH, 219 | type=click.Path(exists=True, path_type=Path), 220 | help=get_option_help(PARAM_CFG_DIR)) 221 | @click.option(*get_option_names(PARAM_DEVICE), required=True, default=None, 222 | help=get_option_help(PARAM_DEVICE)) 223 | def logs(config_dir: Path, device: str): 224 | """ 225 | Prints the logs of a device. 226 | """ 227 | asyncio.run( 228 | c_logs(config_dir, device) 229 | ) 230 | 231 | 232 | @cli.command(name="shell") 233 | @click.option(*get_option_names(PARAM_CFG_DIR), 234 | required=False, 235 | default=DEFAULT_CONFIG_PATH, 236 | type=click.Path(exists=True, path_type=Path), 237 | help=get_option_help(PARAM_CFG_DIR)) 238 | @click.option(*get_option_names(PARAM_DEVICE), required=True, default=None, 239 | help=get_option_help(PARAM_DEVICE)) 240 | def shell(config_dir: Path, device: str): 241 | """ 242 | Connects to the telnet server of a device. 243 | """ 244 | asyncio.run( 245 | c_shell(config_dir, device) 246 | ) 247 | 248 | 249 | @cli.command(name="listen") 250 | @click.option(*get_option_names(PARAM_CFG_DIR), 251 | required=False, 252 | default=DEFAULT_CONFIG_PATH, 253 | type=click.Path(exists=True, path_type=Path), 254 | help=get_option_help(PARAM_CFG_DIR)) 255 | @click.option(*get_option_names(PARAM_DEVICE), 256 | required=False, 257 | help=get_option_help(PARAM_DEVICE)) 258 | @click.option(*get_option_names(PARAM_MQTT_PATH), 259 | required=True, 260 | help=get_option_help(PARAM_MQTT_PATH)) 261 | def listen(config_dir: Path, device: str, path: str): 262 | """ 263 | Sends a state update request to a device. 264 | """ 265 | asyncio.run( 266 | c_listen(config_dir, device, path) 267 | ) 268 | 269 | 270 | @cli.command(name="cmd") 271 | @click.option(*get_option_names(PARAM_CFG_DIR), 272 | required=False, 273 | default=DEFAULT_CONFIG_PATH, 274 | type=click.Path(exists=True, path_type=Path), 275 | help=get_option_help(PARAM_CFG_DIR)) 276 | @click.option(*get_option_names(PARAM_DEVICE), 277 | required=False, 278 | help=get_option_help(PARAM_DEVICE)) 279 | @click.option(*get_option_names(PARAM_CMD), 280 | required=True, 281 | help=get_option_help(PARAM_CMD)) 282 | @click.option(*get_option_names(PARAM_PAYLOAD), 283 | required=False, 284 | default="", 285 | help=get_option_help(PARAM_PAYLOAD)) 286 | def cmd(config_dir: Path, device: str, command: str, payload: str): 287 | """ 288 | Sends a command request to a device. 289 | 290 | The list of possible commands can be found on the official openHASP 291 | documentation: https://www.openhasp.com/latest/commands 292 | """ 293 | asyncio.run( 294 | c_cmd(config_dir, device, command, payload) 295 | ) 296 | 297 | 298 | @cli.command(name="state") 299 | @click.option(*get_option_names(PARAM_CFG_DIR), 300 | required=False, 301 | default=DEFAULT_CONFIG_PATH, 302 | type=click.Path(exists=True, path_type=Path), 303 | help=get_option_help(PARAM_CFG_DIR)) 304 | @click.option(*get_option_names(PARAM_DEVICE), 305 | required=False, 306 | help=get_option_help(PARAM_DEVICE)) 307 | @click.option(*get_option_names(PARAM_OBJECT), 308 | required=True, 309 | help=get_option_help(PARAM_OBJECT)) 310 | @click.option(*get_option_names(PARAM_STATE), 311 | required=True, 312 | help=get_option_help(PARAM_STATE)) 313 | def state(config_dir: Path, device: str, object: str, state: str): 314 | """ 315 | Sends a state update request to a device. 316 | """ 317 | asyncio.run( 318 | c_state(config_dir, device, object, state) 319 | ) 320 | 321 | 322 | @cli.command(name="vars") 323 | @click.option(*get_option_names(PARAM_CFG_DIR), 324 | required=False, 325 | default=DEFAULT_CONFIG_PATH, 326 | type=click.Path(exists=True, path_type=Path), 327 | help=get_option_help(PARAM_CFG_DIR)) 328 | @click.option(*get_option_names(PARAM_PATH), 329 | required=False, 330 | default="", 331 | help=get_option_help(PARAM_PATH)) 332 | def vars(config_dir: Path, path: str): 333 | """ 334 | Prints the variables accessible in a given path. 335 | """ 336 | asyncio.run( 337 | c_vars(config_dir, path) 338 | ) 339 | 340 | 341 | @cli.command(name="screenshot") 342 | @click.option(*get_option_names(PARAM_CFG_DIR), 343 | required=False, 344 | default=DEFAULT_CONFIG_PATH, 345 | type=click.Path(exists=True, path_type=Path), 346 | help=get_option_help(PARAM_CFG_DIR)) 347 | @click.option(*get_option_names(PARAM_DEVICE), 348 | required=True, 349 | help=get_option_help(PARAM_DEVICE)) 350 | @click.option(*get_option_names(PARAM_OUTPUT_DIR), 351 | required=True, 352 | default=DEFAULT_SCREENSHOT_OUTPUT_PATH, 353 | type=click.Path(path_type=Path), 354 | help=get_option_help(PARAM_OUTPUT_DIR)) 355 | def screenshot(config_dir: Path, device: str, output_dir: Path): 356 | """ 357 | Requests a screenshot from the given device and stores it to the given output directory. 358 | """ 359 | asyncio.run( 360 | c_screenshot(config_dir, device, output_dir) 361 | ) 362 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/cmd.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter, _cmd 4 | from openhasp_config_manager.ui.util import success, error 5 | 6 | 7 | async def c_cmd(config_dir: Path, device: str, command: str, payload: str): 8 | try: 9 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 10 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 11 | 12 | if len(filtered_devices) <= 0: 13 | raise Exception(f"No device matches the filter: {device}") 14 | 15 | for device in filtered_devices: 16 | await _cmd(device, command, payload) 17 | success("Done!") 18 | except Exception as ex: 19 | error(str(ex)) 20 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Tuple, List 3 | 4 | from openhasp_config_manager.manager import ConfigManager 5 | from openhasp_config_manager.openhasp_client.model.device import Device 6 | from openhasp_config_manager.processing.variables import VariableManager 7 | from openhasp_config_manager.ui.util import info 8 | 9 | 10 | async def _generate(config_manager: ConfigManager, device: Device): 11 | info(f"Generating output for '{device.name}'...") 12 | try: 13 | config_manager.process(device) 14 | except Exception as ex: 15 | raise Exception(f"Error generating output for {device.name}: {ex.__class__.__name__} {ex}") 16 | 17 | 18 | async def _upload(device: Device, output_dir: Path, purge: bool, show_diff: bool): 19 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 20 | from openhasp_config_manager.uploader import ConfigUploader 21 | 22 | client = OpenHaspClient(device) 23 | uploader = ConfigUploader(output_dir, client) 24 | 25 | info(f"Uploading files to device '{device.name}'...") 26 | return uploader.upload(device, purge, show_diff) 27 | 28 | 29 | async def _deploy(config_manager: ConfigManager, device: Device, output_dir: Path, purge: bool, show_diff: bool): 30 | await _generate(config_manager, device) 31 | changed = await _upload(device, output_dir, purge, show_diff) 32 | # _cmd(config_dir, device="touch_down_1", command="reboot", payload="") 33 | # _reload(config_dir, device) 34 | if changed: 35 | info(f"Rebooting {device.name} to apply changes") 36 | await _reboot(device) 37 | else: 38 | info(f"No changes detected for {device.name}, device is already up-to-date") 39 | 40 | 41 | async def _reload(device: Device): 42 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 43 | 44 | client = OpenHaspClient(device) 45 | await client.command("clearpage", "all") 46 | await client.command("run", "L:/boot.cmd") 47 | 48 | 49 | async def _reboot(device: Device): 50 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 51 | 52 | client = OpenHaspClient(device) 53 | info(f"Rebooting {device.name}...") 54 | client.reboot() 55 | 56 | 57 | async def _cmd(device: Device, command: str, payload: str): 58 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 59 | 60 | client = OpenHaspClient(device) 61 | info(f"Sending command {command} to {device.name}...") 62 | await client.command(command, payload) 63 | 64 | 65 | def _create_config_manager(config_dir, output_dir) -> ConfigManager: 66 | variable_manager = VariableManager(cfg_root=config_dir) 67 | config_manager = ConfigManager( 68 | cfg_root=config_dir, 69 | output_root=output_dir, 70 | variable_manager=variable_manager 71 | ) 72 | return config_manager 73 | 74 | 75 | def _analyze_and_filter(config_manager: ConfigManager, device_filter: str or None) -> Tuple[List[Device], List[Device]]: 76 | """ 77 | 78 | :param config_manager: 79 | :param device_filter: 80 | :return: (matching_devices, ignored_devices) 81 | """ 82 | info(f"Analyzing files in '{config_manager.cfg_root}'...") 83 | devices = config_manager.analyze() 84 | devices = list(sorted(devices, key=lambda x: x.name)) 85 | device_names = list(map(lambda x: x.name, devices)) 86 | info(f"Found devices: {', '.join(device_names)}") 87 | 88 | filtered_devices = [] 89 | ignored_devices = [] 90 | if device_filter is not None: 91 | filtered_devices = list(filter(lambda x: x.name == device_filter, devices)) 92 | ignored_devices = list(filter(lambda x: x not in filtered_devices, devices)) 93 | else: 94 | filtered_devices = devices 95 | 96 | return filtered_devices, ignored_devices 97 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/deploy.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter, _deploy 4 | from openhasp_config_manager.ui.util import warn, success, error 5 | 6 | 7 | async def c_deploy(config_dir: Path, output_dir: Path, device: str, purge: bool, diff: bool): 8 | try: 9 | config_manager = _create_config_manager(config_dir, output_dir) 10 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 11 | 12 | if len(filtered_devices) <= 0: 13 | if device is None: 14 | raise Exception("No devices found.") 15 | else: 16 | raise Exception(f"No device matches the filter: {device}") 17 | 18 | if len(ignored_devices) > 0: 19 | ignored_devices_names = list(map(lambda x: x.name, ignored_devices)) 20 | warn(f"Skipping devices: {', '.join(ignored_devices_names)}") 21 | 22 | for device in filtered_devices: 23 | await _deploy( 24 | config_manager=config_manager, 25 | device=device, 26 | output_dir=output_dir, 27 | purge=purge, 28 | show_diff=diff 29 | ) 30 | 31 | success("Done!") 32 | except Exception as ex: 33 | error(str(ex)) 34 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/generate.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter, _generate 4 | from openhasp_config_manager.ui.util import warn, success, error 5 | 6 | 7 | async def c_generate(config_dir: Path, output_dir: Path, device: str): 8 | try: 9 | config_manager = _create_config_manager(config_dir, output_dir) 10 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 11 | 12 | if len(filtered_devices) <= 0: 13 | if device is None: 14 | raise Exception("No devices found.") 15 | else: 16 | raise Exception(f"No device matches the filter: {device}") 17 | 18 | if len(ignored_devices) > 0: 19 | ignored_devices_names = list(map(lambda x: x.name, ignored_devices)) 20 | warn(f"Skipping devices: {', '.join(ignored_devices_names)}") 21 | 22 | for device in filtered_devices: 23 | await _generate(config_manager, device) 24 | 25 | success("Done!") 26 | except Exception as ex: 27 | error(str(ex)) 28 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/gui.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from PyQt6.QtWidgets import QApplication 5 | 6 | from openhasp_config_manager.cli.common import _create_config_manager 7 | from openhasp_config_manager.ui.qt.main import MainWindow 8 | 9 | 10 | async def c_gui(config_dir: Path, output_dir: Path): 11 | config_manager = _create_config_manager(config_dir, output_dir) 12 | app = QApplication(sys.argv) 13 | 14 | window = MainWindow(config_manager) 15 | window.show() 16 | 17 | app.exec() 18 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/listen.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter 5 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 6 | from openhasp_config_manager.ui.util import success, error, info 7 | 8 | 9 | async def c_listen(config_dir: Path, device: str, path: str): 10 | try: 11 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 12 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 13 | if len(filtered_devices) <= 0: 14 | raise Exception(f"No device matches the filter: {device}") 15 | info(f"Listening to '.../{path}' on devices: {', '.join(map(lambda x: x.name, filtered_devices))}") 16 | for device in filtered_devices: 17 | client = OpenHaspClient(device) 18 | 19 | async def on_message(topic: str, payload: bytes): 20 | info(f"{topic}: {payload.decode('utf-8')}") 21 | 22 | await client.listen_event(path, on_message) 23 | 24 | asyncio.get_event_loop().run_forever() 25 | success("Done!") 26 | except Exception as ex: 27 | error(str(ex)) 28 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/logs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter 4 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 5 | from openhasp_config_manager.ui.util import error, info 6 | 7 | 8 | async def c_logs(config_dir: Path, device: str): 9 | try: 10 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 11 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 12 | if len(filtered_devices) <= 0: 13 | raise Exception(f"No device matches the filter: {device}") 14 | if len(filtered_devices) > 1: 15 | raise Exception(f"More than one device matches the filter: {device}") 16 | info(f"Listening to logs of: {filtered_devices[0].name}") 17 | for device in filtered_devices: 18 | client = OpenHaspClient(device) 19 | await client.logs() 20 | except Exception as ex: 21 | error(str(ex)) 22 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/screenshot.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter 4 | from openhasp_config_manager.ui.util import error, success, info 5 | 6 | 7 | async def c_screenshot(config_dir: Path, device: str, output: Path): 8 | try: 9 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 10 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 11 | 12 | if len(filtered_devices) <= 0: 13 | raise Exception(f"No device matches the filter: {device}") 14 | 15 | for device in filtered_devices: 16 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 17 | from openhasp_config_manager.uploader import ConfigUploader 18 | 19 | client = OpenHaspClient(device) 20 | try: 21 | info(f"Taking screenshot of device '{device.name}'...") 22 | screenshot = client.take_screenshot() 23 | image_file_path = Path(output, f"{device.name}.bmp") 24 | image_file_path.write_bytes(screenshot) 25 | except Exception as ex: 26 | raise Exception(f"Error taking screenshot of device '{device.name}': {ex}") 27 | 28 | success("Done!") 29 | except Exception as ex: 30 | error(str(ex)) 31 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/shell.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter 4 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 5 | from openhasp_config_manager.ui.util import success, error, info 6 | 7 | 8 | async def c_shell(config_dir: Path, device: str): 9 | try: 10 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 11 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 12 | if len(filtered_devices) <= 0: 13 | raise Exception(f"No device matches the filter: {device}") 14 | if len(filtered_devices) > 1: 15 | raise Exception(f"More than one device matches the filter: {device}") 16 | info(f"Opening shell for: {filtered_devices[0].name}") 17 | for device in filtered_devices: 18 | client = OpenHaspClient(device) 19 | await client.shell() 20 | 21 | success("Done!") 22 | except Exception as ex: 23 | error(str(ex)) 24 | raise ex 25 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/state.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Dict 4 | 5 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter 6 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 7 | from openhasp_config_manager.ui.util import success, error 8 | 9 | 10 | async def c_state(config_dir: Path, device: str, object: str, state: str): 11 | try: 12 | state_json = json.loads(state) 13 | if not isinstance(state_json, Dict): 14 | raise Exception("State must be a JSON object.") 15 | 16 | config_manager = _create_config_manager(config_dir, Path("./nonexistent")) 17 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 18 | if len(filtered_devices) <= 0: 19 | raise Exception(f"No device matches the filter: {device}") 20 | for device in filtered_devices: 21 | client = OpenHaspClient(device) 22 | await client.set_object_properties(object, state_json) 23 | success("Done!") 24 | except Exception as ex: 25 | error(str(ex)) 26 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/upload.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.cli.common import _create_config_manager, _analyze_and_filter, _upload 4 | from openhasp_config_manager.ui.util import warn, success, error 5 | 6 | 7 | async def c_upload(config_dir: Path, output_dir: Path, device: str, purge: bool, diff: bool): 8 | try: 9 | config_manager = _create_config_manager(config_dir, output_dir) 10 | filtered_devices, ignored_devices = _analyze_and_filter(config_manager=config_manager, device_filter=device) 11 | 12 | if len(filtered_devices) <= 0: 13 | if device is None: 14 | raise Exception("No devices found.") 15 | else: 16 | raise Exception(f"No device matches the filter: {device}") 17 | 18 | if len(ignored_devices) > 0: 19 | ignored_devices_names = list(map(lambda x: x.name, ignored_devices)) 20 | warn(f"Skipping devices: {', '.join(ignored_devices_names)}") 21 | 22 | for device in filtered_devices: 23 | await _upload(device, output_dir, purge, diff) 24 | 25 | success("Done!") 26 | except Exception as ex: 27 | error(str(ex)) 28 | -------------------------------------------------------------------------------- /openhasp_config_manager/cli/vars.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, List 3 | 4 | from openhasp_config_manager.processing.variables import VariableManager 5 | from openhasp_config_manager.ui.util import error, echo 6 | 7 | 8 | async def _format_variables(variables: Dict) -> str: 9 | def get_dict_contents(d: Dict, parent_key: str = '', result: List[str] = []): 10 | for k in sorted(d.keys()): 11 | v = d[k] 12 | if isinstance(v, dict): 13 | get_dict_contents(v, parent_key + k + '.', result) 14 | elif isinstance(v, list): 15 | for i in range(len(v)): 16 | if isinstance(v[i], dict): 17 | get_dict_contents(v[i], parent_key + k + '.' + str(i) + '.', result) 18 | else: 19 | result.append(f"{parent_key}{k}[{i}]: {v[i]}") 20 | else: 21 | result.append(f"{parent_key}{k}: {v}") 22 | return result 23 | 24 | return "\n".join(get_dict_contents(variables)) 25 | 26 | 27 | async def c_vars(config_dir: Path, path: str): 28 | try: 29 | variable_manager = VariableManager(cfg_root=config_dir) 30 | variable_manager.read() 31 | variables = variable_manager.get_vars(Path(config_dir, path)) 32 | formatted = await _format_variables(variables) 33 | echo(formatted) 34 | except Exception as ex: 35 | error(str(ex)) 36 | -------------------------------------------------------------------------------- /openhasp_config_manager/const.py: -------------------------------------------------------------------------------- 1 | COMMON_FOLDER_NAME = "common" 2 | DEVICES_FOLDER_NAME = "devices" 3 | 4 | SYSTEM_SCRIPTS = ["boot.cmd", "online.cmd", "offline.cmd", "mqtt_off.cmd", "mqtt_on.cmd"] 5 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/openhasp_client/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/image_processor.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from PIL import Image 4 | from temppathlib import NamedTemporaryFile 5 | 6 | 7 | class OpenHaspImageProcessor: 8 | 9 | def image_to_rgb565(self, in_image, out_image, size: Tuple[int or None, int or None], fitscreen: bool): 10 | """ 11 | Transforms an image to rgb565 format according to LVGL requirements. 12 | :param in_image: the image to transform 13 | :param out_image: the output file 14 | :param size: the size of the output image 15 | :param fitscreen: if True, the image will be resized to fit the screen 16 | """ 17 | import struct 18 | 19 | if in_image.startswith("http"): 20 | tmp_file_container = self._fetch_image_from_url(in_image) 21 | else: 22 | # copy file to temp file 23 | tmp_file_container = NamedTemporaryFile(delete=False) 24 | import shutil 25 | shutil.copy(in_image, tmp_file_container.path) 26 | 27 | try: 28 | im = Image.open(tmp_file_container.file) 29 | original_width, original_height = im.size 30 | width, height = size 31 | 32 | if not fitscreen: 33 | width = min(w for w in [width, original_width] if w is not None and w > 0) 34 | height = min(h for h in [height, original_height] if h is not None and h > 0) 35 | im.thumbnail((height, width), Image.Resampling.LANCZOS) 36 | else: 37 | im = im.resize((height, width), Image.Resampling.LANCZOS) 38 | width, height = im.size # actual size after resize 39 | 40 | out_image.write(struct.pack("I", height << 21 | width << 10 | 4)) 41 | 42 | img = im.convert("RGB") 43 | for pix in img.getdata(): 44 | r = (pix[0] >> 3) & 0x1F 45 | g = (pix[1] >> 2) & 0x3F 46 | b = (pix[2] >> 3) & 0x1F 47 | out_image.write(struct.pack("H", (r << 11) | (g << 5) | b)) 48 | out_image.flush() 49 | 50 | im.close() 51 | img.close() 52 | finally: 53 | tmp_file_container.path.unlink() 54 | 55 | @staticmethod 56 | def _fetch_image_from_url(in_image) -> NamedTemporaryFile: 57 | import requests 58 | 59 | # consider last part of the url as filename 60 | filename = in_image.split("/")[-1] 61 | # remove url args, if any 62 | filename = filename.split("?")[0] 63 | response = requests.get(in_image, stream=True) 64 | response.raise_for_status() 65 | content_type = response.headers.get('content-type', None) 66 | content = response.content 67 | 68 | # add file extension based on content type 69 | from mimetypes import guess_extension 70 | guess = guess_extension(content_type) 71 | if guess is not None: 72 | filename = filename + guess 73 | 74 | tmp_image = NamedTemporaryFile(suffix=filename, delete=False) 75 | tmp_image.file.write(content) 76 | return tmp_image 77 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/openhasp_client/model/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/component.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import List 4 | 5 | 6 | @dataclass 7 | class Component: 8 | name: str 9 | type: str 10 | path: Path 11 | 12 | def __hash__(self): 13 | return hash((self.name, self.type, self.path)) 14 | 15 | 16 | @dataclass 17 | class TextComponent(Component): 18 | content: str 19 | 20 | def __hash__(self): 21 | return super().__hash__() 22 | 23 | 24 | @dataclass 25 | class CmdComponent(TextComponent): 26 | commands: List[str] 27 | 28 | def __hash__(self): 29 | return super().__hash__() 30 | 31 | @dataclass 32 | class JsonlComponent(TextComponent): 33 | 34 | def __hash__(self): 35 | return super().__hash__() 36 | 37 | 38 | @dataclass 39 | class RawComponent(Component): 40 | content: bytes 41 | 42 | def __hash__(self): 43 | return super().__hash__() 44 | 45 | 46 | @dataclass 47 | class ImageComponent(RawComponent): 48 | 49 | def __hash__(self): 50 | return super().__hash__() 51 | 52 | 53 | @dataclass 54 | class FontComponent(RawComponent): 55 | 56 | def __hash__(self): 57 | return super().__hash__() 58 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/openhasp_client/model/configuration/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from openhasp_config_manager.openhasp_client.model.configuration.debug_config import DebugConfig 4 | from openhasp_config_manager.openhasp_client.model.configuration.gui_config import GuiConfig 5 | from openhasp_config_manager.openhasp_client.model.configuration.hasp_config import HaspConfig 6 | from openhasp_config_manager.openhasp_client.model.configuration.http_config import HttpConfig 7 | from openhasp_config_manager.openhasp_client.model.configuration.mqtt_config import MqttConfig 8 | from openhasp_config_manager.openhasp_client.model.configuration.telnet_config import TelnetConfig 9 | from openhasp_config_manager.openhasp_client.model.configuration.wifi_config import WifiConfig 10 | from openhasp_config_manager.openhasp_client.model.openhasp_config_manager_config import OpenhaspConfigManagerConfig 11 | 12 | 13 | @dataclass 14 | class Config: 15 | openhasp_config_manager: OpenhaspConfigManagerConfig 16 | wifi: WifiConfig 17 | mqtt: MqttConfig 18 | http: HttpConfig 19 | gui: GuiConfig 20 | hasp: HaspConfig 21 | debug: DebugConfig 22 | telnet: TelnetConfig 23 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/debug_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class DebugConfig: 6 | ansi: int 7 | baud: int 8 | tele: int 9 | host: str 10 | port: int 11 | proto: int 12 | log: int 13 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/device_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from openhasp_config_manager.openhasp_client.model.configuration.screen_config import ScreenConfig 4 | 5 | 6 | @dataclass 7 | class DeviceConfig: 8 | ip: str 9 | screen: ScreenConfig 10 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/gui_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | 5 | @dataclass 6 | class GuiConfig: 7 | idle1: int 8 | idle2: int 9 | bckl: int 10 | bcklinv: int 11 | rotate: int 12 | cursor: int 13 | invert: int 14 | calibration: List[int] 15 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/hasp_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class HaspConfig: 6 | startpage: int 7 | startdim: int 8 | theme: int 9 | color1: str 10 | color2: str 11 | font: str 12 | pages: str 13 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/http_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class HttpConfig: 6 | port: int 7 | user: str 8 | password: str 9 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/mqtt_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class MqttConfig: 6 | name: str 7 | host: str 8 | port: int 9 | user: str 10 | password: str 11 | topic: "MqttTopicConfig" 12 | 13 | 14 | @dataclass 15 | class MqttTopicConfig: 16 | node: str 17 | group: str 18 | broadcast: str 19 | hass: str 20 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/screen_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class ScreenConfig: 6 | width: int 7 | height: int 8 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/telnet_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class TelnetConfig: 6 | enable: int 7 | port: int 8 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/website_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class WebsiteConfig: 6 | website: str 7 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/configuration/wifi_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class WifiConfig: 6 | ssid: str 7 | password: str 8 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/device.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import List 4 | 5 | from openhasp_config_manager.openhasp_client.model.component import JsonlComponent, CmdComponent, \ 6 | ImageComponent, FontComponent 7 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 8 | 9 | 10 | @dataclass 11 | class Device: 12 | name: str 13 | path: Path 14 | config: Config 15 | jsonl: List[JsonlComponent] 16 | cmd: List[CmdComponent] 17 | images: List[ImageComponent] 18 | fonts: List[FontComponent] 19 | output_dir: Path 20 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/model/openhasp_config_manager_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from openhasp_config_manager.openhasp_client.model.configuration.device_config import DeviceConfig 4 | 5 | 6 | @dataclass 7 | class OpenhaspConfigManagerConfig: 8 | device: DeviceConfig 9 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/mqtt_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import uuid 4 | from typing import Callable, List, Dict 5 | 6 | from aiomqtt import Client, Message 7 | 8 | 9 | class MqttClient: 10 | 11 | def __init__(self, host: str, port: int, mqtt_user: str, mqtt_password: str): 12 | self._mqtt_client_id = 'openhasp-config-manager' 13 | self._host = host 14 | self._port = port 15 | self._mqtt_user = mqtt_user 16 | self._mqtt_password = mqtt_password 17 | self._reconnect_interval_seconds = 5 18 | 19 | self._mqtt_client_task: asyncio.Task | None = None 20 | self._callbacks: Dict[str, List[callable]] = {} 21 | self.__mqtt_client: Client | None = None 22 | 23 | async def publish(self, topic: str, payload: any): 24 | """ 25 | Publish a message to a topic 26 | :param topic: topic to publish to 27 | :param payload: payload to publish 28 | """ 29 | async with self._create_mqtt_client() as client: 30 | if isinstance(payload, dict) or isinstance(payload, list): 31 | payload = json.dumps(payload) 32 | 33 | await client.publish(topic, payload=payload) 34 | 35 | async def subscribe(self, topic: str, callback: Callable): 36 | """ 37 | Subscribe to a topic and call the callback when a message is received 38 | :param topic: topic to subscribe to 39 | :param callback: function to call when a message is received 40 | """ 41 | if topic not in self._callbacks: 42 | self._callbacks[topic] = [] 43 | if callback not in self._callbacks[topic]: 44 | self._callbacks[topic].append(callback) 45 | 46 | if self._mqtt_client_task is None: 47 | await self._start_mqtt_client_task() 48 | 49 | async def cancel_callback(self, topic: str = None, callback: Callable = None): 50 | """ 51 | Cancel a single subscription callback or all callbacks for a specific topic 52 | :param topic: the topic to cancel all callbacks for 53 | :param callback: the specific callback to cancel 54 | """ 55 | if topic is None and callback is None: 56 | raise ValueError('Must specify either topic, callback or both') 57 | 58 | if topic is not None and callback is not None: 59 | self._callbacks[topic] = list(filter(lambda x: x != callback, self._callbacks[topic])) 60 | 61 | elif topic is not None and topic in self._callbacks: 62 | self._callbacks.pop(topic) 63 | else: 64 | for topic in self._callbacks.keys(): 65 | self._callbacks[topic] = list(filter(lambda x: x != callback, self._callbacks[topic])) 66 | 67 | if not any(self._callbacks.values()): 68 | await self._stop_mqtt_client_task() 69 | 70 | def _create_mqtt_client(self) -> Client: 71 | return Client( 72 | hostname=self._host, 73 | port=self._port, 74 | username=self._mqtt_user, 75 | password=self._mqtt_password, 76 | identifier=f"{self._mqtt_client_id}-{uuid.uuid4()}", 77 | ) 78 | 79 | async def _start_mqtt_client_task(self): 80 | self._mqtt_client_task = asyncio.ensure_future(self._mqtt_client_task_function()) 81 | 82 | async def _stop_mqtt_client_task(self): 83 | if self._mqtt_client_task is not None: 84 | self._mqtt_client_task.cancel() 85 | self._mqtt_client_task = None 86 | 87 | async def _mqtt_client_task_function(self): 88 | while True: 89 | try: 90 | async with self._create_mqtt_client() as client: 91 | await client.subscribe("hasp/#") 92 | async for message in client.messages: 93 | await self._handle_message(message) 94 | except asyncio.CancelledError: 95 | break 96 | except Exception as ex: 97 | # TODO: use logger instead of print 98 | print(f'Error: {ex}; Reconnecting in {self._reconnect_interval_seconds} seconds ...') 99 | await asyncio.sleep(self._reconnect_interval_seconds) 100 | 101 | async def _handle_message(self, message: Message): 102 | for topic, callbacks in self._callbacks.items(): 103 | if message.topic.matches(topic): 104 | for callback in callbacks: 105 | await callback(message.topic, message.payload) 106 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/telnet_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import re 4 | import sys 5 | 6 | from telnetlib3 import TelnetTerminalClient, open_connection, telnet_client_shell, TelnetWriterUnicode 7 | 8 | 9 | class OpenHaspTelnetClient: 10 | 11 | def __init__(self, host: str, port: int = 23, baudrate: int = 115200, user: str = 'admin', password: str = 'admin'): 12 | self._host = host 13 | self._port = port 14 | self._baudrate = baudrate 15 | self._username = user 16 | self._password = password 17 | 18 | async def shell(self): 19 | async def _shell(reader, writer): 20 | while True: 21 | outp = await reader.read(1024) 22 | 23 | if not outp: 24 | # End of File 25 | break 26 | 27 | if 'Username:' in outp: 28 | # reply all questions with 'y'. 29 | writer.write(f"{self._username}\n") 30 | elif 'Password:' in outp: 31 | writer.write(f"{self._password}\n") 32 | break 33 | 34 | # switch over to interactive shell after automatic login 35 | await telnet_client_shell(reader, writer) 36 | 37 | reader, writer = await open_connection( 38 | host=self._host, port=self._port, 39 | tspeed=(self._baudrate, self._baudrate), 40 | shell=_shell, 41 | client_factory=TelnetTerminalClient 42 | ) 43 | 44 | await writer.protocol.waiter_closed 45 | 46 | async def logs(self): 47 | async def _shell(reader, writer): 48 | login_done = False 49 | stdin, stdout = await self._make_stdio() 50 | buffer = "" 51 | while True: 52 | buffer = buffer + await reader.read(2048) 53 | 54 | if not buffer: 55 | raise EOFError("Connection closed by remote host") 56 | 57 | if login_done: 58 | buffer = buffer.replace('Prompt >', '') 59 | buffer = re.sub(r"\x1b\[\d+\w+\s*", '', buffer, flags=re.IGNORECASE | re.S) 60 | if not buffer.endswith("\r\n"): 61 | # wait for the end of the line before processing 62 | continue 63 | 64 | lines = buffer.splitlines(keepends=True) 65 | for line in lines: 66 | line = line.lstrip(" ") 67 | if not line.startswith("["): 68 | continue 69 | stdout.write(line.encode() or b":?!?:") 70 | 71 | buffer = "" 72 | continue 73 | 74 | if 'Username:' in buffer: 75 | # reply all questions with 'y'. 76 | writer.write(f"{self._username}\n") 77 | elif 'Password:' in buffer: 78 | writer.write(f"{self._password}\n") 79 | login_done = True 80 | 81 | buffer = "" 82 | # switch over to interactive shell after automatic login 83 | # await telnet_client_shell(reader, SilentTelnetWriterUnicode(writer, client=True)) 84 | 85 | reader, writer = await open_connection( 86 | host=self._host, port=self._port, 87 | tspeed=(self._baudrate, self._baudrate), 88 | shell=_shell, 89 | client_factory=TelnetTerminalClient 90 | ) 91 | 92 | await writer.protocol.waiter_closed 93 | 94 | async def _make_stdio(self): 95 | """ 96 | Return (reader, writer) pair for sys.stdin, sys.stdout. 97 | 98 | This method is a coroutine. 99 | """ 100 | reader = asyncio.StreamReader() 101 | reader_protocol = asyncio.StreamReaderProtocol(reader) 102 | 103 | # Thanks: 104 | # 105 | # https://gist.github.com/nathan-hoad/8966377 106 | # 107 | # After some experimentation, this 'sameopenfile' conditional seems 108 | # allow us to handle stdin as a pipe or a keyboard. In the case of 109 | # a tty, 0 and 1 are the same open file, we use: 110 | # 111 | # https://github.com/orochimarufan/.files/blob/master/bin/mpr 112 | write_fobj = sys.stdout 113 | if os.path.sameopenfile(0, 1): 114 | write_fobj = sys.stdin 115 | loop = asyncio.get_event_loop_policy().get_event_loop() 116 | writer_transport, writer_protocol = await loop.connect_write_pipe( 117 | asyncio.streams.FlowControlMixin, write_fobj 118 | ) 119 | 120 | writer = asyncio.StreamWriter(writer_transport, writer_protocol, None, loop) 121 | 122 | await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin) 123 | 124 | return reader, writer 125 | 126 | 127 | class SilentTelnetWriterUnicode(TelnetWriterUnicode): 128 | 129 | def __init__(self, writer, **kwargs): 130 | super().__init__( 131 | writer.transport, writer.protocol, writer.fn_encoding, encoding_errors=writer.encoding_errors, 132 | **kwargs 133 | ) 134 | 135 | def _handle_do_forwardmask(self, buf): 136 | pass 137 | 138 | def write(self, string, errors=None): 139 | pass 140 | 141 | def writelines(self, lines, errors=None): 142 | pass 143 | 144 | def echo(self, string, errors=None): 145 | pass 146 | -------------------------------------------------------------------------------- /openhasp_config_manager/openhasp_client/webservice_client.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | import orjson 4 | import requests 5 | 6 | from openhasp_config_manager.openhasp_client.model.configuration.debug_config import DebugConfig 7 | from openhasp_config_manager.openhasp_client.model.configuration.gui_config import GuiConfig 8 | from openhasp_config_manager.openhasp_client.model.configuration.hasp_config import HaspConfig 9 | from openhasp_config_manager.openhasp_client.model.configuration.http_config import HttpConfig 10 | from openhasp_config_manager.openhasp_client.model.configuration.mqtt_config import MqttConfig, MqttTopicConfig 11 | from openhasp_config_manager.openhasp_client.model.configuration.telnet_config import TelnetConfig 12 | 13 | GET = "GET" 14 | POST = "POST" 15 | DELETE = "DELETE" 16 | 17 | 18 | class WebserviceClient: 19 | 20 | def __init__(self, url: str, username: str, password: str): 21 | self._username = username 22 | self._password = password 23 | self._base_url = self._compute_base_url(url) 24 | 25 | def reboot(self): 26 | """ 27 | Request a reboot 28 | """ 29 | self._do_request( 30 | method=GET, 31 | url=self._base_url + "reboot", 32 | ) 33 | 34 | def set_hasp_config(self, config: HaspConfig): 35 | """ 36 | Set the "HASP" configuration 37 | :param config: the configuration to set 38 | """ 39 | data = { 40 | "startpage": config.startpage, 41 | "startdim": config.startdim, 42 | "theme": config.theme, 43 | "color1": config.color1, 44 | "color2": config.color2, 45 | "font": config.font, 46 | "pages": config.pages, 47 | "save": "hasp" 48 | } 49 | 50 | # ignore keys with None value 51 | data = {k: v for k, v in data.items() if v is not None} 52 | 53 | self._do_request( 54 | method=POST, 55 | url=self._base_url + "config", 56 | data=data, 57 | ) 58 | 59 | def get_http_config(self) -> HttpConfig: 60 | data = self._do_request( 61 | method=GET, 62 | url=self._base_url + "api/config/http/", 63 | ) 64 | 65 | return HttpConfig( 66 | port=data["port"], 67 | user=data["user"], 68 | password=data["pass"], 69 | ) 70 | 71 | def set_http_config(self, config: HttpConfig): 72 | """ 73 | Set the HTTP configuration 74 | :param config: the configuration to set 75 | """ 76 | data = { 77 | "user": config.user, 78 | "pass": config.password, 79 | "save": "http" 80 | } 81 | 82 | # ignore keys with None value 83 | data = {k: v for k, v in data.items() if v is not None} 84 | 85 | self._do_request( 86 | method=POST, 87 | url=self._base_url + "config", 88 | data=data, 89 | ) 90 | 91 | def get_mqtt_config(self) -> MqttConfig: 92 | data = self._do_request( 93 | method=GET, 94 | url=self._base_url + "api/config/mqtt/", 95 | ) 96 | 97 | return MqttConfig( 98 | name=data["name"], 99 | host=data["host"], 100 | port=data["port"], 101 | user=data["user"], 102 | password=data["pass"], 103 | topic=MqttTopicConfig( 104 | node=data["topic"]["node"], 105 | group=data["topic"]["group"], 106 | broadcast=data["topic"]["broadcast"], 107 | hass=data["topic"]["hass"], 108 | ) 109 | ) 110 | 111 | def set_mqtt_config(self, config: MqttConfig): 112 | """ 113 | Set the MQTT configuration 114 | :param config: the configuration to set 115 | """ 116 | data = { 117 | "name": config.name, 118 | "topic": { 119 | "node": config.topic.node, 120 | "group": config.topic.group, 121 | "broadcast": config.topic.broadcast, 122 | "hass": config.topic.hass, 123 | }, 124 | "host": config.host, 125 | "port": config.port, 126 | "user": config.user, 127 | "pass": config.password, 128 | "save": "mqtt" 129 | } 130 | 131 | # ignore keys with None value 132 | data = {k: v for k, v in data.items() if v is not None} 133 | 134 | self._do_request( 135 | method=POST, 136 | url=self._base_url + "config", 137 | data=data, 138 | ) 139 | 140 | def get_gui_config(self) -> GuiConfig: 141 | data = self._do_request( 142 | method=GET, 143 | url=self._base_url + "api/config/gui/", 144 | ) 145 | 146 | return GuiConfig( 147 | idle1=data["idle1"], 148 | idle2=data["idle2"], 149 | rotate=data["rotate"], 150 | cursor=data["cursor"], 151 | bckl=data["bckl"], 152 | bcklinv=data["bcklinv"], 153 | invert=data["invert"], 154 | calibration=data["calibration"] 155 | ) 156 | 157 | def set_gui_config(self, config: GuiConfig): 158 | """ 159 | Set the GUI configuration 160 | :param config: the configuration to set 161 | """ 162 | data = { 163 | "idle1": config.idle1, 164 | "idle2": config.idle2, 165 | "rotate": config.rotate, 166 | "cursor": config.cursor, 167 | "bckl": config.bckl, 168 | "save": "gui" 169 | } 170 | 171 | # ignore keys with None value 172 | data = {k: v for k, v in data.items() if v is not None} 173 | 174 | self._do_request( 175 | method=POST, 176 | url=self._base_url + "config", 177 | data=data, 178 | ) 179 | 180 | def set_telnet_config(self, config: TelnetConfig): 181 | """ 182 | Set the Debug configuration 183 | :param config: the configuration to set 184 | """ 185 | data = { 186 | "enable": config.enable, 187 | "port": config.port, 188 | "save": "telnet" 189 | } 190 | 191 | # ignore keys with None value 192 | data = {k: v for k, v in data.items() if v is not None} 193 | 194 | self._do_request( 195 | method=POST, 196 | url=self._base_url + "config", 197 | data=data, 198 | ) 199 | 200 | def set_debug_config(self, config: DebugConfig): 201 | """ 202 | Set the Debug configuration 203 | :param config: the configuration to set 204 | """ 205 | data = { 206 | "ansi": config.ansi, 207 | "baud": config.baud, 208 | "tele": config.tele, 209 | "host": config.host, 210 | "port": config.port, 211 | "proto": config.proto, 212 | "log": config.log, 213 | "save": "debug" 214 | } 215 | 216 | # ignore keys with None value 217 | data = {k: v for k, v in data.items() if v is not None} 218 | 219 | self._do_request( 220 | method=POST, 221 | url=self._base_url + "config", 222 | data=data, 223 | ) 224 | 225 | def upload_files(self, files: Dict[str, bytes]): 226 | """ 227 | Upload a collection of files 228 | :param files: "target file name"->"file content" mapping 229 | """ 230 | for name, content in files.items(): 231 | self.upload_file(name, content) 232 | 233 | def upload_file(self, name: str, content: bytes): 234 | """ 235 | Upload a single file 236 | :param name: the target name of the file on the device 237 | :param content: the file content 238 | """ 239 | self._do_request( 240 | method=POST, 241 | url=self._base_url + "edit", 242 | files={ 243 | f"{name}": content 244 | }, 245 | ) 246 | 247 | def get_files(self) -> List[str]: 248 | """ 249 | Retrieve a list of all file on the device 250 | :return: a list of all files on the device 251 | """ 252 | response = self._do_request( 253 | method=GET, 254 | url=self._base_url + "list?dir=/", 255 | ) 256 | response_data = orjson.loads(response.decode('utf-8')) 257 | 258 | files = list(filter(lambda x: x["type"] == "file", response_data)) 259 | file_names = list(map(lambda x: x["name"], files)) 260 | return file_names 261 | 262 | def get_file_content(self, file_name: str) -> bytes or None: 263 | try: 264 | response = self._do_request( 265 | method=GET, 266 | url=self._base_url + file_name, 267 | ) 268 | response_data = response 269 | return response_data 270 | except Exception as ex: 271 | return None 272 | 273 | def delete_file(self, file_name: str): 274 | """ 275 | Delete a file on the device 276 | :param file_name: the name of the file 277 | """ 278 | self._do_request( 279 | method=DELETE, 280 | url=self._base_url + "edit", 281 | data={ 282 | "path": "/" + file_name 283 | }, 284 | ) 285 | 286 | def take_screenshot(self) -> bytes: 287 | """ 288 | Requests a screenshot from the device. 289 | :return: 290 | """ 291 | return self._do_request( 292 | method=GET, 293 | url=self._base_url + "screenshot", 294 | params={ 295 | "q": "0" 296 | }, 297 | stream=True, 298 | ) 299 | 300 | def _do_request(self, method: str = GET, url: str = "/", params: dict = None, 301 | json: any = None, files: any = None, data: any = None, 302 | headers: dict = None, 303 | stream: bool = None) -> list or dict or None: 304 | """ 305 | Executes a http request based on the given parameters 306 | 307 | :param method: the method to use (GET, POST) 308 | :param url: the url to use 309 | :param params: query parameters that will be appended to the url 310 | :param json: request body 311 | :param headers: custom headers 312 | :return: the response parsed as a json 313 | """ 314 | _headers = { 315 | } 316 | if headers is not None: 317 | _headers.update(headers) 318 | 319 | response = requests.request( 320 | method, url, headers=_headers, 321 | params=params, 322 | json=json, files=files, 323 | data=data, 324 | auth=(self._username, self._password), 325 | timeout=5, 326 | stream=stream, 327 | ) 328 | 329 | response.raise_for_status() 330 | if len(response.content) > 0: 331 | if "application/json" in response.headers.get("Content-Type", ""): 332 | return response.json() 333 | else: 334 | return response.content 335 | 336 | @staticmethod 337 | def _compute_base_url(url: str) -> str: 338 | if not url.startswith("http://"): 339 | url = "http://" + url 340 | if not url.endswith("/"): 341 | url += "/" 342 | return url 343 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/processing/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/processing/device_processor.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Dict, Any 3 | 4 | import orjson 5 | 6 | from openhasp_config_manager.openhasp_client.model.component import Component, JsonlComponent, CmdComponent, \ 7 | TextComponent, RawComponent 8 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 9 | from openhasp_config_manager.openhasp_client.model.device import Device 10 | from openhasp_config_manager.processing.jsonl import JsonlObjectProcessor 11 | from openhasp_config_manager.processing.preprocessor.jsonl_preprocessor import JsonlPreProcessor 12 | from openhasp_config_manager.processing.template_rendering import render_dict_recursive, _render_template 13 | from openhasp_config_manager.processing.variables import VariableManager 14 | from openhasp_config_manager.util import merge_dict_recursive 15 | 16 | 17 | class DeviceProcessor: 18 | """ 19 | A device specific processor, used to transform any templates 20 | present within the configuration files. 21 | """ 22 | 23 | def __init__(self, device: Device, jsonl_object_processors: List[JsonlObjectProcessor], 24 | variable_manager: VariableManager): 25 | self._device = device 26 | 27 | self._jsonl_components: List[JsonlComponent] = [] 28 | self._others: List[Component] = [] 29 | 30 | self._jsonl_preprocessor = JsonlPreProcessor() 31 | self._jsonl_object_processors = jsonl_object_processors 32 | self._variable_manager = variable_manager 33 | 34 | def add_component(self, component: Component): 35 | if isinstance(component, JsonlComponent): 36 | self._add_jsonl(component) 37 | else: 38 | self._add_other(component) 39 | 40 | def _add_other(self, component: Component): 41 | self._others.append(component) 42 | 43 | def _add_jsonl(self, component: JsonlComponent): 44 | self._jsonl_components.append(component) 45 | 46 | def normalize(self, device: Device, component: Component) -> str | bytes: 47 | if isinstance(component, JsonlComponent): 48 | template_vars: Dict[str, any] = self._compute_jsonl_template_variables(device, component) 49 | return self._normalize_jsonl(self._device.config, component, template_vars) 50 | elif isinstance(component, CmdComponent): 51 | template_vars: Dict[str, any] = {} 52 | return self._normalize_cmd(self._device.config, component, template_vars) 53 | elif isinstance(component, TextComponent): 54 | # no changes necessary 55 | return component.content 56 | elif isinstance(component, RawComponent): 57 | # no changes necessary 58 | return component.content 59 | elif isinstance(component, Component): 60 | raise NotImplementedError(f"Unknown component type: {component.type}") 61 | else: 62 | raise AssertionError(f"Received unexpected input: {component}") 63 | 64 | def _normalize_jsonl(self, config: Config, component: JsonlComponent, template_vars: Dict[str, any]) -> str: 65 | normalized_objects: List[str] = [] 66 | 67 | objects = self._jsonl_preprocessor.split_jsonl_objects(component.content) 68 | for ob in objects: 69 | preprocessed = self._jsonl_preprocessor.cleanup_object_for_json_parsing(ob) 70 | p = self._normalize_jsonl_object(config, component, preprocessed, template_vars) 71 | normalized_objects.append(p) 72 | 73 | return "\n".join(normalized_objects) 74 | 75 | def _normalize_jsonl_object( 76 | self, config: Config, component: JsonlComponent, ob: str, template_vars: Dict[str, any] 77 | ) -> str: 78 | parsed = orjson.loads(ob) 79 | 80 | normalized_object = {} 81 | for key, value in parsed.items(): 82 | if isinstance(value, str): 83 | rendered_value = _render_template(value, template_vars) 84 | normalized_object[key] = rendered_value 85 | else: 86 | normalized_object[key] = value 87 | 88 | processed = normalized_object 89 | for processor in self._jsonl_object_processors: 90 | processed = processor.process(processed, config, template_vars) 91 | 92 | return json.dumps(processed, indent=None) 93 | 94 | def _normalize_cmd(self, _device_config, component: CmdComponent, template_vars: Dict[str, any]) -> str: 95 | return _render_template(component.content, template_vars) 96 | 97 | def _compute_jsonl_template_variables(self, device: Device, component: Component) -> Dict[str, Any]: 98 | """ 99 | Computes a map of "variable" -> "evaluated value in the given path context" for the given component. 100 | 101 | :param component: the component to use as a context for evaluating template variables 102 | :return: map of "variable" -> "evaluated value in the given path context" 103 | """ 104 | result = {} 105 | 106 | # object specific variables for all components that the processor currently knows about 107 | sorted_components = list(sorted(self._jsonl_components, key=lambda x: x == component)) 108 | assert sorted_components[-1] == component 109 | for c in sorted_components: 110 | jsonl_objects = self._jsonl_preprocessor.split_jsonl_objects(c.content) 111 | 112 | component_template_vars = self._variable_manager.get_vars(c.path) 113 | component_template_vars["device"] = self._device.config.openhasp_config_manager.device 114 | if "/common/" in str(c.path): 115 | # for common components, also include device specific, top-level variables 116 | device_vars = self._variable_manager.get_vars(device.path) 117 | component_template_vars = merge_dict_recursive(component_template_vars, device_vars) 118 | 119 | c_result = self._compute_object_map(jsonl_objects) 120 | merged_template_variables = merge_dict_recursive(result, component_template_vars) 121 | merged_template_variables = merge_dict_recursive(merged_template_variables, component_template_vars) 122 | 123 | rendered_template_vars = render_dict_recursive( 124 | input=c_result, 125 | template_vars=merge_dict_recursive(merged_template_variables, c_result) 126 | ) 127 | rendered_template_vars = merge_dict_recursive(rendered_template_vars, merged_template_variables) 128 | 129 | result = merge_dict_recursive(result, rendered_template_vars) 130 | 131 | if result is None: 132 | raise AssertionError("Unexpected None value") 133 | return result 134 | 135 | def _compute_object_map(self, jsonl_objects: List[str]) -> Dict[str, dict]: 136 | """ 137 | :param jsonl_objects: 138 | :return: a map 139 | """ 140 | result = {} 141 | for o in jsonl_objects: 142 | o = self._jsonl_preprocessor.cleanup_object_for_json_parsing(o) 143 | parsed_object = orjson.loads(o) 144 | object_key = self._compute_object_key(parsed_object) 145 | result[object_key] = parsed_object 146 | return result 147 | 148 | @staticmethod 149 | def _compute_object_key(jsonl_object: Dict) -> str: 150 | return f"p{jsonl_object.get('page', '0')}b{jsonl_object.get('id', '0')}" 151 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/jsonl/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Dict 3 | 4 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 5 | 6 | 7 | class JsonlObjectProcessor(metaclass=abc.ABCMeta): 8 | """ 9 | Used to process .jsonl files to add support for additional features. 10 | """ 11 | 12 | @abc.abstractmethod 13 | def process(self, input: Dict, config: Config, template_vars: Dict[str, any]) -> Dict: 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/jsonl/jsonl.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Dict 4 | 5 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 6 | from openhasp_config_manager.processing.jsonl import JsonlObjectProcessor 7 | 8 | LOGGER = logging.getLogger(__name__) 9 | LOGGER.setLevel(logging.DEBUG) 10 | 11 | 12 | class ObjectDimensionsProcessor(JsonlObjectProcessor): 13 | """ 14 | Used to process .jsonl files to add support for additional features. 15 | """ 16 | PERCENTAGE_REGEX_PATTERN = re.compile(r"^\d+(\.\d+)?%$") 17 | 18 | def process( 19 | self, input: Dict, config: Config, template_vars: Dict[str, any] 20 | ) -> Dict: 21 | result: Dict[str, any] = {} 22 | for key, value in input.items(): 23 | if isinstance(value, str) and re.match("[xywh]", key) and re.match(self.PERCENTAGE_REGEX_PATTERN, value): 24 | numeric_value = self._parse_percentage(value) 25 | 26 | total_width = config.openhasp_config_manager.device.screen.width 27 | total_height = config.openhasp_config_manager.device.screen.height 28 | 29 | if key in ["x", "w"]: 30 | total = total_width 31 | else: 32 | total = total_height 33 | 34 | result[key] = self._percentage_of(numeric_value, total) 35 | else: 36 | result[key] = value 37 | 38 | # normalize value types, in case of templates 39 | for key, value in result.items(): 40 | try: 41 | match = re.match( 42 | "^(page|id|x|y|w|h|text_font|value_font|radius|pad_.+|margin_.+|border_width|min|max|prev|next).*$", 43 | string=key, 44 | ) 45 | 46 | if match and isinstance(value, str): 47 | result[key] = int(float(value)) 48 | except Exception as ex: 49 | LOGGER.exception(ex) 50 | print(f"{key}: {value}: {ex}, {input}") 51 | 52 | return result 53 | 54 | @staticmethod 55 | def _percentage_of(percentage: float, total: int) -> int: 56 | """ 57 | :param percentage: 0..100 58 | :param total: value for a percentage of 100 59 | :return: value according to input 60 | """ 61 | return int((percentage * total) / 100) 62 | 63 | @staticmethod 64 | def _parse_percentage(value: str) -> float: 65 | return float(str(value).replace('%', '')) 66 | 67 | 68 | class ObjectThemeProcessor(JsonlObjectProcessor): 69 | """ 70 | Used to apply default theming from "theme.obj.*" values 71 | """ 72 | 73 | def process( 74 | self, input: Dict, config: Config, template_vars: Dict[str, any] 75 | ) -> Dict: 76 | obj_key = input.get("obj", None) 77 | if obj_key is None: 78 | return input 79 | 80 | theme_values = template_vars.get("theme", {}).get("obj", {}).get(obj_key, {}) 81 | for key, value in theme_values.items(): 82 | if key not in input: 83 | input[key] = value 84 | 85 | return input 86 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/preprocessor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/processing/preprocessor/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/processing/preprocessor/jsonl_preprocessor.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | 5 | class JsonlPreProcessor: 6 | 7 | def __init__(self): 8 | pass 9 | 10 | def split_jsonl_objects(self, original_content: str) -> List[str]: 11 | pattern_to_find_beginning_of_objects = re.compile(r'^(?!\n)\s*(?=\{)', re.RegexFlag.MULTILINE) 12 | parts = pattern_to_find_beginning_of_objects.split(original_content) 13 | 14 | result = [] 15 | for part in parts: 16 | part = part.strip() 17 | 18 | # edge case for first match 19 | if "}" not in part: 20 | continue 21 | 22 | # ignore everything after the last closing bracket 23 | part = part.rsplit("}", maxsplit=1)[0] + "}" 24 | 25 | result.append(part) 26 | 27 | return result 28 | 29 | def cleanup_object_for_json_parsing(self, content: str) -> str: 30 | """ 31 | Prepares the given content for json parsing (if possible) 32 | :param content: original content 33 | :return: cleaned up content 34 | """ 35 | result = self._remove_comments(content).strip() 36 | result = self._remove_trailing_comma_of_last_property(result) 37 | return result 38 | 39 | def _remove_comments(self, content: str) -> str: 40 | """ 41 | Removes comments indicated by "//" 42 | :param content: original content 43 | :return: modified content without comments 44 | """ 45 | result_lines = [] 46 | for line in content.splitlines(): 47 | quote_count = 0 48 | new_line = [] 49 | i = 0 50 | while i < len(line): 51 | char = line[i] 52 | if char == '"': 53 | quote_count += 1 54 | if char == '/' and i + 1 < len(line) and line[i + 1] == '/' and quote_count % 2 == 0: 55 | break 56 | new_line.append(char) 57 | i += 1 58 | cleaned_line = "".join(new_line).strip() 59 | if cleaned_line: # Only append non-empty lines 60 | result_lines.append(cleaned_line) 61 | result = "\n".join(result_lines) 62 | return result.strip() 63 | 64 | def _remove_trailing_comma_of_last_property(self, content: str) -> str: 65 | """ 66 | Removes the "," of the last property within the object. 67 | :param content: original content 68 | :return: modified content 69 | """ 70 | result = content 71 | matches = re.findall(r",\s*}", content, flags=re.MULTILINE) 72 | if matches is not None: 73 | for match in matches: 74 | result = result.replace(match, match[1:]) 75 | 76 | return result 77 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/template_rendering.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Dict, List 4 | 5 | import jinja2 6 | from jinja2 import BaseLoader 7 | 8 | from openhasp_config_manager.ui.util import echo, error 9 | 10 | LOGGER = logging.getLogger(__name__) 11 | LOGGER.setLevel(logging.DEBUG) 12 | 13 | 14 | def render_dict_recursive( 15 | input: Dict, 16 | template_vars: Dict, 17 | result_key_path: List[str] = None, 18 | ) -> Dict[str, any]: 19 | """ 20 | Recursively called function to resolve templates within the given input dict 21 | :param input: the dict which (possibly) contains templates 22 | :param template_vars: a map specifying the value of template variables # TODO: this might need context as well... right? 23 | :param result_key_path: 24 | :return: 25 | """ 26 | if result_key_path is None: 27 | result_key_path = [] 28 | 29 | result = {} 30 | 31 | finished = False 32 | progress = 0 33 | last_pending = None 34 | while not finished: 35 | pending = [] 36 | finished = True 37 | keys = list(input.keys()) 38 | for key in keys: 39 | value = input[key] 40 | # key 41 | rendered_key = key 42 | key_undefined = False 43 | if "{{" in key: 44 | try: 45 | rendered_key = _render_template(key, template_vars) 46 | key_undefined = _has_undeclared_variables(rendered_key) 47 | except Exception as ex: 48 | error(f"Undefined key: {key_undefined}, value: {key}") 49 | key_undefined = True 50 | 51 | # value 52 | value_undefined = False 53 | rendered_value = value 54 | if isinstance(value, dict) and rendered_key is not None and not key_undefined: 55 | rendered_value = render_dict_recursive( 56 | input=value, 57 | template_vars=template_vars, 58 | result_key_path=result_key_path + [rendered_key] 59 | ) 60 | elif isinstance(value, list): 61 | try: 62 | rendered_value = list(map(lambda x: _render_template(x, template_vars), value)) 63 | value_undefined = any(map(lambda x: _has_undeclared_variables(x), rendered_value)) 64 | except Exception as ex: 65 | # print(f"Undefined value: {value_undefined}, value: {value}") 66 | value_undefined = True 67 | elif isinstance(value, str): 68 | try: 69 | rendered_value = _render_template(value, template_vars) 70 | value_undefined = _has_undeclared_variables(rendered_value) 71 | except Exception as ex: 72 | # print(f"Undefined value: {value_undefined}, value: {value}") 73 | value_undefined = True 74 | 75 | if key_undefined: 76 | pending.append(key) 77 | elif key in pending: 78 | pending.remove(key) 79 | if value_undefined: 80 | pending.append(f"{key}__value") 81 | elif f"{key}__value" in pending: 82 | pending.remove(f"{key}__value") 83 | 84 | if key_undefined or value_undefined: 85 | # still has undefined keys 86 | # print(f"Undefined keys: {key_undefined}, Undefined values: {value_undefined}") 87 | finished = False 88 | else: 89 | # add it to the template_vars data at the correct location 90 | # which allows later iterations to render the template correctly 91 | tmp = template_vars 92 | for idx, p in enumerate(result_key_path): 93 | if p not in tmp.keys(): 94 | tmp[p] = {} 95 | tmp = tmp[p] 96 | tmp.pop(key, None) 97 | tmp[rendered_key] = rendered_value 98 | 99 | if rendered_key not in result or result[rendered_key] != rendered_value: 100 | progress += 1 101 | 102 | # create the resulting (sub-)dictionary 103 | result[rendered_key] = rendered_value 104 | 105 | finished = finished and True 106 | 107 | if len(pending) <= 0: 108 | return result 109 | elif pending == last_pending: 110 | echo(f"No progression while rendering templates, unable to render: {pending}") 111 | return result 112 | else: 113 | last_pending = pending 114 | 115 | return result 116 | 117 | 118 | _j2_env = jinja2.Environment(loader=BaseLoader(), undefined=jinja2.DebugUndefined) 119 | _template_cache = {} 120 | 121 | 122 | def _render_template(content: str, template_vars: Dict[str, str]) -> str: 123 | inner_templates = re.findall(r"\{\{.+}}", content[2:-2]) 124 | for inner_template in inner_templates: 125 | if inner_template != content[2:-2]: 126 | rendered = _render_template(inner_template, template_vars) 127 | content = content.replace(inner_template, rendered) 128 | try: 129 | if content not in _template_cache: 130 | _template_cache[content] = _j2_env.from_string(content) 131 | template = _template_cache[content] 132 | rendered = template.render(template_vars) 133 | return rendered 134 | except Exception as ex: 135 | LOGGER.exception(ex) 136 | print(f"{ex}: \n\n{content}\n\n {template_vars}") 137 | raise ex 138 | 139 | 140 | _template_ast_cache = {} 141 | 142 | 143 | def _has_undeclared_variables(rendered_value: str): 144 | from jinja2.meta import find_undeclared_variables 145 | 146 | if rendered_value not in _template_ast_cache: 147 | _template_ast_cache[rendered_value] = _j2_env.parse(rendered_value) 148 | ast = _template_ast_cache[rendered_value] 149 | 150 | return find_undeclared_variables(ast) 151 | -------------------------------------------------------------------------------- /openhasp_config_manager/processing/variables.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Dict, Any 3 | 4 | import yaml 5 | from yaml import Loader 6 | 7 | from openhasp_config_manager.util import contains_nested_dict_key, merge_dict_recursive 8 | 9 | 10 | class VariableManager: 11 | """ 12 | Used to manage variable definitions found in YAML files inside the configuration 13 | directory. 14 | """ 15 | 16 | def __init__(self, cfg_root: Path): 17 | self._cfg_root: Path = Path(cfg_root) 18 | self._path_vars: Dict[str, Dict] = {} 19 | 20 | def read(self): 21 | """ 22 | Reads all variable definitions from the configuration directory. 23 | """ 24 | self._path_vars = self._read(self._cfg_root) 25 | 26 | def add_var(self, key: str, value: any, path: Path = None): 27 | """ 28 | Registers a variable to a path 29 | :param key: the variable key 30 | :param value: the variable value 31 | :param path: the path this variable should apply to 32 | """ 33 | self.add_vars({key: value}, path) 34 | 35 | def add_vars(self, vars: Dict[str, Any], path: Path = None): 36 | """ 37 | Registers a set of variables to a path 38 | :param vars: the variables 39 | :param path: the path the variables should apply to 40 | """ 41 | if path is None: 42 | path = self._cfg_root 43 | 44 | relative_path = Path(self._cfg_root, path.relative_to(self._cfg_root)) 45 | if relative_path.is_file(): 46 | relative_path = relative_path.parent 47 | relative_path_str = str(relative_path) 48 | 49 | if relative_path_str not in self._path_vars.keys(): 50 | self._path_vars[relative_path_str] = {} 51 | current_vars = self._path_vars.get(relative_path_str, {}) 52 | combined = merge_dict_recursive(self._path_vars[relative_path_str], current_vars) 53 | combined = merge_dict_recursive(combined, vars) 54 | self._path_vars[relative_path_str] = combined 55 | 56 | def get_vars(self, path: Path) -> [str, Any]: 57 | """ 58 | Returns the variable definitions and values for a given path. 59 | 60 | The resulting map will be a combination of all variables available within each 61 | of the directories along the given path. Variable definitions found in a lower (longer) path 62 | will override the ones that might be present higher up the path. 63 | 64 | :param path: the path context to use for variable evaluation 65 | :return: a map of "variable name" -> "variable value given the path context" 66 | """ 67 | path_vars = self._get_vars_for_path(path) 68 | return path_vars 69 | 70 | def _get_vars_for_path(self, path: Path) -> Dict[str, Any]: 71 | """ 72 | Returns the variable definitions and values for a given path. 73 | 74 | The resulting map will be a combination of all variables available within each 75 | of the directories along the given path. Variable definitions found in a lower (longer) path 76 | will override the ones that might be present higher up the path. 77 | 78 | :param path: the path context to use for variable evaluation 79 | :return: a map of "variable name" -> "variable value given the path context" 80 | """ 81 | result = {} 82 | 83 | toplevel_path = self._cfg_root 84 | relative_path = Path(toplevel_path, path.relative_to(toplevel_path)) 85 | if relative_path.is_file(): 86 | relative_path = relative_path.parent 87 | 88 | current_path = None 89 | relative_paths = relative_path.parts 90 | for subfolder in relative_paths: 91 | if current_path is None: 92 | current_path = Path(subfolder) 93 | else: 94 | current_path = Path(current_path, subfolder) 95 | current_path_str = str(current_path) 96 | result = merge_dict_recursive(result, self._path_vars.get(current_path_str, {})) 97 | 98 | return result 99 | 100 | def _read(self, path: Path) -> Dict[str, Dict]: 101 | """ 102 | Reads all "*.yaml" variable definition files in the given path 103 | :return: a map of "path -> dict of variables" 104 | """ 105 | result = {} 106 | for toplevel_path in [path]: 107 | for p in list(toplevel_path.glob('**/**')): 108 | if not p.is_dir(): 109 | continue 110 | 111 | if p.name.startswith("."): 112 | continue 113 | 114 | path_vars = self._create_vars_dict_for_path(p) 115 | 116 | if contains_nested_dict_key(path_vars, "items"): 117 | # TODO: to avoid this, variables could be accessed by only exposing them 118 | # to jinja2 templates via a custom function like f.ex. "vars('my.key.items.a')". 119 | # This may be cumbersome to use though... 120 | raise AssertionError( 121 | "Variables contain key 'items' which conflicts with the built-in function of jinja2. Please choose a different name.") 122 | 123 | path_str = str(p) 124 | if path_str not in result: 125 | result[path_str] = {} 126 | result[path_str] = merge_dict_recursive(result[path_str], self._create_vars_dict_for_path(p)) 127 | 128 | return result 129 | 130 | def _create_vars_dict_for_path(self, path: Path) -> Dict[str, Dict]: 131 | """ 132 | Creates a dictionary containing all the variables for a given path by reading 133 | the yaml files in this path. This does _not_ take the path hierarchy 134 | into account. Only variables in the exact path will be returned in the result. 135 | :param path: the path to use as a context 136 | :return: a variable dictionary 137 | """ 138 | result = {} 139 | path_var_files = list(path.glob("*.yaml")) 140 | for file in path_var_files: 141 | if not file.is_file(): 142 | continue 143 | 144 | data = self._load_var_file(file) 145 | 146 | if data is None: 147 | # file is empty 148 | continue 149 | else: 150 | result = merge_dict_recursive(result, data) 151 | 152 | return result 153 | 154 | @staticmethod 155 | def _load_var_file(file: Path) -> Dict: 156 | content = file.read_text() 157 | return yaml.load(content, Loader=Loader) 158 | -------------------------------------------------------------------------------- /openhasp_config_manager/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/ui/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/ui/qt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/openhasp_config_manager/ui/qt/__init__.py -------------------------------------------------------------------------------- /openhasp_config_manager/ui/qt/device_list.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from PyQt6 import QtCore 4 | from PyQt6.QtWidgets import QWidget, QPushButton, QHBoxLayout 5 | 6 | from openhasp_config_manager.openhasp_client.model.device import Device 7 | from openhasp_config_manager.ui.qt.util import clear_layout 8 | 9 | 10 | class DeviceListWidget(QWidget): 11 | deviceSelected = QtCore.pyqtSignal(Device) 12 | 13 | def __init__(self, devices): 14 | super().__init__() 15 | self.devices = devices 16 | self.layout = QHBoxLayout(self) 17 | self.create_entries() 18 | 19 | def set_devices(self, devices: List[Device]): 20 | self.devices = devices 21 | self.clear_entries() 22 | self.create_entries() 23 | 24 | def clear_entries(self): 25 | clear_layout(self.layout) 26 | 27 | def create_entries(self): 28 | for device in sorted(self.devices, key=lambda d: d.name): 29 | button = QPushButton(device.name) 30 | button.clicked.connect(lambda _, d=device: self.on_device_label_clicked(d)) 31 | self.layout.addWidget(button) 32 | 33 | def on_device_label_clicked(self, device): 34 | print(f"Clicked on device: {device.name}") 35 | self.deviceSelected.emit(device) 36 | -------------------------------------------------------------------------------- /openhasp_config_manager/ui/qt/file_browser.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from pathlib import Path 3 | 4 | from PyQt6.QtWidgets import QTreeWidget, QAbstractItemView, QTreeWidgetItem, QSizePolicy 5 | 6 | from openhasp_config_manager.ui.qt.util import clear_layout 7 | 8 | 9 | class FileBrowserWidget(QTreeWidget): 10 | def __init__(self, cfg_root: Path): 11 | super().__init__() 12 | self.cfg_root = cfg_root 13 | self.create_layout() 14 | self.create_entries() 15 | 16 | def create_layout(self): 17 | self.setHeaderLabel("File Browser") 18 | self.setAlternatingRowColors(True) 19 | self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) 20 | self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) 21 | self.setAnimated(False) 22 | self.setAllColumnsShowFocus(True) 23 | 24 | self.itemClicked.connect(self.on_item_clicked) 25 | 26 | size_policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) 27 | self.setSizePolicy(size_policy) 28 | 29 | def clear_entries(self): 30 | clear_layout(self.layout) 31 | 32 | def create_entries(self): 33 | self._load_file_structure(self.cfg_root, self) 34 | 35 | def _load_file_structure(self, startpath: Path, tree: QTreeWidget): 36 | """ 37 | Load Project structure tree 38 | :param startpath: 39 | :param tree: 40 | :return: 41 | """ 42 | import os 43 | from PyQt6.QtWidgets import QTreeWidgetItem 44 | from PyQt6.QtGui import QIcon 45 | startpath.is_dir() 46 | for element in sorted(pathlib.Path(startpath).iterdir(), key=lambda x: (not x.is_dir(), x.name)): 47 | path_info = Path(startpath, element) 48 | parent_itm = QTreeWidgetItem(tree, [os.path.basename(element)]) 49 | if os.path.isdir(path_info): 50 | self._load_file_structure(path_info, parent_itm) 51 | parent_itm.setIcon(0, QIcon('assets/folder.ico')) 52 | else: 53 | parent_itm.setIcon(0, QIcon('assets/file.ico')) 54 | 55 | def on_item_clicked(self, it: QTreeWidgetItem, col: int): 56 | print(it, col, it.text(col)) 57 | -------------------------------------------------------------------------------- /openhasp_config_manager/ui/qt/main.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget, QMainWindow, QVBoxLayout 2 | 3 | from openhasp_config_manager.manager import ConfigManager 4 | from openhasp_config_manager.openhasp_client.model.component import CmdComponent 5 | from openhasp_config_manager.openhasp_client.model.device import Device 6 | from openhasp_config_manager.ui.qt.device_list import DeviceListWidget 7 | from openhasp_config_manager.ui.qt.file_browser import FileBrowserWidget 8 | from openhasp_config_manager.ui.qt.page_layout_editor import PageLayoutEditorWidget, OpenHaspDevicePagesData 9 | 10 | 11 | class MainWindow(QMainWindow): 12 | def __init__(self, config_manager: ConfigManager): 13 | super().__init__() 14 | self.config_manager = config_manager 15 | 16 | self.setWindowTitle("openhasp-config-manager") 17 | 18 | self.create_basic_layout() 19 | self.load_plates() 20 | 21 | def create_basic_layout(self): 22 | self.container = QWidget() 23 | self.layout = QVBoxLayout() 24 | self.container.setLayout(self.layout) 25 | 26 | self.device_list_widget = DeviceListWidget(devices=[]) 27 | self.device_list_widget.deviceSelected.connect(self.on_device_selected) 28 | self.layout.addWidget(self.device_list_widget) 29 | 30 | self.file_browser_widget = FileBrowserWidget(self.config_manager.cfg_root) 31 | self.layout.addWidget(self.file_browser_widget) 32 | 33 | self.page_layout_editor_widget = PageLayoutEditorWidget( 34 | config_manager=self.config_manager 35 | ) 36 | self.layout.addWidget(self.page_layout_editor_widget) 37 | 38 | self.setCentralWidget(self.container) 39 | 40 | def load_plates(self): 41 | self.devices = self.config_manager.analyze() 42 | self.device_list_widget.set_devices(self.devices) 43 | 44 | def on_device_selected(self, device: Device): 45 | self.select_device(device) 46 | 47 | def select_device(self, device): 48 | self.device = device 49 | 50 | # setup sample page layout editor for testing 51 | # device_processor = self.config_manager.create_device_processor(device) 52 | # device_validator = self.config_manager.create_device_validator(device) 53 | self.relevant_components = self.config_manager.find_relevant_components(device) 54 | boot_cmd_component: CmdComponent = next( 55 | filter(lambda x: x.name == "boot.cmd", self.relevant_components), None) 56 | self.select_cmd_component(device, boot_cmd_component) 57 | 58 | def select_cmd_component(self, device: Device, cmd_component: CmdComponent): 59 | """ 60 | Selects the given cmd component in the file browser widget. 61 | :param device: The device to select the cmd component for. 62 | :param cmd_component: The cmd component to select. 63 | """ 64 | ordered_device_jsonl_components = self.config_manager.determine_device_jsonl_component_order_for_cmd( 65 | device=device, 66 | cmd_component=cmd_component, 67 | ) 68 | 69 | self.page_layout_editor_widget.set_data( 70 | OpenHaspDevicePagesData( 71 | device=self.device, 72 | name=cmd_component.name, 73 | jsonl_components=ordered_device_jsonl_components 74 | ) 75 | ) 76 | -------------------------------------------------------------------------------- /openhasp_config_manager/ui/qt/util.py: -------------------------------------------------------------------------------- 1 | def clear_layout(layout): 2 | """Recursively delete all widgets and layouts in the given layout.""" 3 | if layout is not None: 4 | while layout.count(): 5 | child = layout.takeAt(0) 6 | if child.widget() is not None: 7 | child.widget().setParent(None) 8 | child.widget().deleteLater() 9 | elif child.layout() is not None: 10 | clear_layout(child.layout()) 11 | -------------------------------------------------------------------------------- /openhasp_config_manager/ui/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Union 3 | 4 | import click 5 | 6 | LOGGER = logging.getLogger(__name__) 7 | LOGGER.setLevel(logging.DEBUG) 8 | 9 | 10 | def error(text: str): 11 | prefix = click.style(" FAIL ", fg="black", bg="red") 12 | click.echo(f"{prefix} ", nl=False) 13 | echo(text) 14 | 15 | 16 | def warn(text: str): 17 | prefix = click.style(" WARN ", fg="black", bg="yellow") 18 | click.echo(f"{prefix} ", nl=False) 19 | echo(text) 20 | 21 | 22 | def info(text: str): 23 | prefix = click.style(" INFO ", fg="white", bg=(33, 33, 33)) 24 | click.echo(f"{prefix} ", nl=False) 25 | echo(text) 26 | 27 | 28 | def success(text: str): 29 | prefix = click.style(" OK ", fg="black", bg="green") 30 | click.echo(f"{prefix} ", nl=False) 31 | echo(text) 32 | 33 | 34 | def echo(text: Union[str, List] = "", fg_color=None, bg_color=None): 35 | """ 36 | Prints a text to the console 37 | :param text: the text 38 | :param fg_color: an optional foreground (text) color 39 | :param bg_color: an optional background color 40 | """ 41 | if text is not click.termui and text is not str: 42 | text = str(text) 43 | if fg_color: 44 | text = click.style(text, fg=fg_color, bg=bg_color) 45 | if len(text) > 0: 46 | LOGGER.debug(text) 47 | click.echo(text) 48 | 49 | 50 | def print_diff_to_console(diff_output): 51 | for line in diff_output.splitlines(): 52 | if line.startswith("+"): 53 | echo(line, fg_color="green") 54 | elif line.startswith("-"): 55 | echo(line, fg_color="red") 56 | else: 57 | echo(line) 58 | -------------------------------------------------------------------------------- /openhasp_config_manager/uploader.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from pathlib import Path 3 | 4 | from openhasp_config_manager import util 5 | from openhasp_config_manager.openhasp_client.model.device import Device 6 | from openhasp_config_manager.openhasp_client.openhasp import OpenHaspClient 7 | from openhasp_config_manager.ui.util import print_diff_to_console, info, warn 8 | 9 | 10 | class ConfigUploader: 11 | 12 | def __init__(self, output_root: Path, openhasp_client: OpenHaspClient): 13 | self._api_client = openhasp_client 14 | self._output_root = output_root 15 | self._cache_dir = Path(self._output_root, ".cache") 16 | 17 | def upload(self, device: Device, purge: bool = False, print_diff: bool = False) -> bool: 18 | """ 19 | Uploads configuration files and config properties to a device. 20 | :param device: The device to upload to. 21 | :param purge: If True, removes files from the device, which are not present in the generated output. 22 | :param print_diff: If true, a diff will be printed to the console for each file that has changed. 23 | :return: True if any files have changed, false otherwise. 24 | """ 25 | result = False 26 | 27 | if purge: 28 | result |= self.cleanup_device(device) 29 | 30 | result |= self._upload_files(device, print_diff) 31 | result |= self._update_config(device) 32 | return result 33 | 34 | def _upload_files(self, device: Device, print_diff: bool) -> bool: 35 | existing_files = self._api_client.get_files() 36 | 37 | result = False 38 | 39 | for file in device.output_dir.iterdir(): 40 | info(f"Preparing '{file.name}' for upload...") 41 | 42 | if file.suffix in [".cmd", ".jsonl"]: 43 | result |= self._upload_text_file(device, print_diff, file, existing_files) 44 | else: 45 | result |= self._upload_binary_file(device, print_diff, file, existing_files) 46 | 47 | return result 48 | 49 | def _upload_text_file(self, device, print_diff, file, existing_files) -> bool: 50 | result = False 51 | 52 | content = file.read_text() 53 | if len(content) <= 0: 54 | warn(f"File is empty, skipping upload: {file}") 55 | return result 56 | 57 | # check if the checksum of the file has changed on the device 58 | file_content_on_device = "" 59 | if file.name in existing_files: 60 | file_content_on_device = self._api_client.get_file_content(file.name).decode("utf-8") 61 | device_file_content_checksum = util.calculate_checksum(file_content_on_device.encode("utf-8")) 62 | else: 63 | device_file_content_checksum = None 64 | 65 | new_checksum = self._check_if_checksum_will_change( 66 | file=file, 67 | original_checksum=device_file_content_checksum, 68 | new_content=content.encode("utf-8") 69 | ) 70 | 71 | if new_checksum is not None: 72 | result = True 73 | if print_diff: 74 | diff_output = self._calculate_diff( 75 | file_name=file.name, 76 | string1=file_content_on_device, 77 | string2=content 78 | ) 79 | print_diff_to_console(diff_output) 80 | try: 81 | self._api_client.upload_file(file.name, content.encode("utf-8")) 82 | checksum_file = self._get_checksum_file(file) 83 | checksum_file.parent.mkdir(parents=True, exist_ok=True) 84 | checksum_file.write_text(new_checksum) 85 | except Exception as ex: 86 | raise Exception(f"Error uploading file '{file.name}' to '{device.name}': {ex}") 87 | else: 88 | info(f"Skipping {file} because it hasn't changed.") 89 | 90 | return result 91 | 92 | def _upload_binary_file(self, device, print_diff, file, existing_files) -> bool: 93 | result = False 94 | 95 | content = file.read_bytes() 96 | if len(content) <= 0: 97 | warn(f"File is empty, skipping upload: {file}") 98 | return result 99 | 100 | # check if the checksum of the file has changed on the device 101 | file_content_on_device = b"" 102 | if file.name in existing_files: 103 | file_content_on_device: bytes = self._api_client.get_file_content(file.name) 104 | device_file_content_checksum = util.calculate_checksum(file_content_on_device) 105 | else: 106 | device_file_content_checksum = None 107 | 108 | new_checksum = self._check_if_checksum_will_change( 109 | file=file, 110 | original_checksum=device_file_content_checksum, 111 | new_content=content 112 | ) 113 | 114 | if new_checksum is not None: 115 | result = True 116 | if print_diff: 117 | info(f"Binary file '{file.name}' has changed ({device_file_content_checksum} -> {new_checksum})") 118 | try: 119 | self._api_client.upload_file(file.name, content) 120 | checksum_file = self._get_checksum_file(file) 121 | checksum_file.parent.mkdir(parents=True, exist_ok=True) 122 | checksum_file.write_text(new_checksum) 123 | except Exception as ex: 124 | raise Exception(f"Error uploading file '{file.name}' to '{device.name}': {ex}") 125 | else: 126 | info(f"Skipping {file} because it hasn't changed.") 127 | 128 | return result 129 | 130 | def cleanup_device(self, device: Device) -> bool: 131 | """ 132 | Delete files from the device, which are not present in the currently generated output 133 | :param device: the target device 134 | :return: True if any files have been deleted, false otherwise 135 | """ 136 | result = False 137 | 138 | file_names = ["config.json"] 139 | for file in device.output_dir.iterdir(): 140 | file_names.append(file.name) 141 | 142 | # cleanup files which are on the device, but not present in the generated output 143 | files_on_device = self._api_client.get_files() 144 | for f in files_on_device: 145 | if f not in file_names: 146 | result = True 147 | info(f"Deleting file '{f}' from device '{device.name}'") 148 | self._api_client.delete_file(f) 149 | return result 150 | 151 | def _check_if_checksum_will_change(self, file: Path, original_checksum: str, new_content: bytes) -> str | None: 152 | """ 153 | Checks if the checksum for the given file has changed since it was last uploaded. 154 | :param file: the path of the file to check 155 | :param original_checksum: expected checksum of the original content 156 | :param new_content: the content of the new file 157 | :return: new checksum if the checksum changed, None otherwise 158 | """ 159 | changed = False 160 | 161 | new_content_checksum = util.calculate_checksum(new_content) 162 | checksum_file = self._get_checksum_file(file) 163 | if not checksum_file.exists(): 164 | changed = True 165 | else: 166 | old_hash = checksum_file.read_text().strip() 167 | if old_hash != original_checksum or old_hash != new_content_checksum: 168 | changed = True 169 | 170 | if changed: 171 | return new_content_checksum 172 | else: 173 | return None 174 | 175 | def _get_checksum_file(self, file: Path) -> Path: 176 | return Path( 177 | self._cache_dir, 178 | *file.relative_to(self._output_root).parts[:-1], 179 | file.name + ".md5" 180 | ) 181 | 182 | def _update_config(self, device: Device): 183 | config_has_changed = False 184 | 185 | current_mqtt_config = self._api_client.get_mqtt_config() 186 | if current_mqtt_config == device.config.mqtt: 187 | info("MQTT config has not changed") 188 | else: 189 | info("Updating MQTT config...") 190 | self._api_client.set_mqtt_config(device.config.mqtt) 191 | config_has_changed = True 192 | 193 | current_http_config = self._api_client.get_http_config() 194 | if current_http_config == device.config.http: 195 | info("HTTP config has not changed") 196 | else: 197 | info("Updating HTTP config...") 198 | self._api_client.set_http_config(device.config.http) 199 | config_has_changed = True 200 | 201 | current_gui_config = self._api_client.get_gui_config() 202 | if current_gui_config == device.config.gui: 203 | info("GUI config has not changed") 204 | else: 205 | info("Updating GUI config...") 206 | self._api_client.set_gui_config(device.config.gui) 207 | config_has_changed = True 208 | 209 | return config_has_changed 210 | 211 | @staticmethod 212 | def _calculate_diff(file_name: str, string1: str, string2: str) -> str: 213 | diff = difflib.unified_diff( 214 | string1.splitlines(), 215 | string2.splitlines(), 216 | lineterm="\n", 217 | fromfile=f"${file_name}", 218 | tofile=f"${file_name}" 219 | ) 220 | 221 | return "\n".join(list(diff)) 222 | -------------------------------------------------------------------------------- /openhasp_config_manager/util.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | def calculate_checksum(content: bytes) -> str: 5 | import hashlib 6 | hash_value = hashlib.md5(content).hexdigest() 7 | return hash_value 8 | 9 | 10 | def contains_nested_dict_key(d: dict, key: str) -> bool: 11 | """ 12 | Recursively checks if any key in a nested dictionary structure is equal to the given string. 13 | :param d: the dictionary to check 14 | :param key: the key to search for 15 | :return: True if the string is found in any key, False otherwise 16 | """ 17 | stack = [d] 18 | while stack: 19 | curr = stack.pop() 20 | for k, v in curr.items(): 21 | if k == key: 22 | return True 23 | if isinstance(v, dict): 24 | stack.append(v) 25 | return False 26 | 27 | 28 | def merge_dict_recursive(d1: Dict, d2: Dict) -> Dict: 29 | """ 30 | Merges two dictionaries, preserving even the key/value pairs of nested dictionaries. 31 | 32 | This differs from the built-in "|" operator of python in that the built-in version 33 | will not preserve a key of a nested dict in d1 if the same dict is present in d2 but 34 | without the given key. 35 | 36 | :param d1: the first dictionary 37 | :param d2: the second dictionary 38 | :return: the merged dictionary 39 | """ 40 | from copy import deepcopy 41 | 42 | result = deepcopy(d1) 43 | for d2_key, d2_value in d2.items(): 44 | if d2_key in d1: 45 | d1_value = d1[d2_key] 46 | if isinstance(d1_value, dict): 47 | if not isinstance(d2_value, dict): 48 | raise AssertionError(f"Incompatible types for merging dict, cannot merge {d1_value} and {d2_value}") 49 | 50 | result[d2_key] = merge_dict_recursive(d1_value, d2_value) 51 | else: 52 | result[d2_key] = deepcopy(d2_value) 53 | else: 54 | result[d2_key] = deepcopy(d2_value) 55 | 56 | return result 57 | -------------------------------------------------------------------------------- /openhasp_config_manager/validation/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class Validator(metaclass=abc.ABCMeta): 5 | """ 6 | Generic interface to implement validators used when validating the configuration. 7 | """ 8 | 9 | @abc.abstractmethod 10 | def validate(self, data: str): 11 | """ 12 | Validates the given input data. 13 | 14 | If the input is considered valid, the method will return. 15 | 16 | If the input is considered invalid, an error will be raised further 17 | detailing the reasons why it is considered invalid. 18 | 19 | :param data: the input data to analyze 20 | """ 21 | raise NotImplementedError 22 | -------------------------------------------------------------------------------- /openhasp_config_manager/validation/cmd.py: -------------------------------------------------------------------------------- 1 | import orjson 2 | 3 | from openhasp_config_manager.validation import Validator 4 | 5 | 6 | class CmdFileValidator(Validator): 7 | 8 | def validate(self, data: str): 9 | for line in data.splitlines(): 10 | line = line.lstrip() 11 | self._validate_line(line) 12 | 13 | def _validate_line(self, line: str): 14 | if line.startswith("run"): 15 | pass 16 | elif line.startswith("jsonl"): 17 | command, arg = line.split(sep=" ", maxsplit=1) 18 | try: 19 | import json 20 | orjson.loads(arg) 21 | except Exception as ex: 22 | raise AssertionError(f"jsonl command argument cannot be parsed: {ex}") 23 | -------------------------------------------------------------------------------- /openhasp_config_manager/validation/device_validator.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.openhasp_client.model.component import Component 2 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 3 | from openhasp_config_manager.validation.cmd import CmdFileValidator 4 | from openhasp_config_manager.validation.jsonl import JsonlObjectValidator 5 | 6 | 7 | class DeviceValidator: 8 | """ 9 | A device specific validator, used to validate the result of the DeviceProcessor. 10 | """ 11 | 12 | def __init__(self, config: Config, jsonl_object_validator: JsonlObjectValidator, 13 | cmd_file_validator: CmdFileValidator): 14 | self._config = config 15 | self._jsonl_object_validator = jsonl_object_validator 16 | self._cmd_file_validator = cmd_file_validator 17 | 18 | def validate(self, component: Component, data: str): 19 | if component.type == "jsonl": 20 | self._jsonl_object_validator.validate(data) 21 | if component.type == "cmd": 22 | self._cmd_file_validator.validate(data) 23 | if len(component.name) > 30: 24 | raise AssertionError( 25 | f"Output file name length must not exceed 30 characters, but was {len(component.name)}: {component.name}") 26 | -------------------------------------------------------------------------------- /openhasp_config_manager/validation/jsonl.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import orjson as orjson 4 | from py_range_parse import Range 5 | 6 | from openhasp_config_manager.validation import Validator 7 | 8 | 9 | class JsonlObjectValidator(Validator): 10 | """ 11 | Validates JSONL object definitions. 12 | """ 13 | 14 | def __init__(self): 15 | self._seen_ids = {} 16 | 17 | def validate(self, data: str): 18 | for line in data.splitlines(): 19 | input = orjson.loads(line) 20 | self._validate_object(input) 21 | 22 | def _validate_object(self, input: Dict): 23 | input_page = input.get("page", None) 24 | input_id = input.get("id", None) 25 | self.__remember_page_id_combo(input_page, input_id, input) 26 | 27 | if input_id is not None: 28 | valid_range = Range(0, 254) 29 | if not isinstance(input_id, int) or input_id not in valid_range: 30 | raise AssertionError(f"Object has invalid id '{input_id}', must be in range: {valid_range}") 31 | 32 | input_align = input.get("align", None) 33 | if input_align is not None: 34 | valid_align_int_values = [0, 1, 2] 35 | valid_align_str_values = ["left", "center", "right"] 36 | valid_align_values = valid_align_str_values + valid_align_int_values 37 | 38 | if input_align not in valid_align_values: 39 | if isinstance(input_align, str): 40 | raise AssertionError( 41 | f"Invalid 'align' string value: '{input_align}', must be one of: {valid_align_values}") 42 | if isinstance(input_align, int): 43 | raise AssertionError( 44 | f"Invalid 'align' integer value: '{input_align}', must be one of: {valid_align_values}") 45 | 46 | def __remember_page_id_combo(self, input_page: int, input_id: int, data: Dict): 47 | if input_page is None or input_id is None: 48 | raise AssertionError(f"page or id is None: {input_page}, {input_id}") 49 | 50 | key = f"p{input_page}b{input_id}" 51 | if key in self._seen_ids.keys(): 52 | raise AssertionError(f"Duplicate id detected: {key}, already seen in object: {self._seen_ids[key]}") 53 | else: 54 | self._seen_ids[key] = data 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "openhasp-config-manager" 3 | version = "0.7.0" 4 | description = "A tool to manage all of your openHASP device configs in a centralized place." 5 | 6 | license = "AGPL-3.0-or-later" 7 | 8 | authors = [ 9 | "Markus Ressel ", 10 | ] 11 | 12 | readme = 'README.md' 13 | 14 | repository = "https://github.com/markusressel/openhasp-config-manager" 15 | homepage = "https://github.com/markusressel/openhasp-config-manager" 16 | 17 | keywords = ['openhasp', 'config', 'management'] 18 | 19 | classifiers = [ 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.10", 23 | "Development Status :: 5 - Production/Stable" 24 | ] 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.12" # Compatible python versions must be declared here 32 | 33 | click = "8.1.8" 34 | requests = "2.28.2,< 2.33.0" # taken from appdaemon 4.5.0 35 | aiohttp = ">= 3.9.0,< 3.12.0" # taken from appdaemon 4.5.0 36 | temppathlib = "1.2.0" 37 | Jinja2 = "3.1.6" 38 | py-range-parse = "1.0.5" 39 | pyyaml = "6.0.2" 40 | 41 | multidict = "6.4.3" 42 | frozenlist = "1.6.0" 43 | yarl = "1.20.0" 44 | 45 | orjson = "3.10.16" 46 | aiomqtt = "*" # compromise to get it working with appdaemon 4.5.0 47 | paho-mqtt = ">= 1.6.1,< 2.2.0" # taken from appdaemon 4.5.0 48 | telnetlib3 = "2.0.4" 49 | pillow = "11.2.1" 50 | 51 | [tool.poetry.group.ui] 52 | optional = true 53 | 54 | [tool.poetry.group.ui.dependencies] 55 | PyQt6 = "6.9.0" 56 | QtAwesome = "1.4.0" 57 | 58 | [tool.poetry.group.dev.dependencies] 59 | pytest = "*" 60 | pytest_asyncio = "*" 61 | 62 | [tool.pytest.ini_options] 63 | testpaths = [ 64 | "tests", 65 | ] 66 | 67 | [project] 68 | name = "openhasp-config-manager" 69 | 70 | [project.scripts] 71 | openhasp-config-manager = 'openhasp_config_manager.cli:cli' -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from openhasp_config_manager.openhasp_client.model.configuration.config import Config 6 | from openhasp_config_manager.openhasp_client.model.configuration.debug_config import DebugConfig 7 | from openhasp_config_manager.openhasp_client.model.configuration.device_config import DeviceConfig 8 | from openhasp_config_manager.openhasp_client.model.configuration.gui_config import GuiConfig 9 | from openhasp_config_manager.openhasp_client.model.configuration.hasp_config import HaspConfig 10 | from openhasp_config_manager.openhasp_client.model.configuration.http_config import HttpConfig 11 | from openhasp_config_manager.openhasp_client.model.configuration.mqtt_config import MqttConfig, MqttTopicConfig 12 | from openhasp_config_manager.openhasp_client.model.configuration.screen_config import ScreenConfig 13 | from openhasp_config_manager.openhasp_client.model.configuration.telnet_config import TelnetConfig 14 | from openhasp_config_manager.openhasp_client.model.configuration.wifi_config import WifiConfig 15 | from openhasp_config_manager.openhasp_client.model.openhasp_config_manager_config import OpenhaspConfigManagerConfig 16 | 17 | 18 | def _find_test_folder() -> Path: 19 | p = Path("./") 20 | 21 | if p.absolute().parts[-1] == "tests": 22 | return p 23 | else: 24 | import glob 25 | while str(p.absolute()) != "/": 26 | files = glob.glob(str(Path(p)) + '/**/tests', recursive=True) 27 | if len(files) > 0: 28 | return Path(files[0]).absolute() 29 | else: 30 | p = p.parent.absolute() 31 | 32 | raise AssertionError("test folder not found") 33 | 34 | 35 | @pytest.mark.usefixtures('tmp_path') 36 | class TestBase: 37 | _test_folder = _find_test_folder() 38 | cfg_root = Path(_test_folder, Path("_test_cfg_root")) 39 | 40 | default_config = Config( 41 | openhasp_config_manager=OpenhaspConfigManagerConfig( 42 | device=DeviceConfig( 43 | ip="192.168.1.10", 44 | screen=ScreenConfig( 45 | width=320, 46 | height=480, 47 | ) 48 | ) 49 | ), 50 | wifi=WifiConfig( 51 | ssid="ssid", 52 | password="password", 53 | ), 54 | mqtt=MqttConfig( 55 | name="name", 56 | topic=MqttTopicConfig( 57 | node="node", 58 | group="group", 59 | broadcast="broadcast", 60 | hass="hass", 61 | ), 62 | host="mqtt.host", 63 | port=1883, 64 | user="user", 65 | password="password", 66 | ), 67 | http=HttpConfig( 68 | port=80, 69 | user="user", 70 | password="password", 71 | ), 72 | gui=GuiConfig( 73 | idle1=10, 74 | idle2=60, 75 | bckl=32, 76 | bcklinv=0, 77 | rotate=1, 78 | cursor=0, 79 | invert=0, 80 | calibration=[ 81 | 0, 82 | 65535, 83 | 0, 84 | 65535, 85 | 0 86 | ], 87 | ), 88 | hasp=HaspConfig( 89 | startpage=1, 90 | startdim=255, 91 | theme=2, 92 | color1="#000000", 93 | color2="#000000", 94 | font="", 95 | pages="/pages_home.jsonl", 96 | ), 97 | debug=DebugConfig( 98 | ansi=1, 99 | baud=115200, 100 | tele=300, 101 | host="", 102 | port=541, 103 | proto=0, 104 | log=0, 105 | ), 106 | telnet=TelnetConfig( 107 | enable=1, 108 | port=23, 109 | ), 110 | ) 111 | -------------------------------------------------------------------------------- /tests/_test_cfg_root/common/dialog/connected.jsonl: -------------------------------------------------------------------------------- 1 | { 2 | "page": 0, 3 | "id": 31, 4 | "obj": "msgbox", 5 | "text": "%ip%", 6 | "auto_close": 20000 7 | } -------------------------------------------------------------------------------- /tests/_test_cfg_root/common/dialog/offline.jsonl: -------------------------------------------------------------------------------- 1 | { 2 | "page": 0, 3 | "id": 41, 4 | "obj": "msgbox", 5 | "text": "offline", 6 | "auto_close": 20000 7 | } -------------------------------------------------------------------------------- /tests/_test_cfg_root/common/offline.cmd: -------------------------------------------------------------------------------- 1 | run /common_dialog_offline.jsonl -------------------------------------------------------------------------------- /tests/_test_cfg_root/common/online.cmd: -------------------------------------------------------------------------------- 1 | run /common_dialog_connected.jsonl -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "openhasp_config_manager": { 3 | "device": { 4 | "ip": "1.2.3.4", 5 | "screen": { 6 | "width": 320, 7 | "height": 480, 8 | "orientation": "portrait" 9 | } 10 | } 11 | }, 12 | "wifi": { 13 | "ssid": "SSID", 14 | "pass": "PASS" 15 | }, 16 | "mqtt": { 17 | "name": "mqtt_name", 18 | "host": "5.6.7.8", 19 | "port": 1883, 20 | "user": "mqtt_user", 21 | "pass": "mqtt_pass", 22 | "topic": { 23 | "node": "hasp/%hostname%/%topic%", 24 | "group": "hasp/plates/%topic%", 25 | "broadcast": "hasp/broadcast/%topic%", 26 | "hass": "homeassistant/status" 27 | } 28 | }, 29 | "telnet": { 30 | "enable": 1, 31 | "port": 23 32 | }, 33 | "mdns": { 34 | "enable": 1 35 | }, 36 | "http": { 37 | "enable": true, 38 | "port": 80, 39 | "website": "http://1.2.3.4", 40 | "user": "admin", 41 | "pass": "admin_pass" 42 | }, 43 | "gpio": { 44 | "config": [ 45 | 0, 46 | 0, 47 | 0, 48 | 0, 49 | 0, 50 | 0, 51 | 0, 52 | 0 53 | ] 54 | }, 55 | "debug": { 56 | "ansi": 1, 57 | "baud": 115200, 58 | "tele": 300, 59 | "host": "", 60 | "port": 514, 61 | "proto": 0, 62 | "log": 0 63 | }, 64 | "gui": { 65 | "idle1": 60, 66 | "idle2": 120, 67 | "bckl": 32, 68 | "bcklinv": 0, 69 | "rotate": 1, 70 | "cursor": 0, 71 | "invert": 0, 72 | "calibration": [ 73 | 0, 74 | 65535, 75 | 0, 76 | 65535, 77 | 0 78 | ] 79 | }, 80 | "hasp": { 81 | "startpage": 1, 82 | "startdim": 255, 83 | "theme": 5, 84 | "color1": "#0079e6", 85 | "color2": "#e60000", 86 | "font": "", 87 | "pages": "/pages.jsonl" 88 | } 89 | } -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/home.cmd: -------------------------------------------------------------------------------- 1 | run L:/home_page.jsonl 2 | run L:/home_test_page.jsonl -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/home/image_50x50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/tests/_test_cfg_root/devices/test_device/home/image_50x50.png -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/home/page.jsonl: -------------------------------------------------------------------------------- 1 | // Content of the "Home" page 2 | 3 | { 4 | // a simple label 5 | "page": 1, 6 | "id": 1, 7 | "obj": "label", 8 | "x": 5, 9 | "y": 5, 10 | "h": 50, 11 | "w": 50, 12 | "text": "Hello", 13 | "enabled": true, 14 | "hidden": false 15 | } 16 | 17 | // === buttons === 18 | 19 | { 20 | // a button 21 | "page": 1, 22 | "id": 2, 23 | "obj": "btn", 24 | "x": 5, 25 | "y": 90, 26 | "h": 90, 27 | "w": 50, 28 | "text": "World", 29 | "enabled": false, 30 | "hidden": false 31 | } 32 | 33 | { 34 | // a label using a global variable 35 | "page": 1, 36 | "id": 3, 37 | "obj": "label", 38 | "x": 5, 39 | "y": 5, 40 | "h": 50, 41 | "w": 50, 42 | "text": "{{ global.var }}", 43 | "enabled": true, 44 | "hidden": false 45 | } 46 | 47 | { 48 | // a label using a local variable 49 | "page": 1, 50 | "id": 4, 51 | "obj": "label", 52 | "x": 5, 53 | "y": 5, 54 | "h": 50, 55 | "w": 50, 56 | "text": "{{ key_also_present_in_device_vars }}", 57 | "enabled": true, 58 | "hidden": false 59 | } 60 | 61 | // === END === -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/home/test_page.jsonl: -------------------------------------------------------------------------------- 1 | { 2 | // URL test 3 | "page": 2, 4 | "id": 2, 5 | "obj": "img", 6 | "x": 35, 7 | "y": 40, 8 | "h": 250, 9 | "w": 250, 10 | "src": "https://upload.wikimedia.org/wikipedia/commons/b/bf/Test_card.png", 11 | "auto_size": 1 12 | } 13 | 14 | { 15 | // local file test 16 | "page": 2, 17 | "id": 3, 18 | "obj": "img", 19 | "x": 35, 20 | "y": 40, 21 | "h": 250, 22 | "w": 250, 23 | "src": "L:/home_image_50x50.png", 24 | "auto_size": 1 25 | } -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/vars.yaml: -------------------------------------------------------------------------------- 1 | key_also_present_in_device_vars: "test_device_value" -------------------------------------------------------------------------------- /tests/_test_cfg_root/devices/test_device/vars2.yaml: -------------------------------------------------------------------------------- 1 | key_vars2: "value_vars2" -------------------------------------------------------------------------------- /tests/_test_cfg_root/global.vars.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | var: "global_var_value" 3 | key_also_present_in_device_vars: "global_value" -------------------------------------------------------------------------------- /tests/manager_analyze_test.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.manager import ConfigManager 2 | from openhasp_config_manager.processing.variables import VariableManager 3 | from tests import TestBase 4 | 5 | 6 | class TestConfigManager(TestBase): 7 | 8 | def test_analyze_whole_config(self, tmp_path): 9 | # GIVEN 10 | variable_manager = VariableManager(self.cfg_root) 11 | manager = ConfigManager(self.cfg_root, tmp_path, variable_manager) 12 | 13 | # WHEN 14 | devices = manager.analyze() 15 | 16 | # THEN 17 | assert len(devices) == 1 18 | 19 | device = devices[0] 20 | assert len(device.cmd) == 3 21 | assert len(device.jsonl) == 4 22 | assert len(device.images) == 1 23 | assert len(device.fonts) == 0 24 | -------------------------------------------------------------------------------- /tests/manager_process_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.manager import ConfigManager 4 | from openhasp_config_manager.processing.variables import VariableManager 5 | from tests import TestBase 6 | 7 | 8 | class TestConfigManager(TestBase): 9 | 10 | def test_process_whole_config(self, tmp_path): 11 | # GIVEN 12 | variable_manager = VariableManager(self.cfg_root) 13 | manager = ConfigManager(self.cfg_root, tmp_path, variable_manager) 14 | 15 | devices = manager.analyze() 16 | 17 | # WHEN 18 | for device in devices: 19 | manager.process(device) 20 | 21 | # THEN 22 | file_count = 0 23 | for file in tmp_path.rglob("*.jsonl"): 24 | file_count += 1 25 | content = file.read_text() 26 | assert len(content) > 0 27 | for line in content.splitlines(): 28 | assert line.startswith("{") 29 | assert line.endswith("}") 30 | 31 | assert file_count > 0 32 | 33 | def test_image_file_is_copied_when_referenced_in_jsonl_src(self, tmp_path): 34 | # GIVEN 35 | variable_manager = VariableManager(self.cfg_root) 36 | manager = ConfigManager(self.cfg_root, tmp_path, variable_manager) 37 | 38 | devices = manager.analyze() 39 | 40 | # WHEN 41 | for device in devices: 42 | manager.process(device) 43 | 44 | # THEN 45 | file_count = 0 46 | for file in tmp_path.rglob("*.png"): 47 | file_count += 1 48 | content = file.read_bytes() 49 | assert len(content) > 0 50 | 51 | assert file_count > 0 52 | 53 | def test_global_variable(self, tmp_path): 54 | # GIVEN 55 | variable_manager = VariableManager(self.cfg_root) 56 | manager = ConfigManager(self.cfg_root, tmp_path, variable_manager) 57 | devices = manager.analyze() 58 | 59 | # WHEN 60 | for device in devices: 61 | manager.process(device) 62 | 63 | # THEN 64 | home_page_file = Path(tmp_path, "test_device", "home_page.jsonl") 65 | content = home_page_file.read_text() 66 | assert "global_var_value" in content 67 | assert "global_value" not in content 68 | assert "test_device_value" in content 69 | -------------------------------------------------------------------------------- /tests/openhasp_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/tests/openhasp_client/__init__.py -------------------------------------------------------------------------------- /tests/openhasp_client/mqtt_client_test.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.openhasp_client.mqtt_client import MqttClient 2 | from tests import TestBase 3 | 4 | 5 | class TestMqttClient(TestBase): 6 | 7 | async def test_subscription(self): 8 | mqtt_client = MqttClient("localhost", 1883, "test", "test") 9 | 10 | async def callback(topic, payload): 11 | pass 12 | 13 | await mqtt_client.subscribe(topic="test", callback=callback) 14 | 15 | assert mqtt_client._callbacks == { 16 | "test": [callback] 17 | } 18 | 19 | await mqtt_client.cancel_callback(callback=callback) 20 | 21 | async def test_cancel_subscription(self): 22 | mqtt_client = MqttClient("localhost", 1883, "test", "test") 23 | 24 | async def callback(topic, payload): 25 | pass 26 | 27 | await mqtt_client.subscribe(topic="test", callback=callback) 28 | await mqtt_client.cancel_callback(callback=callback) 29 | 30 | assert mqtt_client._callbacks == { 31 | "test": [] 32 | } 33 | -------------------------------------------------------------------------------- /tests/processing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markusressel/openhasp-config-manager/8e8ca667d64ce81a8382ca2015b7bddf7ab3ba41/tests/processing/__init__.py -------------------------------------------------------------------------------- /tests/processing/device_processor_test.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | from openhasp_config_manager.openhasp_client.model.component import JsonlComponent 5 | from openhasp_config_manager.openhasp_client.model.device import Device 6 | from openhasp_config_manager.processing.device_processor import DeviceProcessor 7 | from openhasp_config_manager.processing.jsonl.jsonl import ObjectDimensionsProcessor 8 | from openhasp_config_manager.processing.variables import VariableManager 9 | from tests import TestBase 10 | 11 | 12 | class TestDeviceProcessor(TestBase): 13 | 14 | def test_multiline_object_params(self): 15 | # GIVEN 16 | device = Device( 17 | name="test_device", 18 | path=Path(self.cfg_root, "devices", "test_device"), 19 | config=self.default_config, 20 | cmd=[], 21 | jsonl=[], 22 | images=[], 23 | fonts=[], 24 | output_dir=None 25 | ) 26 | 27 | variable_manager = VariableManager(self.cfg_root) 28 | jsonl_object_processors = [ 29 | ObjectDimensionsProcessor() 30 | ] 31 | processor = DeviceProcessor(device, jsonl_object_processors, variable_manager) 32 | 33 | content = textwrap.dedent(""" 34 | { 35 | "x": 0, 36 | "y": 0 37 | } 38 | """) 39 | 40 | component = JsonlComponent( 41 | name="component", 42 | type="jsonl", 43 | path=Path(self.cfg_root, "common"), 44 | content=content 45 | ) 46 | 47 | processor._add_jsonl(component) 48 | 49 | # WHEN 50 | result = processor.normalize(device, component) 51 | 52 | # THEN 53 | assert result == textwrap.dedent(""" 54 | {"x": 0, "y": 0} 55 | """).strip() 56 | 57 | def test_multiple_objects(self): 58 | # GIVEN 59 | device = Device( 60 | name="test_device", 61 | path=Path(self.cfg_root, "devices", "test_device"), 62 | config=self.default_config, 63 | cmd=[], 64 | jsonl=[], 65 | images=[], 66 | fonts=[], 67 | output_dir=None 68 | ) 69 | 70 | variable_manager = VariableManager(self.cfg_root) 71 | jsonl_object_processors = [ 72 | ObjectDimensionsProcessor() 73 | ] 74 | processor = DeviceProcessor(device, jsonl_object_processors, variable_manager) 75 | 76 | content = textwrap.dedent(""" 77 | { "x": 0, "y": 0 } 78 | { "a": 0, "b": 0 } 79 | """) 80 | 81 | component = JsonlComponent( 82 | name="component", 83 | type="jsonl", 84 | path=Path(self.cfg_root, "devices", "test_device"), 85 | content=content 86 | ) 87 | processor._add_jsonl(component) 88 | 89 | # WHEN 90 | result = processor.normalize(device, component) 91 | 92 | # THEN 93 | assert result == textwrap.dedent(""" 94 | {"x": 0, "y": 0} 95 | {"a": 0, "b": 0} 96 | """).strip() 97 | 98 | def test_id_template(self): 99 | # GIVEN 100 | device = Device( 101 | name="test_device", 102 | path=Path(self.cfg_root, "devices", "test_device"), 103 | config=self.default_config, 104 | cmd=[], 105 | jsonl=[], 106 | images=[], 107 | fonts=[], 108 | output_dir=None 109 | ) 110 | 111 | variable_manager = VariableManager(self.cfg_root) 112 | jsonl_object_processors = [ 113 | ObjectDimensionsProcessor() 114 | ] 115 | processor = DeviceProcessor(device, jsonl_object_processors, variable_manager) 116 | 117 | content = textwrap.dedent(""" 118 | { 119 | "id": "{{ p1b1.id - 1 }}", 120 | "page": "{{ p1b1.page }}", 121 | "x": 0, 122 | "y": 0 123 | } 124 | { 125 | "id": 1, 126 | "page": 1, 127 | "x": 0, 128 | "y": 0 129 | } 130 | { 131 | "id": "{{ p1b1.id + 1 }}", 132 | "page": "{{ p1b1.page }}", 133 | "x": 0, 134 | "y": 0 135 | } 136 | """) 137 | 138 | component = JsonlComponent( 139 | name="component", 140 | type="jsonl", 141 | path=Path(self.cfg_root, "devices", "test_device"), 142 | content=content 143 | ) 144 | processor._add_jsonl(component) 145 | 146 | # WHEN 147 | result = processor.normalize(device, component) 148 | 149 | # THEN 150 | assert result == textwrap.dedent(""" 151 | {"id": 0, "page": 1, "x": 0, "y": 0} 152 | {"id": 1, "page": 1, "x": 0, "y": 0} 153 | {"id": 2, "page": 1, "x": 0, "y": 0} 154 | """).strip() 155 | 156 | def test_config_value_template(self): 157 | # GIVEN 158 | device = Device( 159 | name="test_device", 160 | path=Path(self.cfg_root, "devices", "test_device"), 161 | config=self.default_config, 162 | cmd=[], 163 | jsonl=[], 164 | images=[], 165 | fonts=[], 166 | output_dir=None 167 | ) 168 | 169 | variable_manager = VariableManager(self.cfg_root) 170 | jsonl_object_processors = [ 171 | ObjectDimensionsProcessor() 172 | ] 173 | processor = DeviceProcessor(device, jsonl_object_processors, variable_manager) 174 | 175 | content = textwrap.dedent(""" 176 | { 177 | "w": "{{ device.screen.width }}" 178 | } 179 | """) 180 | 181 | component = JsonlComponent( 182 | name="component", 183 | type="jsonl", 184 | path=Path(self.cfg_root, "devices", "test_device"), 185 | content=content 186 | ) 187 | processor._add_jsonl(component) 188 | 189 | # WHEN 190 | result = processor.normalize(device, component) 191 | 192 | # THEN 193 | assert result == textwrap.dedent(f""" 194 | {{"w": {device.config.openhasp_config_manager.device.screen.width}}} 195 | """).strip() 196 | 197 | def test_id_from_config_through_template(self): 198 | # GIVEN 199 | device = Device( 200 | name="test_device", 201 | path=Path(self.cfg_root, "devices", "test_device"), 202 | config=self.default_config, 203 | cmd=[], 204 | jsonl=[], 205 | images=[], 206 | fonts=[], 207 | output_dir=None 208 | ) 209 | 210 | variable_manager = VariableManager(self.cfg_root) 211 | variable_manager.add_vars( 212 | vars={ 213 | "id": { 214 | "text": 10 215 | } 216 | }, 217 | path=device.path, 218 | ) 219 | 220 | jsonl_object_processors = [ 221 | ObjectDimensionsProcessor() 222 | ] 223 | processor = DeviceProcessor(device, jsonl_object_processors, variable_manager) 224 | 225 | content = textwrap.dedent(""" 226 | { 227 | "id": "{{ id.text }}" 228 | } 229 | """) 230 | 231 | component = JsonlComponent( 232 | name="component", 233 | type="jsonl", 234 | path=Path(self.cfg_root, "devices", "test_device"), 235 | content=content 236 | ) 237 | processor._add_jsonl(component) 238 | 239 | # WHEN 240 | result = processor.normalize(device, component) 241 | 242 | # THEN 243 | assert result == textwrap.dedent(f""" 244 | {{"id": 10}} 245 | """).strip() 246 | -------------------------------------------------------------------------------- /tests/processing/preprocessor/jsonl_preprocessor_test.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from openhasp_config_manager.processing.preprocessor.jsonl_preprocessor import JsonlPreProcessor 4 | from tests import TestBase 5 | 6 | 7 | class TestJsonlPreProcessor(TestBase): 8 | 9 | def test_empty_content(self): 10 | # GIVEN 11 | underTest = JsonlPreProcessor() 12 | 13 | content = "" 14 | 15 | # WHEN 16 | result = underTest.cleanup_object_for_json_parsing(content) 17 | 18 | # THEN 19 | assert result == "" 20 | 21 | def test_only_comment(self): 22 | # GIVEN 23 | underTest = JsonlPreProcessor() 24 | 25 | content = "// test" 26 | 27 | # WHEN 28 | result = underTest.cleanup_object_for_json_parsing(content) 29 | 30 | # THEN 31 | assert result == "" 32 | 33 | def test_comment_before_object(self): 34 | # GIVEN 35 | underTest = JsonlPreProcessor() 36 | 37 | content = """ 38 | // test 39 | { "x": 0 } 40 | """ 41 | 42 | # WHEN 43 | result = underTest.cleanup_object_for_json_parsing(content) 44 | 45 | # THEN 46 | assert result == """{ "x": 0 }""" 47 | 48 | def test_comment_after_object(self): 49 | # GIVEN 50 | underTest = JsonlPreProcessor() 51 | 52 | content = """ 53 | { "x": 0 } 54 | // test 55 | """ 56 | 57 | # WHEN 58 | result = underTest.cleanup_object_for_json_parsing(content) 59 | 60 | # THEN 61 | assert result == """{ "x": 0 }""" 62 | 63 | def test_comment_between_object(self): 64 | # GIVEN 65 | underTest = JsonlPreProcessor() 66 | 67 | content = """ 68 | { "x": 0 } 69 | // test 70 | { "y": 0 } 71 | """ 72 | 73 | # WHEN 74 | result = underTest.cleanup_object_for_json_parsing(content) 75 | 76 | # THEN 77 | assert result == textwrap.dedent(""" 78 | { "x": 0 } 79 | { "y": 0 } 80 | """).strip() 81 | 82 | def test_inline_comment_after_object_param_with_comma_is_stripped(self): 83 | # GIVEN 84 | underTest = JsonlPreProcessor() 85 | 86 | content = textwrap.dedent(""" 87 | { 88 | "x": 0, // comment 89 | "y": 0 90 | } 91 | """) 92 | 93 | # WHEN 94 | result = underTest.cleanup_object_for_json_parsing(content) 95 | 96 | # THEN 97 | assert result == textwrap.dedent(""" 98 | { 99 | "x": 0, 100 | "y": 0 101 | } 102 | """).strip() 103 | 104 | def test_inline_comment_after_object_param_without_comma_is_stripped(self): 105 | # GIVEN 106 | underTest = JsonlPreProcessor() 107 | 108 | content = textwrap.dedent(""" 109 | { 110 | "x": 0, 111 | "y": 0 // comment 112 | } 113 | """) 114 | 115 | # WHEN 116 | result = underTest.cleanup_object_for_json_parsing(content) 117 | 118 | # THEN 119 | assert result == textwrap.dedent(""" 120 | { 121 | "x": 0, 122 | "y": 0 123 | } 124 | """).strip() 125 | 126 | def test_trailing_comma_on_last_object_is_stripped(self): 127 | # GIVEN 128 | underTest = JsonlPreProcessor() 129 | 130 | content = textwrap.dedent(""" 131 | { 132 | "x": 0, 133 | } 134 | """) 135 | 136 | # WHEN 137 | result = underTest.cleanup_object_for_json_parsing(content) 138 | 139 | # THEN 140 | assert result == textwrap.dedent(""" 141 | { 142 | "x": 0 143 | } 144 | """).strip() 145 | 146 | def test_split_objects_empty_content(self): 147 | # GIVEN 148 | underTest = JsonlPreProcessor() 149 | 150 | content = "" 151 | 152 | # WHEN 153 | result = underTest.split_jsonl_objects(content) 154 | 155 | # THEN 156 | assert result == [] 157 | 158 | def test_split_objects_single_object_with_random_stuff_around_it(self): 159 | # GIVEN 160 | underTest = JsonlPreProcessor() 161 | 162 | content = textwrap.dedent(""" 163 | 1237!() 164 | // comment 165 | random text 166 | { "x": 0 } 167 | random stuff 168 | // comment 169 | 1237!() 170 | """).strip() 171 | 172 | # WHEN 173 | result = underTest.split_jsonl_objects(content) 174 | 175 | # THEN 176 | assert result == ["""{ "x": 0 }"""] 177 | 178 | def test_split_objects_multiple_objects_with_random_stuff_around_them(self): 179 | # GIVEN 180 | underTest = JsonlPreProcessor() 181 | 182 | content = textwrap.dedent(""" 183 | 1237!() 184 | // comment 185 | random text 186 | { "x": 0 } 187 | random stuff 188 | // comment 189 | 1237!() 190 | { "y": 1 } 191 | random stuff 192 | // comment 193 | 1237!() 194 | 195 | """).strip() 196 | 197 | # WHEN 198 | result = underTest.split_jsonl_objects(content) 199 | 200 | # THEN 201 | assert result == [ 202 | """{ "x": 0 }""", 203 | """{ "y": 1 }""", 204 | ] 205 | 206 | def test_src_is_url(self): 207 | # GIVEN 208 | underTest = JsonlPreProcessor() 209 | 210 | content = """ 211 | { "x": 0, "url": "https://upload.wikimedia.org/wikipedia/commons/b/bf/Test_card.png", } 212 | """ 213 | 214 | # WHEN 215 | result = underTest.cleanup_object_for_json_parsing(content) 216 | 217 | # THEN 218 | assert result == textwrap.dedent(""" 219 | { "x": 0, "url": "https://upload.wikimedia.org/wikipedia/commons/b/bf/Test_card.png" } 220 | """).strip() 221 | -------------------------------------------------------------------------------- /tests/processing/template_rendering_test.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.processing.template_rendering import render_dict_recursive 2 | from tests import TestBase 3 | 4 | 5 | class TestTemplateRendering(TestBase): 6 | 7 | def test_render_dict_recursively__template_rendering_works(self): 8 | # GIVEN 9 | input_data = { 10 | "{{ key }}": "{{ value }}" 11 | } 12 | template_vars = { 13 | "key": "key_rendered", 14 | "value": "value_rendered" 15 | } 16 | 17 | # WHEN 18 | result = render_dict_recursive( 19 | input=input_data, 20 | template_vars=template_vars 21 | ) 22 | 23 | # THEN 24 | assert result == { 25 | "key_rendered": "value_rendered" 26 | } 27 | 28 | def test_render_dict_recursively__two_step_rendering(self): 29 | # GIVEN 30 | input_data = { 31 | "A": "B", 32 | "B": "{{ A }}", 33 | "C": "{{ B }}", 34 | } 35 | 36 | template_vars = {} 37 | 38 | # WHEN 39 | result = render_dict_recursive( 40 | input=input_data, 41 | template_vars=template_vars 42 | ) 43 | 44 | # THEN 45 | assert result == { 46 | 'A': 'B', 47 | 'B': 'B', 48 | 'C': 'B' 49 | } 50 | 51 | def test_render_dict_recursively__inner_template(self): 52 | # GIVEN 53 | input_data = { 54 | "A": "{{ {{ B }}{{ C }} }}", 55 | "B": "1", 56 | "C": "2", 57 | } 58 | 59 | template_vars = {} 60 | 61 | # WHEN 62 | result = render_dict_recursive( 63 | input=input_data, 64 | template_vars=template_vars 65 | ) 66 | 67 | # THEN 68 | assert result == { 69 | 'A': '12', 70 | "B": "1", 71 | "C": "2", 72 | } 73 | 74 | def test_render_dict_recursively__global_vars(self): 75 | # GIVEN 76 | input_data = { 77 | "A": "{{ header.bar.item_y }}", 78 | } 79 | 80 | template_vars = { 81 | "header": { 82 | "bar": { 83 | "height": 27, 84 | "item_y": "0.5%" 85 | } 86 | } 87 | } 88 | 89 | # WHEN 90 | result = render_dict_recursive( 91 | input=input_data, 92 | template_vars=template_vars 93 | ) 94 | 95 | # THEN 96 | assert result == { 97 | 'A': '0.5%', 98 | } 99 | 100 | def test_render_dict_recursively__template_in_list_item(self): 101 | # GIVEN 102 | input_data = { 103 | "key": [ 104 | "{{ variable }}", 105 | ] 106 | } 107 | 108 | template_vars = { 109 | "variable": "value" 110 | } 111 | 112 | # WHEN 113 | result = render_dict_recursive( 114 | input=input_data, 115 | template_vars=template_vars 116 | ) 117 | 118 | # THEN 119 | assert result == { 120 | 'key': ['value'], 121 | } 122 | -------------------------------------------------------------------------------- /tests/processing/variable_manager_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.processing.variables import VariableManager 4 | from tests import TestBase 5 | 6 | 7 | class TestVariableManager(TestBase): 8 | 9 | def test_global_variable(self, tmp_path): 10 | # GIVEN 11 | variable_manager = VariableManager(self.cfg_root) 12 | variable_manager.read() 13 | tested_component_path = Path(self.cfg_root, "devices", "test_device", "home", "page.jsonl") 14 | 15 | # WHEN 16 | result = variable_manager.get_vars(tested_component_path) 17 | 18 | # THEN 19 | assert result == { 20 | "global": { 21 | "var": "global_var_value" 22 | }, 23 | 'key_vars2': 'value_vars2', 24 | "key_also_present_in_device_vars": "test_device_value" 25 | } 26 | 27 | def test_add_var(self, tmp_path): 28 | # GIVEN 29 | variable_manager = VariableManager(self.cfg_root) 30 | variable_manager.read() 31 | tested_component_path = Path(self.cfg_root, "devices", "test_device", "home", "page.jsonl") 32 | 33 | # WHEN 34 | variable_manager.add_var( 35 | key="A", 36 | value="B", 37 | path=tested_component_path, 38 | ) 39 | 40 | # THEN 41 | result = variable_manager.get_vars(tested_component_path) 42 | assert result == { 43 | "A": "B", 44 | "global": { 45 | "var": "global_var_value" 46 | }, 47 | 'key_vars2': 'value_vars2', 48 | "key_also_present_in_device_vars": "test_device_value" 49 | } 50 | 51 | def test_add_var_global(self, tmp_path): 52 | # GIVEN 53 | variable_manager = VariableManager(self.cfg_root) 54 | variable_manager.read() 55 | tested_component_path = Path(self.cfg_root, "devices", "test_device", "home", "page.jsonl") 56 | 57 | # WHEN 58 | variable_manager.add_var( 59 | key="A", 60 | value="B", 61 | path=self.cfg_root, 62 | ) 63 | 64 | # THEN 65 | result = variable_manager.get_vars(tested_component_path) 66 | assert result == { 67 | "A": "B", 68 | "global": { 69 | "var": "global_var_value" 70 | }, 71 | 'key_vars2': 'value_vars2', 72 | "key_also_present_in_device_vars": "test_device_value" 73 | } 74 | 75 | def test_add_vars(self, tmp_path): 76 | # GIVEN 77 | variable_manager = VariableManager(self.cfg_root) 78 | variable_manager.read() 79 | tested_component_path = Path(self.cfg_root, "devices", "test_device", "home", "page.jsonl") 80 | 81 | # WHEN 82 | variable_manager.add_vars( 83 | vars={ 84 | "A": { 85 | "B": "C" 86 | } 87 | }, 88 | path=tested_component_path, 89 | ) 90 | 91 | # THEN 92 | result = variable_manager.get_vars(tested_component_path) 93 | assert result == { 94 | "A": { 95 | "B": "C" 96 | }, 97 | "global": { 98 | "var": "global_var_value" 99 | }, 100 | 'key_vars2': 'value_vars2', 101 | "key_also_present_in_device_vars": "test_device_value" 102 | } 103 | 104 | def test_add_vars_merge_with_existing(self, tmp_path): 105 | # GIVEN 106 | variable_manager = VariableManager(self.cfg_root) 107 | variable_manager.read() 108 | tested_component_path = Path(self.cfg_root, "devices", "test_device", "home", "page.jsonl") 109 | 110 | variable_manager.add_vars( 111 | vars={ 112 | "A": { 113 | "B": "C" 114 | } 115 | }, 116 | path=tested_component_path, 117 | ) 118 | 119 | # WHEN 120 | variable_manager.add_vars( 121 | vars={ 122 | "A": { 123 | "B": "D", 124 | "C": "E" 125 | } 126 | }, 127 | path=tested_component_path, 128 | ) 129 | 130 | # THEN 131 | result = variable_manager.get_vars(tested_component_path) 132 | assert result == { 133 | "A": { 134 | "B": "D", 135 | "C": "E" 136 | }, 137 | "global": { 138 | "var": "global_var_value" 139 | }, 140 | 'key_vars2': 'value_vars2', 141 | "key_also_present_in_device_vars": "test_device_value" 142 | } 143 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto -------------------------------------------------------------------------------- /tests/util_test.py: -------------------------------------------------------------------------------- 1 | from openhasp_config_manager.util import contains_nested_dict_key, merge_dict_recursive 2 | from tests import TestBase 3 | 4 | 5 | class TestUtil(TestBase): 6 | 7 | def test_contains_nested_key_true(self, tmp_path): 8 | # GIVEN 9 | d = { 10 | "a": { 11 | "items": { 12 | "b": 1 13 | } 14 | } 15 | } 16 | 17 | # WHEN 18 | result = contains_nested_dict_key(d, "items") 19 | assert result is True 20 | 21 | def test_contains_nested_key_false(self, tmp_path): 22 | # GIVEN 23 | d = { 24 | "a": { 25 | "items": { 26 | "b": 1 27 | } 28 | } 29 | } 30 | 31 | # WHEN 32 | result = contains_nested_dict_key(d, "test") 33 | assert result is False 34 | 35 | def test_merge_dict_recursive(self, tmp_path): 36 | # GIVEN 37 | 38 | d1 = { 39 | "A": { 40 | "B": { 41 | "C": "D", 42 | "D": "E" 43 | } 44 | } 45 | } 46 | 47 | d2 = { 48 | "A": { 49 | "B": { 50 | "D": "E" 51 | } 52 | } 53 | } 54 | 55 | # WHEN 56 | result = merge_dict_recursive(d1, d2) 57 | 58 | # THEN 59 | assert result == { 60 | "A": { 61 | "B": { 62 | "C": "D", 63 | "D": "E" 64 | } 65 | } 66 | } 67 | assert (d1 | d2) != { 68 | "A": { 69 | "B": { 70 | "C": "D", 71 | "D": "E" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/validation/cmd_validator_test.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from openhasp_config_manager.validation.cmd import CmdFileValidator 4 | from tests import TestBase 5 | 6 | 7 | class TestCmdFileValidator(TestBase): 8 | 9 | def test_single_jsonl_command_valid(self): 10 | # GIVEN 11 | under_test = CmdFileValidator() 12 | 13 | data = textwrap.dedent(""" 14 | jsonl {"obj":"btn","id":14,"x":120,"y":1,"w":30,"h":40,"text_font":"2","text":"Test","text_color":"gray","bg_opa":0,"border_width":0} 15 | """).strip() 16 | 17 | # WHEN 18 | under_test.validate(data) 19 | 20 | # THEN 21 | assert True 22 | 23 | def test_single_jsonl_command_invalid(self): 24 | # GIVEN 25 | under_test = CmdFileValidator() 26 | 27 | data = textwrap.dedent(""" 28 | jsonl {"obj":"btn","id":14,"x":120,"y":1,"w":30,"h":40,"text_font":"2","text":"Test","text_color":"gray","bg_opa":0,"border_width":0 29 | """).strip() 30 | 31 | try: 32 | # WHEN 33 | under_test.validate(data=data) 34 | assert False 35 | except Exception as ex: 36 | # THEN 37 | assert "jsonl command argument cannot be parsed" in str(ex) 38 | -------------------------------------------------------------------------------- /tests/validation/device_validator_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from openhasp_config_manager.openhasp_client.model.component import TextComponent 4 | from openhasp_config_manager.validation.cmd import CmdFileValidator 5 | from openhasp_config_manager.validation.device_validator import DeviceValidator 6 | from openhasp_config_manager.validation.jsonl import JsonlObjectValidator 7 | from tests import TestBase 8 | 9 | 10 | class TestDeviceValidator(TestBase): 11 | 12 | def test_example_config(self): 13 | # GIVEN 14 | device_validator = DeviceValidator( 15 | config=self.default_config, 16 | # TODO: mock validators 17 | jsonl_object_validator=JsonlObjectValidator(), 18 | cmd_file_validator=CmdFileValidator() 19 | ) 20 | 21 | component = TextComponent( 22 | name="name", 23 | type="", 24 | path=Path(), 25 | content="", 26 | ) 27 | data = "" 28 | 29 | # WHEN 30 | device_validator.validate( 31 | component=component, 32 | data=data 33 | ) 34 | 35 | # THEN 36 | assert True 37 | -------------------------------------------------------------------------------- /tests/validation/jsonl_object_validator_test.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from openhasp_config_manager.validation.jsonl import JsonlObjectValidator 4 | from tests import TestBase 5 | 6 | 7 | class TestJsonlObjectValidator(TestBase): 8 | 9 | def test_single_object_valid(self): 10 | # GIVEN 11 | under_test = JsonlObjectValidator() 12 | 13 | data = textwrap.dedent(""" 14 | {"page": 0, "id": 5, "obj": "btn", "action": "prev", "x": 0, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue141", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 28} 15 | """).strip() 16 | 17 | # WHEN 18 | under_test.validate(data=data) 19 | 20 | # THEN 21 | assert True 22 | 23 | def test_multi_object_valid(self): 24 | # GIVEN 25 | under_test = JsonlObjectValidator() 26 | 27 | data = textwrap.dedent(""" 28 | {"page": 1, "id": 0, "prev": 9} 29 | {"page": 9, "id": 0, "next": 1} 30 | {"page": 0, "id": 5, "obj": "btn", "action": "prev", "x": 0, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue141", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 28} 31 | {"page": 0, "id": 6, "obj": "btn", "action": "back", "x": 161, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue2dc", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 22} 32 | {"page": 0, "id": 7, "obj": "btn", "action": "next", "x": 322, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue142", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 28} 33 | """).strip() 34 | 35 | # WHEN 36 | under_test.validate(data=data) 37 | 38 | # THEN 39 | assert True 40 | 41 | def test_single_object_invalid_wrong_id_range(self): 42 | # GIVEN 43 | under_test = JsonlObjectValidator() 44 | 45 | data = textwrap.dedent(""" 46 | {"page": 0, "id": 300, "obj": "btn"} 47 | """).strip() 48 | 49 | try: 50 | # WHEN 51 | under_test.validate(data=data) 52 | assert False 53 | except Exception as ex: 54 | # THEN 55 | assert str(ex) == "Object has invalid id '300', must be in range: [0..254]" 56 | 57 | def test_single_object_invalid_duplicate_id(self): 58 | # GIVEN 59 | under_test = JsonlObjectValidator() 60 | 61 | data = textwrap.dedent(""" 62 | {"page": 0, "id": 5, "obj": "btn", "action": "prev", "x": 0, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue141", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 28} 63 | {"page": 0, "id": 5, "obj": "btn", "action": "prev", "x": 0, "y": 290, "w": 159, "h": 30, "bg_color": "#2C3E50", "text": "\ue141", "text_color": "#FFFFFF", "radius": 0, "border_side": 0, "text_font": 28} 64 | """).strip() 65 | 66 | try: 67 | # WHEN 68 | under_test.validate(data=data) 69 | assert False 70 | except Exception as ex: 71 | # THEN 72 | assert "Duplicate id detected: p0b5" in str(ex) 73 | 74 | def test_single_object_invalid_wrong_align_keyword(self): 75 | # GIVEN 76 | under_test = JsonlObjectValidator() 77 | 78 | data = textwrap.dedent(""" 79 | {"page": 0, "id": 5, "obj": "btn", "align": "wrong" } 80 | """).strip() 81 | 82 | try: 83 | # WHEN 84 | under_test.validate(data=data) 85 | assert False 86 | except Exception as ex: 87 | # THEN 88 | assert str( 89 | ex) == "Invalid 'align' string value: 'wrong', must be one of: ['left', 'center', 'right', 0, 1, 2]" 90 | 91 | def test_single_object_invalid_wrong_align_number(self): 92 | # GIVEN 93 | under_test = JsonlObjectValidator() 94 | 95 | data = textwrap.dedent(""" 96 | {"page": 0, "id": 5, "obj": "btn", "align": 4 } 97 | """).strip() 98 | 99 | try: 100 | # WHEN 101 | under_test.validate(data=data) 102 | assert False 103 | except Exception as ex: 104 | # THEN 105 | assert str(ex) == "Invalid 'align' integer value: '4', must be one of: ['left', 'center', 'right', 0, 1, 2]" 106 | --------------------------------------------------------------------------------