├── .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 | [](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 | 
122 |
123 |
124 | # Add-on port configuration
125 |
126 | 
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 | }
--------------------------------------------------------------------------------