├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── hacs.yaml │ ├── hassfest.yaml │ └── release.yaml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── custom_components └── grocy │ ├── __init__.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── entity.py │ ├── grocy_data.py │ ├── helpers.py │ ├── json_encoder.py │ ├── manifest.json │ ├── sensor.py │ ├── services.py │ ├── services.yaml │ └── translations │ └── en.json ├── grocy-addon-config.png ├── grocy-integration-config.png └── hacs.json /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.grocy: debug 7 | pygrocy.grocy_api_client: debug 8 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "ghcr.io/ludeeus/devcontainer/integration:stable", 3 | "context": "..", 4 | "appPort": [ 5 | "9123:8123" 6 | ], 7 | "postCreateCommand": "for req in $(jq -c -r '.requirements | .[]' custom_components/grocy/manifest.json); do python -m pip install $req; done && container install", 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "tabnine.tabnine-vscode", 12 | "ms-python.vscode-pylance" 13 | ], 14 | "settings": { 15 | "files.eol": "\n", 16 | "editor.tabSize": 4, 17 | "python.pythonPath": "/usr/bin/python3", 18 | "python.linting.pylintEnabled": true, 19 | "python.linting.enabled": true, 20 | "python.formatting.provider": "black", 21 | "editor.formatOnPaste": false, 22 | "editor.formatOnSave": true, 23 | "editor.formatOnType": true, 24 | "files.trimTrailingWhitespace": true 25 | } 26 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | ## Unless all relevant information is provided, I can't help you 10 | 11 | **Describe the bug** 12 | A clear and concise description of what the bug is. 13 | 14 | **Expected behavior** 15 | A clear and concise description of what you expected to happen. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | ### General information to help debugging: 25 | **What sensors do you have enabled? Are they working and/or what state are they in? Do you have the corresponding functions enabled in Grocy?** 26 | 27 | **What is your installed versions of Home Assistant, Grocy and this integration?** 28 | 29 | **How do you have Grocy installed? Add-on or external?** 30 | 31 | **Have you added debugging to the log, and what does the log say?** 32 | 33 | **JSON service data (if related to using a service)** 34 | ```json 35 | { 36 | 37 | } 38 | ``` 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with HACS 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | validate: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | - name: HACS Action 19 | uses: "hacs/action@main" 20 | with: 21 | category: "integration" 22 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | validate: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | - uses: home-assistant/actions/hassfest@master 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release_zip_file: 9 | name: Prepare and upload release asset 10 | runs-on: ubuntu-latest 11 | env: 12 | GROCY_ROOT_DIR: "${{ github.workspace }}/custom_components/grocy" 13 | steps: 14 | - name: Check out repository 15 | uses: actions/checkout@v1 16 | 17 | - name: Download Lokalise CLI 18 | run: | 19 | curl -sfL https://raw.githubusercontent.com/lokalise/lokalise-cli-2-go/master/install.sh | sh 20 | 21 | - name: Download latest translations with Lokalise 22 | run: | 23 | ./bin/lokalise2 \ 24 | --token "${{ secrets.lokalise_token }}"\ 25 | --project-id "260939135f7593a05f2b79.75475372" \ 26 | file download \ 27 | --format json \ 28 | --unzip-to /tmp/lokalise \ 29 | --export-empty-as skip \ 30 | --export-sort a_z \ 31 | --original-filenames=false \ 32 | --bundle-structure %LANG_ISO%.%FORMAT% 33 | 34 | - name: Move downloaded translations 35 | run: | 36 | mkdir -p "${{ env.GROCY_ROOT_DIR }}/translations/" 37 | cp /tmp/lokalise/* "${{ env.GROCY_ROOT_DIR }}/translations/" 38 | 39 | - name: Set release version number in files 40 | run: | 41 | sed -i '/VERSION = /c\VERSION = "${{ github.ref_name }}"' "${{ env.GROCY_ROOT_DIR }}/const.py" 42 | (jq '.version = "${{ github.ref_name }}"' "${{ env.GROCY_ROOT_DIR }}/manifest.json") > "${{ env.GROCY_ROOT_DIR }}/manifest.json.tmp" 43 | mv "${{ env.GROCY_ROOT_DIR }}/manifest.json.tmp" "${{ env.GROCY_ROOT_DIR }}/manifest.json" 44 | 45 | - name: Add Grocy folder to zip archive 46 | run: | 47 | cd "${{ env.GROCY_ROOT_DIR }}" 48 | zip grocy.zip -r ./ 49 | 50 | - name: Upload release asset 51 | uses: actions/upload-release-asset@v1 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | with: 55 | upload_url: ${{ github.event.release.upload_url }} 56 | asset_path: "${{ env.GROCY_ROOT_DIR }}/grocy.zip" 57 | asset_name: grocy.zip 58 | asset_content_type: application/zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that dont work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # End of https://www.gitignore.io/api/python 131 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) 2 | 3 | --- 4 | **INFO** 5 | 6 | **The integration supports Grocy version 3.2 and above.** 7 | 8 | **At least Home Assistant version 2021.12 is required for the integration from v4.3.3 and above.** 9 | 10 | You have to have the Grocy software already installed and running, this integration only communicates with an existing installation of Grocy. You can install the software with the [Grocy add-on](https://github.com/hassio-addons/addon-grocy) or another installation method, found at [Grocy website](https://grocy.info/). 11 | 12 | --- 13 | 14 | # Adding the integration 15 | 16 | ## First steps for Grocy add-on 17 | The configuration is slightly different for those who use the [official Grocy add-on](https://github.com/hassio-addons/addon-grocy) from the add-on store. 18 | 19 | 1. If you haven't already done so, install Grocy from the add-on store. 20 | 2. In the 'Configuration' section of the add-on config, input `9192` in the Network port field - see [screenshot](#screenshot-addon-config). Save your changes and restart the add-on. 21 | 3. Now continue with the instructions below. 22 | 23 | ## Instructions for both installation methods 24 | 1. Install [HACS](https://hacs.xyz/), if you haven't already done so. 25 | 2. In Home Assistant go to HACS > Integrations. Search for the "Grocy custom component" repository by clicking the "Explore & download repositories" button in the bottom right corner. 26 | 3. In the bottom right corner click "Download the repository with HACS". Click "Download". Optional: for latest features and fixes choose "Show beta versions". 27 | 4. Restart Home Assistant as instructed by HACS. 28 | 5. Install the [Grocy integration](https://my.home-assistant.io/redirect/config_flow_start/?domain=grocy). Fill out the information according to [this instruction](#integration-configuration). 29 | 6. Before integration version v4.3.3, now restart Home Assistant again (with later versions you can skip this step). 30 | 7. You will now have a new integration for Grocy. All entities are disabled from start, manually enable the entities you want to use. 31 | 32 | Future integration updates will appear automatically within Home Assistant via HACS. 33 | 34 | 35 | # Entities 36 | 37 | **All entities are disabled from the start. You have to manually enable the entities you want to use in Home Assistant.** 38 | You get a sensor each for chores, meal plan, shopping list, stock, tasks and batteries. 39 | You get a binary sensor each for overdue, expired, expiring and missing products and for overdue tasks, overdue chores and overdue batteries. 40 | 41 | 42 | # Services 43 | 44 | The following services come with the integration. For all available options check the [Developer Tools: Services](https://my.home-assistant.io/redirect/developer_services/) within Home Assistant. 45 | 46 | - **Grocy: Add Generic** (_grocy.add_generic_) 47 | 48 | Adds a single object of the given entity type. 49 | 50 | - **Grocy: Add Product To Stock** (_grocy.add_product_to_stock_) 51 | 52 | Adds a given amount of a product to the stock. 53 | 54 | - **Grocy: Open Product** (_grocy.open_product_) 55 | 56 | Opens a given amount of a product in stock. 57 | 58 | - **Grocy: Track Battery** (_grocy.track_battery_) 59 | 60 | Tracks the given battery. 61 | 62 | - **Grocy: Complete Task** (_grocy.complete_task_) 63 | 64 | Completes the given task. 65 | 66 | - **Grocy: Consume Product From Stock** (_grocy.consume_product_from_stock_) 67 | 68 | Consumes a given amount of a product from the stock. 69 | 70 | - **Grocy: Execute Chore** (_grocy.execute_chore_) 71 | 72 | Executes the given chore with an optional timestamp and executor. 73 | 74 | - **Grocy: Consume Recipe** (_grocy.consume_recipe_) 75 | 76 | Consumes the given recipe. 77 | 78 | - **Grocy: Add Missing Products to Shopping List** (_grocy.add_missing_products_to_shopping_list_) 79 | 80 | Adds currently missing products to a given shopping list. 81 | 82 | - **Grocy: Remove Product in Shopping List** (_grocy.remove_product_in_shopping_list_) 83 | 84 | Removes a product in the given shopping list. 85 | 86 | # Translations 87 | 88 | Translations are done via [Lokalise](https://app.lokalise.com/public/260939135f7593a05f2b79.75475372/). If you want to translate into your native language, please [join the team](https://app.lokalise.com/public/260939135f7593a05f2b79.75475372/). 89 | 90 | 91 | # Troubleshooting 92 | 93 | If you have problems with the integration you can add debug prints to the log. 94 | 95 | ```yaml 96 | logger: 97 | default: info 98 | logs: 99 | pygrocy.grocy_api_client: debug 100 |    custom_components.grocy: debug 101 | ``` 102 | 103 | If you are having issues and want to report a problem, always start with making sure that you're on the latest _beta_ version of the integration, Grocy and Home Assistant. 104 | 105 | You can ask for help [in the forums](https://community.home-assistant.io/t/grocy-custom-component-and-card-s/218978), or [make an issue with all of the relevant information here](https://github.com/custom-components/grocy/issues/new?assignees=&labels=&template=bug_report.md&title=). 106 | 107 | 108 | # Integration configuration 109 | 110 | ## URL 111 | The Grocy url should be in the form below (start with `http://` or `https://`) and point to your Grocy instance. If you use a SSL certificate you should have `https` and also check the "Verify SSL Certificate" box. Do **not** enter a port in the url field. Subdomains are also supported, fill out the full url in the field. 112 | 113 | ## API key 114 | Go to your Grocy instance. Navigate via the wrench icon in the top right corner to "Manage API keys" and add a new API key. Copy and paste the generated key. 115 | 116 | ## Port 117 | It should work with for example a Duck DNS address as well, but you still have to access it via a port, and the above instructions for the url still apply. 118 | - If you have configured the [Grocy add-on](#addon) as described, use port 9192 (without https). Either be sure the port is open in your router or use your internal Home Assistant address. 119 | - If you have configured an [external Grocy](#both) instance and not sure, use port 80 for http or port 443 for https. Unless you have set a custom port for Grocy. 120 | 121 | ![alt text](grocy-integration-config.png) 122 | 123 | 124 | # Add-on port configuration 125 | 126 | ![alt text](grocy-addon-config.png) 127 | -------------------------------------------------------------------------------- /custom_components/grocy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate Grocy with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/custom-components/grocy 6 | """ 7 | from __future__ import annotations 8 | 9 | import logging 10 | from typing import List 11 | 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | 15 | from .const import ( 16 | ATTR_BATTERIES, 17 | ATTR_CHORES, 18 | ATTR_EXPIRED_PRODUCTS, 19 | ATTR_EXPIRING_PRODUCTS, 20 | ATTR_MEAL_PLAN, 21 | ATTR_MISSING_PRODUCTS, 22 | ATTR_OVERDUE_BATTERIES, 23 | ATTR_OVERDUE_CHORES, 24 | ATTR_OVERDUE_PRODUCTS, 25 | ATTR_OVERDUE_TASKS, 26 | ATTR_SHOPPING_LIST, 27 | ATTR_STOCK, 28 | ATTR_TASKS, 29 | DOMAIN, 30 | PLATFORMS, 31 | STARTUP_MESSAGE, 32 | ) 33 | from .coordinator import GrocyDataUpdateCoordinator 34 | from .grocy_data import GrocyData, async_setup_endpoint_for_image_proxy 35 | from .services import async_setup_services, async_unload_services 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): 41 | """Set up this integration using UI.""" 42 | _LOGGER.info(STARTUP_MESSAGE) 43 | 44 | coordinator: GrocyDataUpdateCoordinator = GrocyDataUpdateCoordinator(hass) 45 | coordinator.available_entities = await _async_get_available_entities( 46 | coordinator.grocy_data 47 | ) 48 | await coordinator.async_config_entry_first_refresh() 49 | hass.data.setdefault(DOMAIN, {}) 50 | hass.data[DOMAIN] = coordinator 51 | 52 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 53 | await async_setup_services(hass, config_entry) 54 | await async_setup_endpoint_for_image_proxy(hass, config_entry.data) 55 | 56 | return True 57 | 58 | 59 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 60 | """Unload a config entry.""" 61 | await async_unload_services(hass) 62 | if unloaded := await hass.config_entries.async_unload_platforms( 63 | config_entry, PLATFORMS 64 | ): 65 | del hass.data[DOMAIN] 66 | 67 | return unloaded 68 | 69 | 70 | async def _async_get_available_entities(grocy_data: GrocyData) -> List[str]: 71 | """Return a list of available entities based on enabled Grocy features.""" 72 | available_entities = [] 73 | grocy_config = await grocy_data.async_get_config() 74 | if grocy_config: 75 | if "FEATURE_FLAG_STOCK" in grocy_config.enabled_features: 76 | available_entities.append(ATTR_STOCK) 77 | available_entities.append(ATTR_MISSING_PRODUCTS) 78 | available_entities.append(ATTR_EXPIRED_PRODUCTS) 79 | available_entities.append(ATTR_EXPIRING_PRODUCTS) 80 | available_entities.append(ATTR_OVERDUE_PRODUCTS) 81 | 82 | if "FEATURE_FLAG_SHOPPINGLIST" in grocy_config.enabled_features: 83 | available_entities.append(ATTR_SHOPPING_LIST) 84 | 85 | if "FEATURE_FLAG_TASKS" in grocy_config.enabled_features: 86 | available_entities.append(ATTR_TASKS) 87 | available_entities.append(ATTR_OVERDUE_TASKS) 88 | 89 | if "FEATURE_FLAG_CHORES" in grocy_config.enabled_features: 90 | available_entities.append(ATTR_CHORES) 91 | available_entities.append(ATTR_OVERDUE_CHORES) 92 | 93 | if "FEATURE_FLAG_RECIPES" in grocy_config.enabled_features: 94 | available_entities.append(ATTR_MEAL_PLAN) 95 | 96 | if "FEATURE_FLAG_BATTERIES" in grocy_config.enabled_features: 97 | available_entities.append(ATTR_BATTERIES) 98 | available_entities.append(ATTR_OVERDUE_BATTERIES) 99 | 100 | _LOGGER.debug("Available entities: %s", available_entities) 101 | 102 | return available_entities 103 | -------------------------------------------------------------------------------- /custom_components/grocy/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for Grocy.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from collections.abc import Callable, Mapping 6 | from dataclasses import dataclass 7 | from typing import Any, List 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorEntity, 11 | BinarySensorEntityDescription, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from .const import ( 18 | ATTR_EXPIRED_PRODUCTS, 19 | ATTR_EXPIRING_PRODUCTS, 20 | ATTR_MISSING_PRODUCTS, 21 | ATTR_OVERDUE_BATTERIES, 22 | ATTR_OVERDUE_CHORES, 23 | ATTR_OVERDUE_PRODUCTS, 24 | ATTR_OVERDUE_TASKS, 25 | DOMAIN, 26 | ) 27 | from .coordinator import GrocyDataUpdateCoordinator 28 | from .entity import GrocyEntity 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | config_entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ): 38 | """Setup binary sensor platform.""" 39 | coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] 40 | entities = [] 41 | for description in BINARY_SENSORS: 42 | if description.exists_fn(coordinator.available_entities): 43 | entity = GrocyBinarySensorEntity(coordinator, description, config_entry) 44 | coordinator.entities.append(entity) 45 | entities.append(entity) 46 | else: 47 | _LOGGER.debug( 48 | "Entity description '%s' is not available.", 49 | description.key, 50 | ) 51 | 52 | async_add_entities(entities, True) 53 | 54 | 55 | @dataclass 56 | class GrocyBinarySensorEntityDescription(BinarySensorEntityDescription): 57 | """Grocy binary sensor entity description.""" 58 | 59 | attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None 60 | exists_fn: Callable[[List[str]], bool] = lambda _: True 61 | entity_registry_enabled_default: bool = False 62 | 63 | 64 | BINARY_SENSORS: tuple[GrocyBinarySensorEntityDescription, ...] = ( 65 | GrocyBinarySensorEntityDescription( 66 | key=ATTR_EXPIRED_PRODUCTS, 67 | name="Grocy expired products", 68 | icon="mdi:delete-alert-outline", 69 | exists_fn=lambda entities: ATTR_EXPIRED_PRODUCTS in entities, 70 | attributes_fn=lambda data: { 71 | "expired_products": [x.as_dict() for x in data], 72 | "count": len(data), 73 | }, 74 | ), 75 | GrocyBinarySensorEntityDescription( 76 | key=ATTR_EXPIRING_PRODUCTS, 77 | name="Grocy expiring products", 78 | icon="mdi:clock-fast", 79 | exists_fn=lambda entities: ATTR_EXPIRING_PRODUCTS in entities, 80 | attributes_fn=lambda data: { 81 | "expiring_products": [x.as_dict() for x in data], 82 | "count": len(data), 83 | }, 84 | ), 85 | GrocyBinarySensorEntityDescription( 86 | key=ATTR_OVERDUE_PRODUCTS, 87 | name="Grocy overdue products", 88 | icon="mdi:alert-circle-check-outline", 89 | exists_fn=lambda entities: ATTR_OVERDUE_PRODUCTS in entities, 90 | attributes_fn=lambda data: { 91 | "overdue_products": [x.as_dict() for x in data], 92 | "count": len(data), 93 | }, 94 | ), 95 | GrocyBinarySensorEntityDescription( 96 | key=ATTR_MISSING_PRODUCTS, 97 | name="Grocy missing products", 98 | icon="mdi:flask-round-bottom-empty-outline", 99 | exists_fn=lambda entities: ATTR_MISSING_PRODUCTS in entities, 100 | attributes_fn=lambda data: { 101 | "missing_products": [x.as_dict() for x in data], 102 | "count": len(data), 103 | }, 104 | ), 105 | GrocyBinarySensorEntityDescription( 106 | key=ATTR_OVERDUE_CHORES, 107 | name="Grocy overdue chores", 108 | icon="mdi:alert-circle-check-outline", 109 | exists_fn=lambda entities: ATTR_OVERDUE_CHORES in entities, 110 | attributes_fn=lambda data: { 111 | "overdue_chores": [x.as_dict() for x in data], 112 | "count": len(data), 113 | }, 114 | ), 115 | GrocyBinarySensorEntityDescription( 116 | key=ATTR_OVERDUE_TASKS, 117 | name="Grocy overdue tasks", 118 | icon="mdi:alert-circle-check-outline", 119 | exists_fn=lambda entities: ATTR_OVERDUE_TASKS in entities, 120 | attributes_fn=lambda data: { 121 | "overdue_tasks": [x.as_dict() for x in data], 122 | "count": len(data), 123 | }, 124 | ), 125 | GrocyBinarySensorEntityDescription( 126 | key=ATTR_OVERDUE_BATTERIES, 127 | name="Grocy overdue batteries", 128 | icon="mdi:battery-charging-10", 129 | exists_fn=lambda entities: ATTR_OVERDUE_BATTERIES in entities, 130 | attributes_fn=lambda data: { 131 | "overdue_batteries": [x.as_dict() for x in data], 132 | "count": len(data), 133 | }, 134 | ), 135 | ) 136 | 137 | 138 | class GrocyBinarySensorEntity(GrocyEntity, BinarySensorEntity): 139 | """Grocy binary sensor entity definition.""" 140 | 141 | @property 142 | def is_on(self) -> bool | None: 143 | """Return true if the binary sensor is on.""" 144 | entity_data = self.coordinator.data.get(self.entity_description.key, None) 145 | 146 | return len(entity_data) > 0 if entity_data else False 147 | -------------------------------------------------------------------------------- /custom_components/grocy/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for Grocy.""" 2 | import logging 3 | from collections import OrderedDict 4 | 5 | import voluptuous as vol 6 | from homeassistant import config_entries 7 | from pygrocy2.grocy import Grocy 8 | 9 | from .const import ( 10 | CONF_API_KEY, 11 | CONF_PORT, 12 | CONF_URL, 13 | CONF_VERIFY_SSL, 14 | DEFAULT_PORT, 15 | DOMAIN, 16 | NAME, 17 | ) 18 | from .helpers import extract_base_url_and_path 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | class GrocyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 24 | """Config flow for Grocy.""" 25 | 26 | VERSION = 1 27 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 28 | 29 | def __init__(self): 30 | """Initialize.""" 31 | self._errors = {} 32 | 33 | async def async_step_user(self, user_input=None): 34 | """Handle a flow initialized by the user.""" 35 | self._errors = {} 36 | _LOGGER.debug("Step user") 37 | 38 | if self._async_current_entries(): 39 | return self.async_abort(reason="single_instance_allowed") 40 | 41 | if user_input is not None: 42 | valid = await self._test_credentials( 43 | user_input[CONF_URL], 44 | user_input[CONF_API_KEY], 45 | user_input[CONF_PORT], 46 | user_input[CONF_VERIFY_SSL], 47 | ) 48 | _LOGGER.debug("Testing of credentials returned: ") 49 | _LOGGER.debug(valid) 50 | if valid: 51 | return self.async_create_entry(title=NAME, data=user_input) 52 | 53 | self._errors["base"] = "auth" 54 | return await self._show_config_form(user_input) 55 | 56 | return await self._show_config_form(user_input) 57 | 58 | async def _show_config_form(self, user_input): # pylint: disable=unused-argument 59 | """Show the configuration form to edit the data.""" 60 | data_schema = OrderedDict() 61 | data_schema[vol.Required(CONF_URL, default="")] = str 62 | data_schema[ 63 | vol.Required( 64 | CONF_API_KEY, 65 | default="", 66 | ) 67 | ] = str 68 | data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int 69 | data_schema[vol.Optional(CONF_VERIFY_SSL, default=False)] = bool 70 | _LOGGER.debug("config form") 71 | 72 | return self.async_show_form( 73 | step_id="user", 74 | data_schema=vol.Schema(data_schema), 75 | errors=self._errors, 76 | ) 77 | 78 | async def _test_credentials(self, url, api_key, port, verify_ssl): 79 | """Return true if credentials is valid.""" 80 | try: 81 | (base_url, path) = extract_base_url_and_path(url) 82 | client = Grocy( 83 | base_url, api_key, port=port, path=path, verify_ssl=verify_ssl 84 | ) 85 | 86 | _LOGGER.debug("Testing credentials") 87 | 88 | def system_info(): 89 | """Get system information from Grocy.""" 90 | return client.get_system_info() 91 | 92 | await self.hass.async_add_executor_job(system_info) 93 | return True 94 | except Exception as error: # pylint: disable=broad-except 95 | _LOGGER.error(error) 96 | return False 97 | -------------------------------------------------------------------------------- /custom_components/grocy/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Grocy.""" 2 | from datetime import timedelta 3 | from typing import Final 4 | 5 | NAME: Final = "Grocy" 6 | DOMAIN: Final = "grocy" 7 | VERSION = "0.0.0" 8 | 9 | ISSUE_URL: Final = "https://github.com/custom-components/grocy/issues" 10 | 11 | PLATFORMS: Final = ["binary_sensor", "sensor"] 12 | 13 | SCAN_INTERVAL = timedelta(seconds=30) 14 | 15 | DEFAULT_PORT: Final = 9192 16 | CONF_URL: Final = "url" 17 | CONF_PORT: Final = "port" 18 | CONF_API_KEY: Final = "api_key" 19 | CONF_VERIFY_SSL: Final = "verify_ssl" 20 | 21 | STARTUP_MESSAGE: Final = f""" 22 | ------------------------------------------------------------------- 23 | {NAME} 24 | Version: {VERSION} 25 | This is a custom integration! 26 | If you have any issues with this you need to open an issue here: 27 | {ISSUE_URL} 28 | ------------------------------------------------------------------- 29 | """ 30 | 31 | CHORES: Final = "Chore(s)" 32 | MEAL_PLANS: Final = "Meal(s)" 33 | PRODUCTS: Final = "Product(s)" 34 | TASKS: Final = "Task(s)" 35 | ITEMS: Final = "Item(s)" 36 | 37 | ATTR_BATTERIES: Final = "batteries" 38 | ATTR_CHORES: Final = "chores" 39 | ATTR_EXPIRED_PRODUCTS: Final = "expired_products" 40 | ATTR_EXPIRING_PRODUCTS: Final = "expiring_products" 41 | ATTR_MEAL_PLAN: Final = "meal_plan" 42 | ATTR_MISSING_PRODUCTS: Final = "missing_products" 43 | ATTR_OVERDUE_BATTERIES: Final = "overdue_batteries" 44 | ATTR_OVERDUE_CHORES: Final = "overdue_chores" 45 | ATTR_OVERDUE_PRODUCTS: Final = "overdue_products" 46 | ATTR_OVERDUE_TASKS: Final = "overdue_tasks" 47 | ATTR_SHOPPING_LIST: Final = "shopping_list" 48 | ATTR_STOCK: Final = "stock" 49 | ATTR_TASKS: Final = "tasks" 50 | -------------------------------------------------------------------------------- /custom_components/grocy/coordinator.py: -------------------------------------------------------------------------------- 1 | """Data update coordinator for Grocy.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any, Dict, List 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity import Entity 9 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 10 | from pygrocy2.grocy import Grocy 11 | 12 | from .const import ( 13 | CONF_API_KEY, 14 | CONF_PORT, 15 | CONF_URL, 16 | CONF_VERIFY_SSL, 17 | DOMAIN, 18 | SCAN_INTERVAL, 19 | ) 20 | from .grocy_data import GrocyData 21 | from .helpers import extract_base_url_and_path 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class GrocyDataUpdateCoordinator(DataUpdateCoordinator[Dict[str, Any]]): 27 | """Grocy data update coordinator.""" 28 | 29 | def __init__( 30 | self, 31 | hass: HomeAssistant, 32 | ) -> None: 33 | """Initialize Grocy data update coordinator.""" 34 | super().__init__( 35 | hass, 36 | _LOGGER, 37 | name=DOMAIN, 38 | update_interval=SCAN_INTERVAL, 39 | ) 40 | 41 | url = self.config_entry.data[CONF_URL] 42 | api_key = self.config_entry.data[CONF_API_KEY] 43 | port = self.config_entry.data[CONF_PORT] 44 | verify_ssl = self.config_entry.data[CONF_VERIFY_SSL] 45 | 46 | (base_url, path) = extract_base_url_and_path(url) 47 | 48 | self.grocy_api = Grocy( 49 | base_url, api_key, path=path, port=port, verify_ssl=verify_ssl 50 | ) 51 | self.grocy_data = GrocyData(hass, self.grocy_api) 52 | 53 | self.available_entities: List[str] = [] 54 | self.entities: List[Entity] = [] 55 | 56 | async def _async_update_data(self) -> dict[str, Any]: 57 | """Fetch data.""" 58 | data: dict[str, Any] = {} 59 | 60 | for entity in self.entities: 61 | if not entity.enabled: 62 | _LOGGER.debug("Entity %s is disabled.", entity.entity_id) 63 | continue 64 | 65 | try: 66 | data[ 67 | entity.entity_description.key 68 | ] = await self.grocy_data.async_update_data( 69 | entity.entity_description.key 70 | ) 71 | except Exception as error: # pylint: disable=broad-except 72 | raise UpdateFailed(f"Update failed: {error}") from error 73 | 74 | return data 75 | -------------------------------------------------------------------------------- /custom_components/grocy/entity.py: -------------------------------------------------------------------------------- 1 | """Entity for Grocy.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from collections.abc import Mapping 6 | from typing import Any 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.helpers.device_registry import DeviceEntryType 10 | from homeassistant.helpers.entity import DeviceInfo, EntityDescription 11 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 12 | 13 | from .const import DOMAIN, NAME, VERSION 14 | from .coordinator import GrocyDataUpdateCoordinator 15 | from .json_encoder import CustomJSONEncoder 16 | 17 | 18 | class GrocyEntity(CoordinatorEntity[GrocyDataUpdateCoordinator]): 19 | """Grocy base entity definition.""" 20 | 21 | def __init__( 22 | self, 23 | coordinator: GrocyDataUpdateCoordinator, 24 | description: EntityDescription, 25 | config_entry: ConfigEntry, 26 | ) -> None: 27 | """Initialize entity.""" 28 | super().__init__(coordinator) 29 | self._attr_name = description.name 30 | self._attr_unique_id = f"{config_entry.entry_id}{description.key.lower()}" 31 | self.entity_description = description 32 | 33 | @property 34 | def device_info(self) -> DeviceInfo: 35 | """Grocy device information.""" 36 | return DeviceInfo( 37 | identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, 38 | name=NAME, 39 | manufacturer=NAME, 40 | sw_version=VERSION, 41 | entry_type=DeviceEntryType.SERVICE, 42 | ) 43 | 44 | @property 45 | def extra_state_attributes(self) -> Mapping[str, Any] | None: 46 | """Return the extra state attributes.""" 47 | data = self.coordinator.data.get(self.entity_description.key) 48 | if data and hasattr(self.entity_description, "attributes_fn"): 49 | return json.loads( 50 | json.dumps( 51 | self.entity_description.attributes_fn(data), 52 | cls=CustomJSONEncoder, 53 | ) 54 | ) 55 | 56 | return None 57 | -------------------------------------------------------------------------------- /custom_components/grocy/grocy_data.py: -------------------------------------------------------------------------------- 1 | """Communication with Grocy API.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from datetime import datetime, timedelta 6 | from typing import List 7 | 8 | from aiohttp import hdrs, web 9 | from homeassistant.components.http import HomeAssistantView 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | from pygrocy2.data_models.battery import Battery 14 | 15 | from .const import ( 16 | ATTR_BATTERIES, 17 | ATTR_CHORES, 18 | ATTR_EXPIRED_PRODUCTS, 19 | ATTR_EXPIRING_PRODUCTS, 20 | ATTR_MEAL_PLAN, 21 | ATTR_MISSING_PRODUCTS, 22 | ATTR_OVERDUE_BATTERIES, 23 | ATTR_OVERDUE_CHORES, 24 | ATTR_OVERDUE_PRODUCTS, 25 | ATTR_OVERDUE_TASKS, 26 | ATTR_SHOPPING_LIST, 27 | ATTR_STOCK, 28 | ATTR_TASKS, 29 | CONF_API_KEY, 30 | CONF_PORT, 31 | CONF_URL, 32 | ) 33 | from .helpers import ProductWrapper, MealPlanItemWrapper, extract_base_url_and_path 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | class GrocyData: 39 | """Handles communication and gets the data.""" 40 | 41 | def __init__(self, hass, api): 42 | """Initialize Grocy data.""" 43 | self.hass = hass 44 | self.api = api 45 | self.entity_update_method = { 46 | ATTR_STOCK: self.async_update_stock, 47 | ATTR_CHORES: self.async_update_chores, 48 | ATTR_TASKS: self.async_update_tasks, 49 | ATTR_SHOPPING_LIST: self.async_update_shopping_list, 50 | ATTR_EXPIRING_PRODUCTS: self.async_update_expiring_products, 51 | ATTR_EXPIRED_PRODUCTS: self.async_update_expired_products, 52 | ATTR_OVERDUE_PRODUCTS: self.async_update_overdue_products, 53 | ATTR_MISSING_PRODUCTS: self.async_update_missing_products, 54 | ATTR_MEAL_PLAN: self.async_update_meal_plan, 55 | ATTR_OVERDUE_CHORES: self.async_update_overdue_chores, 56 | ATTR_OVERDUE_TASKS: self.async_update_overdue_tasks, 57 | ATTR_BATTERIES: self.async_update_batteries, 58 | ATTR_OVERDUE_BATTERIES: self.async_update_overdue_batteries, 59 | } 60 | 61 | async def async_update_data(self, entity_key): 62 | """Update data.""" 63 | if entity_key in self.entity_update_method: 64 | return await self.entity_update_method[entity_key]() 65 | 66 | async def async_update_stock(self): 67 | """Update stock data.""" 68 | 69 | def wrapper(): 70 | return [ProductWrapper(item, self.hass) for item in self.api._api_client.get_stock()] 71 | 72 | return await self.hass.async_add_executor_job(wrapper) 73 | 74 | async def async_update_chores(self): 75 | """Update chores data.""" 76 | 77 | def wrapper(): 78 | return self.api.chores(True) 79 | 80 | return await self.hass.async_add_executor_job(wrapper) 81 | 82 | async def async_update_overdue_chores(self): 83 | """Update overdue chores data.""" 84 | 85 | query_filter = [f"next_estimated_execution_time<{datetime.now()}"] 86 | 87 | def wrapper(): 88 | return self.api.chores(get_details=True, query_filters=query_filter) 89 | 90 | return await self.hass.async_add_executor_job(wrapper) 91 | 92 | async def async_get_config(self): 93 | """Get the configuration from Grocy.""" 94 | 95 | def wrapper(): 96 | return self.api.get_system_config() 97 | 98 | return await self.hass.async_add_executor_job(wrapper) 99 | 100 | async def async_update_tasks(self): 101 | """Update tasks data.""" 102 | 103 | return await self.hass.async_add_executor_job(self.api.tasks) 104 | 105 | async def async_update_overdue_tasks(self): 106 | """Update overdue tasks data.""" 107 | 108 | and_query_filter = [ 109 | f"due_date<{datetime.now().date()}", 110 | # It's not possible to pass an empty value to Grocy, so use a regex that matches non-empty values to exclude empty str due_date. 111 | r"due_date§.*\S.*", 112 | ] 113 | 114 | def wrapper(): 115 | return self.api.tasks(query_filters=and_query_filter) 116 | 117 | return await self.hass.async_add_executor_job(wrapper) 118 | 119 | async def async_update_shopping_list(self): 120 | """Update shopping list data.""" 121 | 122 | def wrapper(): 123 | return self.api.shopping_list(True) 124 | 125 | return await self.hass.async_add_executor_job(wrapper) 126 | 127 | async def async_update_expiring_products(self): 128 | """Update expiring products data.""" 129 | 130 | def wrapper(): 131 | return self.api.due_products(True) 132 | 133 | return await self.hass.async_add_executor_job(wrapper) 134 | 135 | async def async_update_expired_products(self): 136 | """Update expired products data.""" 137 | 138 | def wrapper(): 139 | return self.api.expired_products(True) 140 | 141 | return await self.hass.async_add_executor_job(wrapper) 142 | 143 | async def async_update_overdue_products(self): 144 | """Update overdue products data.""" 145 | 146 | def wrapper(): 147 | return self.api.overdue_products(True) 148 | 149 | return await self.hass.async_add_executor_job(wrapper) 150 | 151 | async def async_update_missing_products(self): 152 | """Update missing products data.""" 153 | 154 | def wrapper(): 155 | return self.api.missing_products(True) 156 | 157 | return await self.hass.async_add_executor_job(wrapper) 158 | 159 | async def async_update_meal_plan(self): 160 | """Update meal plan data.""" 161 | 162 | # The >= condition is broken before Grocy 3.3.1. So use > to maintain backward compatibility. 163 | yesterday = datetime.now() - timedelta(1) 164 | query_filter = [f"day>{yesterday.date()}"] 165 | 166 | def wrapper(): 167 | meal_plan = self.api.meal_plan(get_details=True, query_filters=query_filter) 168 | plan = [MealPlanItemWrapper(item) for item in meal_plan] 169 | return sorted(plan, key=lambda item: item.meal_plan.day) 170 | 171 | return await self.hass.async_add_executor_job(wrapper) 172 | 173 | async def async_update_batteries(self) -> List[Battery]: 174 | """Update batteries.""" 175 | 176 | def wrapper(): 177 | return self.api.batteries(get_details=True) 178 | 179 | return await self.hass.async_add_executor_job(wrapper) 180 | 181 | async def async_update_overdue_batteries(self) -> List[Battery]: 182 | """Update overdue batteries.""" 183 | 184 | def wrapper(): 185 | filter_query = [f"next_estimated_charge_time<{datetime.now()}"] 186 | return self.api.batteries(filter_query, get_details=True) 187 | 188 | return await self.hass.async_add_executor_job(wrapper) 189 | 190 | 191 | async def async_setup_endpoint_for_image_proxy( 192 | hass: HomeAssistant, config_entry: ConfigEntry 193 | ): 194 | """Setup and register the image api for grocy images with HA.""" 195 | session = async_get_clientsession(hass) 196 | 197 | url = config_entry.get(CONF_URL) 198 | (grocy_base_url, grocy_path) = extract_base_url_and_path(url) 199 | api_key = config_entry.get(CONF_API_KEY) 200 | port_number = config_entry.get(CONF_PORT) 201 | if grocy_path: 202 | grocy_full_url = f"{grocy_base_url}:{port_number}/{grocy_path}" 203 | else: 204 | grocy_full_url = f"{grocy_base_url}:{port_number}" 205 | 206 | _LOGGER.debug("Generated image api url to grocy: '%s'", grocy_full_url) 207 | hass.http.register_view(GrocyPictureView(session, grocy_full_url, api_key)) 208 | 209 | 210 | class GrocyPictureView(HomeAssistantView): 211 | """View to render pictures from grocy without auth.""" 212 | 213 | requires_auth = False 214 | url = "/api/grocy/{picture_type}/{filename}" 215 | name = "api:grocy:picture" 216 | 217 | def __init__(self, session, base_url, api_key): 218 | self._session = session 219 | self._base_url = base_url 220 | self._api_key = api_key 221 | 222 | async def get(self, request, picture_type: str, filename: str) -> web.Response: 223 | """GET request for the image.""" 224 | width = request.query.get("width", 400) 225 | url = f"{self._base_url}/api/files/{picture_type}/{filename}" 226 | url = f"{url}?force_serve_as=picture&best_fit_width={int(width)}" 227 | headers = {"GROCY-API-KEY": self._api_key, "accept": "*/*"} 228 | 229 | async with self._session.get(url, headers=headers) as resp: 230 | resp.raise_for_status() 231 | 232 | response_headers = {} 233 | for name, value in resp.headers.items(): 234 | if name in ( 235 | hdrs.CACHE_CONTROL, 236 | hdrs.CONTENT_DISPOSITION, 237 | hdrs.CONTENT_LENGTH, 238 | hdrs.CONTENT_TYPE, 239 | hdrs.CONTENT_ENCODING, 240 | ): 241 | response_headers[name] = value 242 | 243 | body = await resp.read() 244 | return web.Response(body=body, headers=response_headers) 245 | -------------------------------------------------------------------------------- /custom_components/grocy/helpers.py: -------------------------------------------------------------------------------- 1 | """Helpers for Grocy.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | import base64 6 | from typing import Any, Dict, Tuple 7 | from urllib.parse import urlparse 8 | 9 | from pygrocy2.data_models.meal_items import MealPlanItem 10 | from pygrocy2.data_models.product import Product 11 | from pygrocy2.grocy_api_client import CurrentStockResponse 12 | 13 | 14 | def extract_base_url_and_path(url: str) -> Tuple[str, str]: 15 | """Extract the base url and path from a given URL.""" 16 | parsed_url = urlparse(url) 17 | 18 | return (f"{parsed_url.scheme}://{parsed_url.netloc}", parsed_url.path.strip("/")) 19 | 20 | 21 | class MealPlanItemWrapper: 22 | """Wrapper around the pygrocy MealPlanItem.""" 23 | 24 | def __init__(self, meal_plan: MealPlanItem): 25 | self._meal_plan = meal_plan 26 | 27 | @property 28 | def meal_plan(self) -> MealPlanItem: 29 | """The pygrocy MealPlanItem.""" 30 | return self._meal_plan 31 | 32 | @property 33 | def picture_url(self) -> str | None: 34 | """Proxy URL to the picture.""" 35 | recipe = self.meal_plan.recipe 36 | if recipe and recipe.picture_file_name: 37 | b64name = base64.b64encode(recipe.picture_file_name.encode("ascii")) 38 | return f"/api/grocy/recipepictures/{str(b64name, 'utf-8')}" 39 | return None 40 | 41 | def as_dict(self) -> Dict[str, Any]: 42 | """Return attributes for the pygrocy MealPlanItem object including picture URL.""" 43 | props = self.meal_plan.as_dict() 44 | props["picture_url"] = self.picture_url 45 | return props 46 | 47 | class ProductWrapper: 48 | """Wrapper around the pygrocy CurrentStockResponse.""" 49 | 50 | def __init__(self, product: CurrentStockResponse, hass): 51 | self._product = Product(product) 52 | self._hass = hass 53 | self._picture_url = self.get_picture_url(product) 54 | 55 | @property 56 | def product(self) -> Product: 57 | """The pygrocy Product.""" 58 | return self._product 59 | 60 | @property 61 | def picture_url(self) -> str | None: 62 | """Proxy URL to the picture.""" 63 | return self._picture_url 64 | 65 | def get_picture_url(self, product: CurrentStockResponse) -> str | None: 66 | """Proxy URL to the picture.""" 67 | 68 | if product.product and product.product.picture_file_name: 69 | b64name = base64.b64encode(product.product.picture_file_name.encode("ascii")) 70 | return f"/api/grocy/productpictures/{str(b64name, 'utf-8')}" 71 | 72 | return None 73 | 74 | def as_dict(self) -> Dict[str, Any]: 75 | """Return attributes for the pygrocy Product object including picture URL.""" 76 | props = self.product.as_dict() 77 | props["picture_url"] = self.picture_url 78 | return props -------------------------------------------------------------------------------- /custom_components/grocy/json_encoder.py: -------------------------------------------------------------------------------- 1 | """JSON encoder for Grocy.""" 2 | 3 | import datetime 4 | from typing import Any 5 | 6 | from homeassistant.helpers.json import ExtendedJSONEncoder 7 | 8 | 9 | class CustomJSONEncoder(ExtendedJSONEncoder): 10 | """JSONEncoder for compatibility, falls back to the Home Assistant Core ExtendedJSONEncoder.""" 11 | 12 | def default(self, o: Any) -> Any: 13 | """Convert certain objects.""" 14 | 15 | if isinstance(o, (datetime.date, datetime.time)): 16 | return o.isoformat() 17 | 18 | return super().default(o) 19 | -------------------------------------------------------------------------------- /custom_components/grocy/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "grocy", 3 | "name": "Grocy", 4 | "codeowners": [ 5 | "@SebRut", 6 | "@isabellaalstrom" 7 | ], 8 | "config_flow": true, 9 | "dependencies": [ 10 | "http" 11 | ], 12 | "documentation": "https://github.com/custom-components/grocy", 13 | "iot_class": "local_polling", 14 | "issue_tracker": "https://github.com/custom-components/grocy/issues", 15 | "requirements": [ 16 | "pygrocy2==2.4.1" 17 | ], 18 | "version": "0.0.0" 19 | } 20 | -------------------------------------------------------------------------------- /custom_components/grocy/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for Grocy.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from collections.abc import Callable, Mapping 6 | from dataclasses import dataclass 7 | from typing import Any, List 8 | 9 | from homeassistant.components.sensor import ( 10 | SensorEntity, 11 | SensorEntityDescription, 12 | SensorStateClass, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.helpers.typing import StateType 18 | 19 | from .const import ( 20 | ATTR_BATTERIES, 21 | ATTR_CHORES, 22 | ATTR_MEAL_PLAN, 23 | ATTR_SHOPPING_LIST, 24 | ATTR_STOCK, 25 | ATTR_TASKS, 26 | CHORES, 27 | DOMAIN, 28 | ITEMS, 29 | MEAL_PLANS, 30 | PRODUCTS, 31 | TASKS, 32 | ) 33 | from .coordinator import GrocyDataUpdateCoordinator 34 | from .entity import GrocyEntity 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | async def async_setup_entry( 40 | hass: HomeAssistant, 41 | config_entry: ConfigEntry, 42 | async_add_entities: AddEntitiesCallback, 43 | ): 44 | """Setup sensor platform.""" 45 | coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] 46 | entities = [] 47 | for description in SENSORS: 48 | if description.exists_fn(coordinator.available_entities): 49 | entity = GrocySensorEntity(coordinator, description, config_entry) 50 | coordinator.entities.append(entity) 51 | entities.append(entity) 52 | else: 53 | _LOGGER.debug( 54 | "Entity description '%s' is not available.", 55 | description.key, 56 | ) 57 | 58 | async_add_entities(entities, True) 59 | 60 | 61 | @dataclass 62 | class GrocySensorEntityDescription(SensorEntityDescription): 63 | """Grocy sensor entity description.""" 64 | 65 | attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None 66 | exists_fn: Callable[[List[str]], bool] = lambda _: True 67 | entity_registry_enabled_default: bool = False 68 | 69 | 70 | SENSORS: tuple[GrocySensorEntityDescription, ...] = ( 71 | GrocySensorEntityDescription( 72 | key=ATTR_CHORES, 73 | name="Grocy chores", 74 | native_unit_of_measurement=CHORES, 75 | state_class=SensorStateClass.MEASUREMENT, 76 | icon="mdi:broom", 77 | exists_fn=lambda entities: ATTR_CHORES in entities, 78 | attributes_fn=lambda data: { 79 | "chores": [x.as_dict() for x in data], 80 | "count": len(data), 81 | }, 82 | ), 83 | GrocySensorEntityDescription( 84 | key=ATTR_MEAL_PLAN, 85 | name="Grocy meal plan", 86 | native_unit_of_measurement=MEAL_PLANS, 87 | state_class=SensorStateClass.MEASUREMENT, 88 | icon="mdi:silverware-variant", 89 | exists_fn=lambda entities: ATTR_MEAL_PLAN in entities, 90 | attributes_fn=lambda data: { 91 | "meals": [x.as_dict() for x in data], 92 | "count": len(data), 93 | }, 94 | ), 95 | GrocySensorEntityDescription( 96 | key=ATTR_SHOPPING_LIST, 97 | name="Grocy shopping list", 98 | native_unit_of_measurement=PRODUCTS, 99 | state_class=SensorStateClass.MEASUREMENT, 100 | icon="mdi:cart-outline", 101 | exists_fn=lambda entities: ATTR_SHOPPING_LIST in entities, 102 | attributes_fn=lambda data: { 103 | "products": [x.as_dict() for x in data], 104 | "count": len(data), 105 | }, 106 | ), 107 | GrocySensorEntityDescription( 108 | key=ATTR_STOCK, 109 | name="Grocy stock", 110 | native_unit_of_measurement=PRODUCTS, 111 | state_class=SensorStateClass.MEASUREMENT, 112 | icon="mdi:fridge-outline", 113 | exists_fn=lambda entities: ATTR_STOCK in entities, 114 | attributes_fn=lambda data: { 115 | "products": [x.as_dict() for x in data], 116 | "count": len(data), 117 | }, 118 | ), 119 | GrocySensorEntityDescription( 120 | key=ATTR_TASKS, 121 | name="Grocy tasks", 122 | native_unit_of_measurement=TASKS, 123 | state_class=SensorStateClass.MEASUREMENT, 124 | icon="mdi:checkbox-marked-circle-outline", 125 | exists_fn=lambda entities: ATTR_TASKS in entities, 126 | attributes_fn=lambda data: { 127 | "tasks": [x.as_dict() for x in data], 128 | "count": len(data), 129 | }, 130 | ), 131 | GrocySensorEntityDescription( 132 | key=ATTR_BATTERIES, 133 | name="Grocy batteries", 134 | native_unit_of_measurement=ITEMS, 135 | state_class=SensorStateClass.MEASUREMENT, 136 | icon="mdi:battery", 137 | exists_fn=lambda entities: ATTR_BATTERIES in entities, 138 | attributes_fn=lambda data: { 139 | "batteries": [x.as_dict() for x in data], 140 | "count": len(data), 141 | }, 142 | ), 143 | ) 144 | 145 | 146 | class GrocySensorEntity(GrocyEntity, SensorEntity): 147 | """Grocy sensor entity definition.""" 148 | 149 | @property 150 | def native_value(self) -> StateType: 151 | """Return the value reported by the sensor.""" 152 | entity_data = self.coordinator.data.get(self.entity_description.key, None) 153 | 154 | return len(entity_data) if entity_data else 0 155 | -------------------------------------------------------------------------------- /custom_components/grocy/services.py: -------------------------------------------------------------------------------- 1 | """Grocy services.""" 2 | from __future__ import annotations 3 | 4 | import voluptuous as vol 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant, ServiceCall 7 | from pygrocy2.grocy import EntityType, TransactionType 8 | from datetime import datetime 9 | 10 | from .const import ATTR_CHORES, ATTR_TASKS, DOMAIN 11 | from .coordinator import GrocyDataUpdateCoordinator 12 | 13 | SERVICE_PRODUCT_ID = "product_id" 14 | SERVICE_AMOUNT = "amount" 15 | SERVICE_PRICE = "price" 16 | SERVICE_SPOILED = "spoiled" 17 | SERVICE_SUBPRODUCT_SUBSTITUTION = "allow_subproduct_substitution" 18 | SERVICE_TRANSACTION_TYPE = "transaction_type" 19 | SERVICE_CHORE_ID = "chore_id" 20 | SERVICE_DONE_BY = "done_by" 21 | SERVICE_EXECUTION_NOW = "track_execution_now" 22 | SERVICE_SKIPPED = "skipped" 23 | SERVICE_TASK_ID = "task_id" 24 | SERVICE_ENTITY_TYPE = "entity_type" 25 | SERVICE_DATA = "data" 26 | SERVICE_RECIPE_ID = "recipe_id" 27 | SERVICE_BATTERY_ID = "battery_id" 28 | SERVICE_OBJECT_ID = "object_id" 29 | SERVICE_LIST_ID = "list_id" 30 | 31 | SERVICE_ADD_PRODUCT = "add_product_to_stock" 32 | SERVICE_OPEN_PRODUCT = "open_product" 33 | SERVICE_CONSUME_PRODUCT = "consume_product_from_stock" 34 | SERVICE_EXECUTE_CHORE = "execute_chore" 35 | SERVICE_COMPLETE_TASK = "complete_task" 36 | SERVICE_ADD_GENERIC = "add_generic" 37 | SERVICE_UPDATE_GENERIC = "update_generic" 38 | SERVICE_DELETE_GENERIC = "delete_generic" 39 | SERVICE_CONSUME_RECIPE = "consume_recipe" 40 | SERVICE_TRACK_BATTERY = "track_battery" 41 | SERVICE_ADD_MISSING_PRODUCTS_TO_SHOPPING_LIST = "add_missing_products_to_shopping_list" 42 | SERVICE_REMOVE_PRODUCT_IN_SHOPPING_LIST = "remove_product_in_shopping_list" 43 | 44 | SERVICE_ADD_PRODUCT_SCHEMA = vol.All( 45 | vol.Schema( 46 | { 47 | vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), 48 | vol.Required(SERVICE_AMOUNT): vol.Coerce(float), 49 | vol.Optional(SERVICE_PRICE): str, 50 | } 51 | ) 52 | ) 53 | 54 | SERVICE_OPEN_PRODUCT_SCHEMA = vol.All( 55 | vol.Schema( 56 | { 57 | vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), 58 | vol.Required(SERVICE_AMOUNT): vol.Coerce(float), 59 | vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, 60 | } 61 | ) 62 | ) 63 | 64 | SERVICE_CONSUME_PRODUCT_SCHEMA = vol.All( 65 | vol.Schema( 66 | { 67 | vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), 68 | vol.Required(SERVICE_AMOUNT): vol.Coerce(float), 69 | vol.Optional(SERVICE_SPOILED): bool, 70 | vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, 71 | vol.Optional(SERVICE_TRANSACTION_TYPE): str, 72 | } 73 | ) 74 | ) 75 | 76 | SERVICE_EXECUTE_CHORE_SCHEMA = vol.All( 77 | vol.Schema( 78 | { 79 | vol.Required(SERVICE_CHORE_ID): vol.Coerce(int), 80 | vol.Optional(SERVICE_DONE_BY): vol.Coerce(int), 81 | vol.Optional(SERVICE_EXECUTION_NOW): bool, 82 | vol.Optional(SERVICE_SKIPPED): bool, 83 | } 84 | ) 85 | ) 86 | 87 | SERVICE_COMPLETE_TASK_SCHEMA = vol.All( 88 | vol.Schema( 89 | { 90 | vol.Required(SERVICE_TASK_ID): vol.Coerce(int), 91 | } 92 | ) 93 | ) 94 | 95 | SERVICE_ADD_GENERIC_SCHEMA = vol.All( 96 | vol.Schema( 97 | { 98 | vol.Required(SERVICE_ENTITY_TYPE): str, 99 | vol.Required(SERVICE_DATA): object, 100 | } 101 | ) 102 | ) 103 | 104 | SERVICE_UPDATE_GENERIC_SCHEMA = vol.All( 105 | vol.Schema( 106 | { 107 | vol.Required(SERVICE_ENTITY_TYPE): str, 108 | vol.Required(SERVICE_OBJECT_ID): vol.Coerce(int), 109 | vol.Required(SERVICE_DATA): object, 110 | } 111 | ) 112 | ) 113 | 114 | SERVICE_DELETE_GENERIC_SCHEMA = vol.All( 115 | vol.Schema( 116 | { 117 | vol.Required(SERVICE_ENTITY_TYPE): str, 118 | vol.Required(SERVICE_OBJECT_ID): vol.Coerce(int), 119 | } 120 | ) 121 | ) 122 | 123 | SERVICE_CONSUME_RECIPE_SCHEMA = vol.All( 124 | vol.Schema( 125 | { 126 | vol.Required(SERVICE_RECIPE_ID): vol.Coerce(int), 127 | } 128 | ) 129 | ) 130 | 131 | SERVICE_TRACK_BATTERY_SCHEMA = vol.All( 132 | vol.Schema( 133 | { 134 | vol.Required(SERVICE_BATTERY_ID): vol.Coerce(int), 135 | } 136 | ) 137 | ) 138 | 139 | SERVICE_ADD_MISSING_PRODUCTS_TO_SHOPPING_LIST_SCHEMA = vol.All( 140 | vol.Schema( 141 | { 142 | vol.Optional(SERVICE_LIST_ID): vol.Coerce(int), 143 | } 144 | ) 145 | ) 146 | 147 | SERVICE_REMOVE_PRODUCT_IN_SHOPPING_LIST_SCHEMA = vol.All( 148 | vol.Schema( 149 | { 150 | vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), 151 | vol.Optional(SERVICE_LIST_ID): vol.Coerce(int), 152 | vol.Required(SERVICE_AMOUNT): vol.Coerce(float), 153 | } 154 | ) 155 | ) 156 | 157 | SERVICES_WITH_ACCOMPANYING_SCHEMA: list[tuple[str, vol.Schema]] = [ 158 | (SERVICE_ADD_PRODUCT, SERVICE_ADD_PRODUCT_SCHEMA), 159 | (SERVICE_OPEN_PRODUCT, SERVICE_OPEN_PRODUCT_SCHEMA), 160 | (SERVICE_CONSUME_PRODUCT, SERVICE_CONSUME_PRODUCT_SCHEMA), 161 | (SERVICE_EXECUTE_CHORE, SERVICE_EXECUTE_CHORE_SCHEMA), 162 | (SERVICE_COMPLETE_TASK, SERVICE_COMPLETE_TASK_SCHEMA), 163 | (SERVICE_ADD_GENERIC, SERVICE_ADD_GENERIC_SCHEMA), 164 | (SERVICE_UPDATE_GENERIC, SERVICE_UPDATE_GENERIC_SCHEMA), 165 | (SERVICE_DELETE_GENERIC, SERVICE_DELETE_GENERIC_SCHEMA), 166 | (SERVICE_CONSUME_RECIPE, SERVICE_CONSUME_RECIPE_SCHEMA), 167 | (SERVICE_TRACK_BATTERY, SERVICE_TRACK_BATTERY_SCHEMA), 168 | (SERVICE_ADD_MISSING_PRODUCTS_TO_SHOPPING_LIST, SERVICE_ADD_MISSING_PRODUCTS_TO_SHOPPING_LIST_SCHEMA), 169 | (SERVICE_REMOVE_PRODUCT_IN_SHOPPING_LIST, SERVICE_REMOVE_PRODUCT_IN_SHOPPING_LIST_SCHEMA), 170 | ] 171 | 172 | 173 | async def async_setup_services( 174 | hass: HomeAssistant, config_entry: ConfigEntry # pylint: disable=unused-argument 175 | ) -> None: 176 | """Set up services for Grocy integration.""" 177 | coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] 178 | if hass.services.async_services().get(DOMAIN): 179 | return 180 | 181 | async def async_call_grocy_service(service_call: ServiceCall) -> None: 182 | """Call correct Grocy service.""" 183 | service = service_call.service 184 | service_data = service_call.data 185 | 186 | if service == SERVICE_ADD_PRODUCT: 187 | await async_add_product_service(hass, coordinator, service_data) 188 | 189 | elif service == SERVICE_OPEN_PRODUCT: 190 | await async_open_product_service(hass, coordinator, service_data) 191 | 192 | elif service == SERVICE_CONSUME_PRODUCT: 193 | await async_consume_product_service(hass, coordinator, service_data) 194 | 195 | elif service == SERVICE_EXECUTE_CHORE: 196 | await async_execute_chore_service(hass, coordinator, service_data) 197 | 198 | elif service == SERVICE_COMPLETE_TASK: 199 | await async_complete_task_service(hass, coordinator, service_data) 200 | 201 | elif service == SERVICE_ADD_GENERIC: 202 | await async_add_generic_service(hass, coordinator, service_data) 203 | 204 | elif service == SERVICE_UPDATE_GENERIC: 205 | await async_update_generic_service(hass, coordinator, service_data) 206 | 207 | elif service == SERVICE_DELETE_GENERIC: 208 | await async_delete_generic_service(hass, coordinator, service_data) 209 | 210 | elif service == SERVICE_CONSUME_RECIPE: 211 | await async_consume_recipe_service(hass, coordinator, service_data) 212 | 213 | elif service == SERVICE_TRACK_BATTERY: 214 | await async_track_battery_service(hass, coordinator, service_data) 215 | 216 | elif service == SERVICE_ADD_MISSING_PRODUCTS_TO_SHOPPING_LIST: 217 | await async_add_missing_products_to_shopping_list(hass, coordinator, service_data) 218 | 219 | elif service == SERVICE_REMOVE_PRODUCT_IN_SHOPPING_LIST: 220 | await async_remove_product_in_shopping_list_service(hass, coordinator, service_data) 221 | 222 | for service, schema in SERVICES_WITH_ACCOMPANYING_SCHEMA: 223 | hass.services.async_register(DOMAIN, service, async_call_grocy_service, schema) 224 | 225 | 226 | async def async_unload_services(hass: HomeAssistant) -> None: 227 | """Unload Grocy services.""" 228 | if not hass.services.async_services().get(DOMAIN): 229 | return 230 | 231 | for service, _ in SERVICES_WITH_ACCOMPANYING_SCHEMA: 232 | hass.services.async_remove(DOMAIN, service) 233 | 234 | 235 | async def async_add_product_service(hass, coordinator, data): 236 | """Add a product in Grocy.""" 237 | product_id = data[SERVICE_PRODUCT_ID] 238 | amount = data[SERVICE_AMOUNT] 239 | price = data.get(SERVICE_PRICE, "") 240 | 241 | def wrapper(): 242 | coordinator.grocy_api.add_product(product_id, amount, price) 243 | 244 | await hass.async_add_executor_job(wrapper) 245 | 246 | 247 | async def async_open_product_service(hass, coordinator, data): 248 | """Open a product in Grocy.""" 249 | product_id = data[SERVICE_PRODUCT_ID] 250 | amount = data[SERVICE_AMOUNT] 251 | allow_subproduct_substitution = data.get(SERVICE_SUBPRODUCT_SUBSTITUTION, False) 252 | 253 | def wrapper(): 254 | coordinator.grocy_api.open_product( 255 | product_id, amount, allow_subproduct_substitution 256 | ) 257 | 258 | await hass.async_add_executor_job(wrapper) 259 | 260 | 261 | async def async_consume_product_service(hass, coordinator, data): 262 | """Consume a product in Grocy.""" 263 | product_id = data[SERVICE_PRODUCT_ID] 264 | amount = data[SERVICE_AMOUNT] 265 | spoiled = data.get(SERVICE_SPOILED, False) 266 | allow_subproduct_substitution = data.get(SERVICE_SUBPRODUCT_SUBSTITUTION, False) 267 | 268 | transaction_type_raw = data.get(SERVICE_TRANSACTION_TYPE, None) 269 | transaction_type = TransactionType.CONSUME 270 | 271 | if transaction_type_raw is not None: 272 | transaction_type = TransactionType[transaction_type_raw] 273 | 274 | def wrapper(): 275 | coordinator.grocy_api.consume_product( 276 | product_id, 277 | amount, 278 | spoiled=spoiled, 279 | transaction_type=transaction_type, 280 | allow_subproduct_substitution=allow_subproduct_substitution, 281 | ) 282 | 283 | await hass.async_add_executor_job(wrapper) 284 | 285 | 286 | async def async_execute_chore_service(hass, coordinator, data): 287 | should_track_now = data.get(SERVICE_EXECUTION_NOW, False) 288 | 289 | """Execute a chore in Grocy.""" 290 | chore_id = data[SERVICE_CHORE_ID] 291 | done_by = data.get(SERVICE_DONE_BY, "") 292 | tracked_time = datetime.now() if should_track_now else None 293 | skipped = data.get(SERVICE_SKIPPED, False) 294 | 295 | def wrapper(): 296 | coordinator.grocy_api.execute_chore(chore_id, done_by, tracked_time, skipped=skipped) 297 | 298 | await hass.async_add_executor_job(wrapper) 299 | await _async_force_update_entity(coordinator, ATTR_CHORES) 300 | 301 | 302 | async def async_complete_task_service(hass, coordinator, data): 303 | """Complete a task in Grocy.""" 304 | task_id = data[SERVICE_TASK_ID] 305 | 306 | def wrapper(): 307 | coordinator.grocy_api.complete_task(task_id) 308 | 309 | await hass.async_add_executor_job(wrapper) 310 | await _async_force_update_entity(coordinator, ATTR_TASKS) 311 | 312 | 313 | async def async_add_generic_service(hass, coordinator, data): 314 | """Add a generic entity in Grocy.""" 315 | entity_type_raw = data.get(SERVICE_ENTITY_TYPE, None) 316 | entity_type = EntityType.TASKS 317 | 318 | if entity_type_raw is not None: 319 | entity_type = EntityType(entity_type_raw) 320 | 321 | data = data[SERVICE_DATA] 322 | 323 | def wrapper(): 324 | coordinator.grocy_api.add_generic(entity_type, data) 325 | 326 | await hass.async_add_executor_job(wrapper) 327 | await post_generic_refresh(coordinator, entity_type); 328 | 329 | 330 | async def async_update_generic_service(hass, coordinator, data): 331 | """Update a generic entity in Grocy.""" 332 | entity_type_raw = data.get(SERVICE_ENTITY_TYPE, None) 333 | entity_type = EntityType.TASKS 334 | 335 | if entity_type_raw is not None: 336 | entity_type = EntityType(entity_type_raw) 337 | 338 | object_id = data[SERVICE_OBJECT_ID] 339 | 340 | data = data[SERVICE_DATA] 341 | 342 | def wrapper(): 343 | coordinator.grocy_api.update_generic(entity_type, object_id, data) 344 | 345 | await hass.async_add_executor_job(wrapper) 346 | await post_generic_refresh(coordinator, entity_type); 347 | 348 | 349 | async def async_delete_generic_service(hass, coordinator, data): 350 | """Delete a generic entity in Grocy.""" 351 | entity_type_raw = data.get(SERVICE_ENTITY_TYPE, None) 352 | entity_type = EntityType.TASKS 353 | 354 | if entity_type_raw is not None: 355 | entity_type = EntityType(entity_type_raw) 356 | 357 | object_id = data[SERVICE_OBJECT_ID] 358 | 359 | def wrapper(): 360 | coordinator.grocy_api.delete_generic(entity_type, object_id) 361 | 362 | await hass.async_add_executor_job(wrapper) 363 | await post_generic_refresh(coordinator, entity_type); 364 | 365 | 366 | async def post_generic_refresh(coordinator, entity_type): 367 | if entity_type == "tasks" or entity_type == "chores": 368 | await _async_force_update_entity(coordinator, entity_type) 369 | 370 | async def async_consume_recipe_service(hass, coordinator, data): 371 | """Consume a recipe in Grocy.""" 372 | recipe_id = data[SERVICE_RECIPE_ID] 373 | 374 | def wrapper(): 375 | coordinator.grocy_api.consume_recipe(recipe_id) 376 | 377 | await hass.async_add_executor_job(wrapper) 378 | 379 | 380 | async def async_track_battery_service(hass, coordinator, data): 381 | """Track a battery in Grocy.""" 382 | battery_id = data[SERVICE_BATTERY_ID] 383 | 384 | def wrapper(): 385 | coordinator.grocy_api.charge_battery(battery_id) 386 | 387 | await hass.async_add_executor_job(wrapper) 388 | 389 | async def async_add_missing_products_to_shopping_list(hass, coordinator, data): 390 | """Adds currently missing proudcts (below defined min. stock amount) to the given shopping list.""" 391 | list_id = data.get(SERVICE_LIST_ID, 1) 392 | 393 | def wrapper(): 394 | coordinator.grocy_api.add_missing_product_to_shopping_list(list_id) 395 | 396 | await hass.async_add_executor_job(wrapper) 397 | 398 | async def async_remove_product_in_shopping_list_service(hass, coordinator, data): 399 | """Removes the given product from the given shopping list""" 400 | product_id = data[SERVICE_PRODUCT_ID] 401 | list_id = data.get(SERVICE_LIST_ID, 1) 402 | amount = data[SERVICE_AMOUNT] 403 | 404 | def wrapper(): 405 | coordinator.grocy_api.remove_product_in_shopping_list(product_id, list_id, amount) 406 | 407 | await hass.async_add_executor_job(wrapper) 408 | 409 | async def _async_force_update_entity( 410 | coordinator: GrocyDataUpdateCoordinator, entity_key: str 411 | ) -> None: 412 | """Force entity update for given entity key.""" 413 | entity = next( 414 | ( 415 | entity 416 | for entity in coordinator.entities 417 | if entity.entity_description.key == entity_key 418 | ), 419 | None, 420 | ) 421 | if entity: 422 | await entity.async_update_ha_state(force_refresh=True) 423 | -------------------------------------------------------------------------------- /custom_components/grocy/services.yaml: -------------------------------------------------------------------------------- 1 | add_product_to_stock: 2 | name: Add Product To Stock 3 | description: Adds a given amount of a product to the stock 4 | fields: 5 | product_id: 6 | name: Product Id 7 | example: '3' 8 | required: true 9 | description: The id of the product to add to stock 10 | selector: 11 | text: 12 | amount: 13 | name: Amount 14 | description: The amount to add to stock 15 | required: true 16 | example: 3 17 | default: 1 18 | selector: 19 | number: 20 | min: 1 21 | max: 1000 22 | mode: box 23 | price: 24 | name: Price 25 | example: 1.99 26 | description: The purchase price per purchase quantity unit of the added product 27 | selector: 28 | text: 29 | 30 | open_product: 31 | name: Open Product 32 | description: Opens a given amount of a product in stock 33 | fields: 34 | product_id: 35 | name: Product Id 36 | example: '3' 37 | required: true 38 | description: The id of the product to open 39 | selector: 40 | text: 41 | amount: 42 | name: Amount 43 | example: 1 44 | required: true 45 | description: The amount to open 46 | selector: 47 | number: 48 | min: 1 49 | max: 1000 50 | mode: box 51 | allow_subproduct_substitution: 52 | name: Subproduct substitution 53 | description: If subproduct substitution is allowed 54 | example: false 55 | default: false 56 | selector: 57 | boolean: 58 | 59 | consume_product_from_stock: 60 | name: Consume Product From Stock 61 | description: Consumes a given amount of a product to the stock 62 | fields: 63 | product_id: 64 | name: Product Id 65 | example: '3' 66 | required: true 67 | description: The id of the product to consume 68 | selector: 69 | text: 70 | amount: 71 | name: Amount 72 | example: 3 73 | required: true 74 | description: The amount to consume 75 | selector: 76 | number: 77 | min: 1 78 | max: 1000 79 | mode: box 80 | spoiled: 81 | name: Spoiled 82 | description: If the product was removed because of spoilage 83 | example: false 84 | default: false 85 | selector: 86 | boolean: 87 | allow_subproduct_substitution: 88 | name: Subproduct substitution 89 | description: If subproduct substitution is allowed 90 | example: false 91 | default: false 92 | selector: 93 | boolean: 94 | transaction_type: 95 | name: Transaction Type 96 | description: The type of the transaction. 97 | required: true 98 | example: "CONSUME" 99 | default: "CONSUME" 100 | selector: 101 | select: 102 | options: 103 | - "CONSUME" 104 | - "PURCHASE" 105 | - "INVENTORY_CORRECTION" 106 | - "PRODUCT_OPENED" 107 | execute_chore: 108 | name: Execute Chore 109 | description: Executes the given chore with an optional timestamp and executor 110 | fields: 111 | chore_id: 112 | name: Chore Id 113 | example: '3' 114 | required: true 115 | description: The id of the chore to execute 116 | selector: 117 | text: 118 | done_by: 119 | name: User Id 120 | example: '0' 121 | required: true 122 | description: The id of the user who executed the chore 123 | selector: 124 | text: 125 | track_execution_now: 126 | name: Execution now 127 | example: false 128 | default: false 129 | required: false 130 | description: If the chore execution should be tracked with the time now 131 | selector: 132 | boolean: 133 | skipped: 134 | name: Skip execution 135 | description: Skip next chore schedule 136 | example: false 137 | default: false 138 | selector: 139 | boolean: 140 | 141 | complete_task: 142 | name: Complete Task 143 | description: Completes the given task 144 | fields: 145 | task_id: 146 | name: Task Id 147 | example: '3' 148 | required: true 149 | description: The id of the task to complete 150 | selector: 151 | text: 152 | 153 | add_generic: 154 | name: Add Generic 155 | description: Adds a single object of the given entity type 156 | fields: 157 | entity_type: 158 | name: Entity Type 159 | description: The type of entity you like to add. 160 | required: true 161 | example: 'tasks' 162 | default: 'tasks' 163 | selector: 164 | select: 165 | options: 166 | - "products" 167 | - "chores" 168 | - "product_barcodes" 169 | - "batteries" 170 | - "locations" 171 | - "quantity_units" 172 | - "quantity_unit_conversions" 173 | - "shopping_list" 174 | - "shopping_lists" 175 | - "shopping_locations" 176 | - "recipes" 177 | - "recipes_pos" 178 | - "recipes_nestings" 179 | - "tasks" 180 | - "task_categories" 181 | - "product_groups" 182 | - "equipment" 183 | - "userfields" 184 | - "userentities" 185 | - "userobjects" 186 | - "meal_plan" 187 | data: 188 | name: Data 189 | description: "JSON object with what data you want to add (yaml format also works). See Grocy api documentation on Generic entity interactions: https://demo.grocy.info/api" 190 | required: true 191 | default: {"name": "Task name", "due_date": "2021-05-21"} 192 | selector: 193 | object: 194 | 195 | 196 | update_generic: 197 | name: Update Generic 198 | description: Edits a single object of the given entity type 199 | fields: 200 | entity_type: 201 | name: Entity Type 202 | description: The type of entity you like to update. 203 | required: true 204 | example: 'tasks' 205 | default: 'tasks' 206 | selector: 207 | select: 208 | options: 209 | - "products" 210 | - "chores" 211 | - "product_barcodes" 212 | - "batteries" 213 | - "locations" 214 | - "quantity_units" 215 | - "quantity_unit_conversions" 216 | - "shopping_list" 217 | - "shopping_lists" 218 | - "shopping_locations" 219 | - "recipes" 220 | - "recipes_pos" 221 | - "recipes_nestings" 222 | - "tasks" 223 | - "task_categories" 224 | - "product_groups" 225 | - "equipment" 226 | - "userfields" 227 | - "userentities" 228 | - "userobjects" 229 | - "meal_plan" 230 | 231 | object_id: 232 | name: Object ID 233 | description: The ID of the entity to update. 234 | required: true 235 | example: '1' 236 | selector: 237 | text: 238 | 239 | data: 240 | name: Data 241 | description: "JSON object with what data you want to update (yaml format also works). See Grocy api documentation on Generic entity interactions: https://demo.grocy.info/api" 242 | required: true 243 | default: {"name": "Task name", "due_date": "2021-05-21"} 244 | selector: 245 | object: 246 | 247 | 248 | delete_generic: 249 | name: Delete Generic 250 | description: Deletes a single object of the given entity type 251 | fields: 252 | entity_type: 253 | name: Entity Type 254 | description: The type of entity to be deleted. 255 | required: true 256 | example: 'tasks' 257 | default: 'tasks' 258 | selector: 259 | select: 260 | options: 261 | - "products" 262 | - "chores" 263 | - "product_barcodes" 264 | - "batteries" 265 | - "locations" 266 | - "quantity_units" 267 | - "quantity_unit_conversions" 268 | - "shopping_list" 269 | - "shopping_lists" 270 | - "shopping_locations" 271 | - "recipes" 272 | - "recipes_pos" 273 | - "recipes_nestings" 274 | - "tasks" 275 | - "task_categories" 276 | - "product_groups" 277 | - "equipment" 278 | - "userfields" 279 | - "userentities" 280 | - "userobjects" 281 | - "meal_plan" 282 | 283 | object_id: 284 | name: Object ID 285 | description: The ID of the entity to delete. 286 | required: true 287 | example: '1' 288 | selector: 289 | text: 290 | 291 | 292 | consume_recipe: 293 | name: Consume Recipe 294 | description: Consumes the given recipe 295 | fields: 296 | recipe_id: 297 | name: Recipe Id 298 | example: '3' 299 | required: true 300 | description: The id of the recipe to consume 301 | selector: 302 | text: 303 | 304 | track_battery: 305 | name: Track Battery 306 | description: Tracks the given battery 307 | fields: 308 | battery_id: 309 | name: Battery Id 310 | example: '1' 311 | required: true 312 | description: The id of the battery 313 | selector: 314 | text: 315 | 316 | add_missing_products_to_shopping_list: 317 | name: Add Missing Products to Shopping List 318 | description: Adds currently missing products to the given shopping list. 319 | fields: 320 | list_id: 321 | name: List Id 322 | example: '1' 323 | required: false 324 | description: The id of the shopping list to be added to. 325 | selector: 326 | text: 327 | 328 | remove_product_in_shopping_list: 329 | name: Remove Product in Shopping List 330 | description: Removes a product in the given shopping list. 331 | fields: 332 | list_id: 333 | name: List Id 334 | example: '1' 335 | required: false 336 | description: The id of the shopping list to be added to. 337 | selector: 338 | text: 339 | product_id: 340 | name: Product Id 341 | example: '3' 342 | required: true 343 | description: The id of the product to remove 344 | selector: 345 | text: 346 | amount: 347 | name: Amount 348 | example: 3 349 | required: true 350 | description: The amount to remove 351 | selector: 352 | number: 353 | min: 1 354 | max: 1000 355 | mode: box 356 | -------------------------------------------------------------------------------- /custom_components/grocy/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Connect to your Grocy instance", 6 | "data": { 7 | "url": "Grocy API URL (e.g. \"http://yourgrocyurl.com\")", 8 | "api_key": "Grocy API Key", 9 | "port": "Port Number (9192)", 10 | "verify_ssl": "Verify SSL Certificate" 11 | } 12 | } 13 | }, 14 | "error": { 15 | "auth": "Something went wrong." 16 | }, 17 | "abort": { 18 | "single_instance_allowed": "Only a single configuration of Grocy is allowed." 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /grocy-addon-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/grocy/6304f3122b9b6fcfda72a8b64f8c642843b3d56e/grocy-addon-config.png -------------------------------------------------------------------------------- /grocy-integration-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/grocy/6304f3122b9b6fcfda72a8b64f8c642843b3d56e/grocy-integration-config.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Grocy custom component", 3 | "render_readme": true, 4 | "zip_release": true, 5 | "hide_default_branch": true, 6 | "homeassistant": "2021.12.0", 7 | "filename": "grocy.zip" 8 | } --------------------------------------------------------------------------------