├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.yml
└── workflows
│ └── docker-image.yml
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── CHANGELOG.md
├── DOCS.md
├── Dockerfile-for-github-action
├── LICENSE
├── README.md
├── assets
├── Alipay.png
├── Ha-mini-card.jpg
├── QQ_group.jpg
├── WeiChat.jpg
├── background.png
├── badge.svg
├── database.png
├── edit1.jpg
├── image-20230730135540291.png
├── image-20240514.jpg
└── restart.jpg
├── config.yaml
├── docker-compose.yml
├── example.env
├── ha_addons_doc
├── Add-on教程.md
└── img
│ ├── add-repository.png
│ ├── addon-running-status.png
│ ├── addon-store.png
│ ├── addons-page.png
│ ├── configuration-ignore_user_id.png
│ ├── configuration.png
│ ├── find-addon.png
│ ├── install-addon.png
│ ├── installation-complete.png
│ ├── installation-progress.png
│ ├── refresh.png
│ ├── repositories-menu.png
│ ├── show-unused-options.png
│ └── start-addon.png
├── icon.png
├── logo.png
├── repository.yaml
├── requirements.txt
└── scripts
├── captcha.onnx
├── const.py
├── data_fetcher.py
├── main.py
├── onnx.py
└── sensor_updator.py
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: 向我报告问题,或者你遇到了问题,或者对功能进行请求
2 | description: 创建问题
3 | body:
4 | - type: textarea
5 | validations:
6 | required: true
7 | id: problem
8 | attributes:
9 | label: 问题描述
10 | description: >-
11 | 在此描述您遇到的问题,以便与维护者沟通。告诉我们您正在尝试做什么,以及发生了什么。
12 | 提供一个清晰简洁的问题描述。
13 | - type: markdown
14 | attributes:
15 | value: |
16 | ## 环境
17 | - type: input
18 | id: version
19 | validations:
20 | required: true
21 | attributes:
22 | label: 哪个版本存在此问题?
23 | description: >
24 | Releases发行版
25 | - type: dropdown
26 | validations:
27 | required: true
28 | id: selfTest
29 | attributes:
30 | label: 您是否手动登录国家电网网页并尝试获取数据?
31 | options:
32 | - 未尝试手动获取数据
33 | - 手动登录不成功
34 | - 已经手动登录国网并且可以正常获取数据
35 | - type: dropdown
36 | validations:
37 | required: true
38 | id: installation
39 | attributes:
40 | label: 您使用的是哪种安装方式?
41 | options:
42 | - Windows Docker环境
43 | - Linxu Docker环境
44 | - 群晖Docker环境
45 | - 其他环境请在附加信息说明
46 | - type: input
47 | validations:
48 | required: false
49 | attributes:
50 | label: Home Assistant core 哪个版本,安装方式,
51 | description: >
52 | Home Assistant 的版本,如 2021.8。 安装方式Hassos Docker
53 | - type: textarea
54 | id: logs
55 | attributes:
56 | label: 日志中是否有任何可能对我们有用的信息?
57 | description: 例如,docker logs -f <容器ID>
58 | - type: textarea
59 | id: additional
60 | attributes:
61 | label: 附加信息
62 | description: >
63 | 如果您有任何附加信息提供给我们,请在下面的字段中填写。
64 | 请注意,您可以通过拖放文件在下面的字段中附加截图或屏幕录制。
65 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image Build
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'version of this branch'
8 | required: true
9 | type: string
10 | paths:
11 | - 'scripts/**'
12 | - 'Dockerfile-for-github-action'
13 | - 'requirements.txt'
14 | - 'example.env'
15 | - '.github/workflows/docker-image.yml'
16 |
17 | jobs:
18 |
19 | build:
20 |
21 | runs-on: ubuntu-latest
22 | steps:
23 | - uses: actions/checkout@v3
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v1
27 |
28 | - name: Log into docker hub registry
29 | run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
30 |
31 |
32 | - name: Build and push Docker image
33 | run: |
34 | PLATFORMS=linux/arm64,linux/amd64
35 | DOCKER_IMAGE=arcw/sgcc_electricity
36 | ARCH_TAGS="arm64 amd64"
37 | VERSION=${{ inputs.version }}
38 | docker buildx build --build-arg VERSION=${VERSION} --platform $PLATFORMS -t $DOCKER_IMAGE:latest -t $DOCKER_IMAGE:${VERSION} --file Dockerfile-for-github-action --push .
39 |
40 | for ARCH in $ARCH_TAGS; do
41 | if [ "$ARCH" == "arm64" ]; then
42 | TAG_ARCH="aarch64"
43 | else
44 | TAG_ARCH=$ARCH
45 | fi
46 | docker buildx build --build-arg VERSION=${VERSION} --platform linux/${ARCH} -t ${DOCKER_IMAGE}-${TAG_ARCH}:latest -t ${DOCKER_IMAGE}-${TAG_ARCH}:${VERSION} --file Dockerfile-for-github-action --push .
47 | done
48 |
49 | - name: Log into Aliyun hub registry and push Docker image
50 | run: |
51 | echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.ALIYUN_USERNAME }} --password-stdin registry.cn-hangzhou.aliyuncs.com
52 | PLATFORMS=linux/arm64,linux/amd64
53 | DOCKER_IMAGE=registry.cn-hangzhou.aliyuncs.com/arcw/sgcc_electricity
54 | ARCH_TAGS="arm64 amd64"
55 | VERSION=${{ inputs.version }}
56 | docker buildx build --build-arg VERSION=${VERSION} --platform $PLATFORMS -t $DOCKER_IMAGE:latest -t $DOCKER_IMAGE:${VERSION} --file Dockerfile-for-github-action --push .
57 |
58 | for ARCH in $ARCH_TAGS; do
59 | if [ "$ARCH" == "arm64" ]; then
60 | TAG_ARCH="aarch64"
61 | else
62 | TAG_ARCH=$ARCH
63 | fi
64 | docker buildx build --build-arg VERSION=${VERSION} --platform linux/${ARCH} -t ${DOCKER_IMAGE}-${TAG_ARCH}:latest -t ${DOCKER_IMAGE}-${TAG_ARCH}:${VERSION} --file Dockerfile-for-github-action --push .
65 | done
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Python template
2 | config.yaml
3 | .env
4 |
5 | translations
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 | share/python-wheels/
29 | *.egg-info/
30 | .installed.cfg
31 | *.egg
32 | MANIFEST
33 |
34 | # PyInstaller
35 | # Usually these files are written by a python script from a template
36 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
37 | *.manifest
38 | *.spec
39 |
40 | # Installer logs
41 | pip-log.txt
42 | pip-delete-this-directory.txt
43 |
44 | # Unit test / coverage reports
45 | htmlcov/
46 | .tox/
47 | .nox/
48 | .coverage
49 | .coverage.*
50 | .cache
51 | nosetests.xml
52 | coverage.xml
53 | *.cover
54 | *.py,cover
55 | .hypothesis/
56 | .pytest_cache/
57 | cover/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | .pybuilder/
81 | target/
82 |
83 | # Jupyter Notebook
84 | .ipynb_checkpoints
85 |
86 | # IPython
87 | profile_default/
88 | ipython_config.py
89 |
90 | # pyenv
91 | # For a library or package, you might want to ignore these files since the code is
92 | # intended to run in multiple environments; otherwise, check them in:
93 | # .python-version
94 |
95 | # pipenv
96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
99 | # install all needed dependencies.
100 | #Pipfile.lock
101 |
102 | # poetry
103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
104 | # This is especially recommended for binary packages to ensure reproducibility, and is more
105 | # commonly ignored for libraries.
106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
107 | #poetry.lock
108 |
109 | # pdm
110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
111 | #pdm.lock
112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
113 | # in version control.
114 | # https://pdm.fming.dev/#use-with-ide
115 | .pdm.toml
116 |
117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
118 | __pypackages__/
119 |
120 | # Celery stuff
121 | celerybeat-schedule
122 | celerybeat.pid
123 |
124 | # SageMath parsed files
125 | *.sage.py
126 |
127 | # Environments
128 | .env
129 | .venv
130 | env/
131 | venv/
132 | ENV/
133 | env.bak/
134 | venv.bak/
135 |
136 | # Spyder project settings
137 | .spyderproject
138 | .spyproject
139 |
140 | # Rope project settings
141 | .ropeproject
142 |
143 | # mkdocs documentation
144 | /site
145 |
146 | # mypy
147 | .mypy_cache/
148 | .dmypy.json
149 | dmypy.json
150 |
151 | # Pyre type checker
152 | .pyre/
153 |
154 | # pytype static type analyzer
155 | .pytype/
156 |
157 | # Cython debug symbols
158 | cython_debug/
159 |
160 | # PyCharm
161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
163 | # and can be added to the global gitignore or merged into this file. For a more nuclear
164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
165 | .idea/
166 | *.db
167 | ./script/__pycache__
168 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // 使用 IntelliSense 了解相关属性。
3 | // 悬停以查看现有属性的描述。
4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Python 调试程序: 当前文件",
9 | "type": "debugpy",
10 | "request": "launch",
11 | // "program": "${file}",
12 | "program": "main.py",
13 | "console": "integratedTerminal",
14 | "cwd": "${workspaceFolder}\\scripts"
15 | }
16 | ]
17 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [v1.6.4] - 2025-01-06
4 |
5 | ### Fixed
6 |
7 | - add-on功能优化.
8 |
9 | ## [v1.6.3] - 2025-01-05
10 |
11 | ### Added
12 |
13 | - 初步测试了addon功能.
14 |
15 | ### Fixed
16 |
17 | - 修复了一些小bug.
18 |
19 | [v1.6.4]: https://github.com/ARC-MX/sgcc_electricity_new/compare/v1.6.3...v1.6.4
20 | [v1.6.3]: https://github.com/ARC-MX/sgcc_electricity_new/releases/tag/v1.6.3
21 |
--------------------------------------------------------------------------------
/DOCS.md:
--------------------------------------------------------------------------------
1 | # Home Assistant sgcc electricity new Add-on 配置和启动
2 |
3 | ## 配置和启动
4 | ### 1. 配置
5 | - 安装完成后,点击 "CONFIGURATION" 或 "配置" 标签
6 | - 根据需要修改配置参数
7 | 
8 | - 点击显示未使用的可选配置选项按钮,可以配置ignore_user_id等可选参数
9 | 
10 | 
11 |
12 | - 点击 "SAVE" 或 "保存" 保存配置
13 |
14 | ### 2. 启动
15 | - 点击 "Info" 或 "信息" 标签页
16 | - 点击 "START" 或 "启动" 启动 Add-on
17 | 
18 |
19 | - 启动后,点击 "日志" 标签页,可以看到Add-on的运行状态
20 | 
21 |
22 | ## 常见问题
23 | - 如果无法找到新添加的 Add-on,请尝试刷新页面
24 | - 如果安装失败,检查存储库地址是否正确
25 | - 遇到问题可以查看 "日志" 标签页的日志信息
--------------------------------------------------------------------------------
/Dockerfile-for-github-action:
--------------------------------------------------------------------------------
1 | FROM python:3.11.11-slim-bookworm as build
2 |
3 | ENV PYTHONDONTWRITEBYTECODE=1
4 | ENV PYTHONUNBUFFERED=1
5 | ENV SET_CONTAINER_TIMEZONE=true
6 | ENV CONTAINER_TIMEZONE=Asia/Shanghai
7 | ENV TZ=Asia/Shanghai
8 |
9 |
10 | ARG TARGETARCH
11 | ARG VERSION
12 | ENV VERSION=${VERSION}
13 | ENV PYTHON_IN_DOCKER='PYTHON_IN_DOCKER'
14 |
15 | COPY scripts/* /app/
16 | WORKDIR /app
17 |
18 | RUN apt-get --allow-releaseinfo-change update \
19 | && apt-get install -y --no-install-recommends jq chromium chromium-driver tzdata \
20 | && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
21 | && echo $TZ > /etc/timezone \
22 | && dpkg-reconfigure --frontend noninteractive tzdata \
23 | && rm -rf /var/lib/apt/lists/* \
24 | && apt-get clean
25 |
26 | COPY ./requirements.txt /tmp/requirements.txt
27 |
28 | RUN mkdir /data \
29 | && cd /tmp \
30 | && python3 -m pip install --upgrade pip \
31 | && PIP_ROOT_USER_ACTION=ignore pip install \
32 | --disable-pip-version-check \
33 | --no-cache-dir \
34 | -r requirements.txt \
35 | && rm -rf /tmp/* \
36 | && pip cache purge \
37 | && rm -rf /var/lib/apt/lists/* \
38 | && rm -rf /var/log/*
39 |
40 | ENV LANG=C.UTF-8
41 |
42 | CMD ["python3","main.py"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
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 | **重要说明:**原作者@renhai-lab 已于2023年10将项目归档,原仓库不再更新。这个版本是在原仓库基础上大幅改动,在此向原作者表达谢意和致敬。验证码识别已经从最开始的在线商业API替换成离线神经网络检测版本,请使用本仓库的同学点个小星星,或者打赏鼓励。
2 |
3 | 添加微信通知后,我想这基本上就是这个插件的最终形态了,docker镜像压缩到300MB,后续可能只会在网站变动或者出问题才会更新,再次感谢大家的Star。
4 |
5 | **注意** 有很多新手都在提交验证码不能识别的相关issue,特在此统一说明:国网每天有登录限制,每天只能登录有限的几次,超过限制验证码识别成功也不会登录成功。因此,诸如[issue47](https://github.com/ARC-MX/sgcc_electricity_new/issues/47),[issue50](https://github.com/ARC-MX/sgcc_electricity_new/issues/50),[issue29](https://github.com/ARC-MX/sgcc_electricity_new/issues/29)这些都是这个问题,以后就不做回复了。
6 |
7 | 最近issue太多实在是回复不过来了,特此添加QQ交流群
8 | [关于创建QQ付费群的说明](https://github.com/ARC-MX/sgcc_electricity_new/issues/78)
9 |
10 | ### 支付宝&微信 打赏码
11 |
12 |
13 |
14 |
15 |
16 |
17 | # ⚡️国家电网电力获取
18 |
19 | [](https://github.com/ARC-MX/sgcc_electricity_new/actions/workflows/docker-image.yml)
20 | [](https://hub.docker.com/r/arcw/sgcc_electricity)
21 | [](https://hub.docker.com/r/arcw/sgcc_electricity)
22 |
23 |
24 |
25 |
26 |
27 |
28 | ## 简介
29 |
30 | 本应用可以帮助你将国网的电费、用电量数据接入homeassistant,实现实时追踪家庭用电量情况;并且可以将每日用电量保存到数据库,历史有迹可循。具体提供两类数据:
31 |
32 | 1. 在homeassistant以实体显示:
33 |
34 | | 实体entity_id | 说明 |
35 | | -------------------------------------- | -------------------------------------------------- |
36 | | sensor.last_electricity_usage_xxxx | 最近一天用电量,单位KWH、度。 |
37 | | sensor.electricity_charge_balance_xxxx | 预付费显示电费余额,反之显示上月应交电费,单位元。 |
38 | | sensor.yearly_electricity_usage_xxxx | 今年总用电量,单位KWH、度。 |
39 | | sensor.yearly_electricity_charge_xxxx | 今年总用电费,单位元。 |
40 | | sensor.month_electricity_usage_xxxx | 最近一个月用电量,单位KWH、度。 |
41 | | sensor.month_electricity_charge_xxxx | 上月总用电费,单位元。 |
42 | 2. 可选,近三十天每日用电量数据(SQLite数据库)
43 | 数据库表名为 daily+userid ,在项目路径下有个homeassistant.db 的数据库文件就是;
44 | 如需查询可以用
45 |
46 | ```
47 | "SELECT * FROM dailyxxxxxxxx;"
48 | ```
49 |
50 | 得到如下结果:
51 |
52 |
53 |
54 | ## 适用范围
55 |
56 | 1. 适用于除南方电网覆盖省份外的用户。即除广东、广西、云南、贵州、海南等省份的用户外,均可使用本应用获取电力、电费数据。
57 | 2. 不管是通过哪种哪种安装的homeassistant,只要可以运行python,有约1G硬盘空间和500M运行内存,都可以采用本仓库部署。
58 |
59 | 本镜像支持架构:
60 |
61 | > - `linux/amd64`:适用于 x86-64(amd64)架构的 Linux 系统,例如windows电脑。
62 | > - `linux/arm64`:适用于 ARMv8 架构的 Linux 系统,例如树莓派3+,N1盒子等。
63 | > - `linux/armv7`,暂不提供 ARMv7 架构的 Linux 系统,例如树莓派2,玩客云等,主要原因是onnx-runtime没有armv7版本的库,用户可以参考 [https://github.com/nknytk/built-onnxruntime-for-raspberrypi-linux.git](https://github.com/nknytk/built-onnxruntime-for-raspberrypi-linux.git)自行安装库然后编译docker镜像。
64 |
65 | ## 实现流程
66 |
67 | 通过python的selenium包获取国家电网的数据,通过homeassistant的提供的[REST API](https://developers.home-assistant.io/docs/api/rest/)将采用POST请求将实体状态更新到homeassistant。
68 |
69 | 国家电网添加了滑动验证码登录验证,我这边最早采取了调用商业API的方式,现在已经更新成了离线方案。利用Yolov3神经网络识别验证码,请大家放心使用。
70 |
71 | # 安装与部署
72 |
73 | ## 1)注册国家电网账户
74 |
75 | 首先要注册国家电网账户,绑定电表,并且可以手动查询电量
76 |
77 | 注册网址:[https://www.95598.cn/osgweb/login](https://www.95598.cn/osgweb/login)
78 |
79 | ## 2)获取HA token
80 |
81 | token获取方法参考[https://blog.csdn.net/qq_25886111/article/details/106282492](https://blog.csdn.net/qq_25886111/article/details/106282492)
82 |
83 | ## 3)docker镜像部署,速度快
84 |
85 | 1. 安装docker和homeassistant,[Homeassistant极简安装法](https://github.com/renhaiidea/easy-homeassistant)。
86 | 2. 克隆仓库
87 |
88 | ```bash
89 | git clone https://github.com/ARC-MX/sgcc_electricity_new.git
90 | # 如果github网络环境不好的话可以使用国内镜像,完全同步的,个人推荐使用国内镜像
91 | # git clone https://gitee.com/ARC-MX/sgcc_electricity_new.git
92 | cd sgcc_electricity_new
93 | ```
94 |
95 | 3. 创建环境变量文件
96 |
97 | ```bash
98 | cp example.env .env
99 | vim .env # 参考以下文件编写.env文件
100 | ```
101 |
102 | ```bash
103 | ### 以下项都需要修改
104 | ## 国网登录信息
105 | # 修改为自己的登录账号
106 | PHONE_NUMBER="xxx"
107 | # 修改为自己的登录密码
108 | PASSWORD="xxxx"
109 | # 排除指定用户ID,如果出现一些不想检测的ID或者有些充电、发电帐号、可以使用这个环境变量,如果有多个就用","分隔,","之间不要有空格
110 | IGNORE_USER_ID=xxxxxxx,xxxxxxx,xxxxxxx
111 |
112 | # SQLite 数据库配置
113 | # or False 不启用数据库储存每日用电量数据。
114 | ENABLE_DATABASE_STORAGE=True
115 | # 数据库名,默认为homeassistant
116 | DB_NAME="homeassistant.db"
117 | # COLLECTION_NAME默认为electricity_daily_usage_{国网用户id},不支持修改。
118 |
119 | ## homeassistant配置
120 | # 改为你的localhost为你的homeassistant地址
121 | HASS_URL="http://localhost:8123/"
122 | # homeassistant的长期令牌
123 | HASS_TOKEN="eyxxxxx"
124 |
125 | ## selenium运行参数
126 | # 任务开始时间,24小时制,例如"07:00”则为每天早上7点执行,第一次启动程序如果时间晚于早上7点则会立即执行一次,每隔12小时执行一次。
127 | JOB_START_TIME="07:00"
128 | # 每次操作等待时间,推荐设定范围为[2,30],该值表示每次点击网页后所要等待数据加载的时间,如果出现“no such element”诸如此类的错误可适当调大该值,如果硬件性能较好可以适当调小该值
129 | RETRY_WAIT_TIME_OFFSET_UNIT=15
130 |
131 |
132 | ## 记录的天数, 仅支持填写 7 或 30
133 | # 国网原本可以记录 30 天,现在不开通智能缴费只能查询 7 天造成错误
134 | DATA_RETENTION_DAYS=7
135 |
136 | ## 余额提醒
137 | # 是否缴费提醒
138 | RECHARGE_NOTIFY=Flase
139 | # 余额
140 | BALANCE=5.0
141 | # pushplus token 如果有多个就用","分隔,","之间不要有空格,单个就不要有","
142 | PUSHPLUS_TOKEN=xxxxxxx,xxxxxxx,xxxxxxx
143 | ```
144 |
145 | 4. 运行
146 |
147 | 我已经优化了镜像环境,将镜像的地址配置为阿里云,如果要使用docker hub的源可以将docker-compose.yml中
148 | image: registry.cn-hangzhou.aliyuncs.com/arcw/sgcc_electricity:latest 改为 arcw/sgcc_electricity:latest
149 |
150 | ```bash
151 | 运行获取传感器名称
152 | docker-compose up -d
153 | docker-compose logs sgcc_electricity_app
154 | ```
155 |
156 | 运行成功应该显示如下日志:
157 |
158 | ```bash
159 | 2024-06-06 16:00:43 [INFO ] ---- 程序开始,当前仓库版本为1.x.x,仓库地址为https://github.com/ARC-MX/sgcc_electricity_new.git
160 | 2024-06-06 16:00:43 [INFO ] ---- enable_database_storage为false,不会储存到数据库
161 | 2024-06-06 16:00:43 [INFO ] ---- 当前登录的用户名为: xxxxxx,homeassistant地址为http://192.168.1.xx:8123/,程序将在每天00:00执行
162 | 2024-06-06 16:00:43 [INFO ] ---- 此次为首次运行,等待时间(FIRST_SLEEP_TIME)为10秒,可在.env中设置
163 | 2024-06-06 16:00:59 [INFO ] ---- Webdriver initialized.
164 | 2024-06-06 16:01:20 [INFO ] ---- Click login button.
165 | 2024-06-06 16:01:20 [INFO ] ---- Get electricity canvas image successfully.
166 | 2024-06-06 16:01:20 [INFO ] ---- Image CaptCHA distance is xxx.
167 | 2024-06-06 16:01:25 [INFO ] ---- Login successfully on https://www.95598.cn/osgweb/login
168 | 2024-06-06 16:01:33 [INFO ] ---- 将获取1户数据,user_id: ['xxxxxxx']
169 | 2024-06-06 16:01:42 [INFO ] ---- Get electricity charge balance for xxxxxxx successfully, balance is xxx CNY.
170 | 2024-06-06 16:01:51 [INFO ] ---- Get year power usage for xxxxxxx successfully, usage is xxx kwh
171 | 2024-06-06 16:01:51 [INFO ] ---- Get year power charge for xxxxxxx successfully, yealrly charge is xxx CNY
172 | 2024-06-06 16:01:55 [INFO ] ---- Get month power charge for xxxxxxx successfully, 01 月 usage is xxx KWh, charge is xxx CNY.
173 | 2024-06-06 16:01:55 [INFO ] ---- Get month power charge for xxxxxxx successfully, 02 月 usage is xxx KWh, charge is xxx CNY.
174 | 2024-06-06 16:01:55 [INFO ] ---- Get month power charge for xxxxxxx successfully, 2024-03-01-2024-03-31 usage is xxx KWh, charge is xxx CNY.
175 | 2024-06-06 16:01:55 [INFO ] ---- Get month power charge for xxxxxxx successfully, 2024-04-01-2024-04-30 usage is xxx KWh, charge is xxx CNY.
176 | 2024-06-06 16:01:59 [INFO ] ---- Get daily power consumption for xxxxxxx successfully, , 2024-06-05 usage is xxx kwh.
177 | ........
178 | 2024-12-25 13:43:25 [INFO ] ---- Check the electricity bill balance. When the balance is less than 100.0 CNY, the notification will be sent = True
179 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.electricity_charge_balance_xxxx state updated: 102.3 CNY
180 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.last_electricity_usage_xxxx state updated: 6.56 kWh
181 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.yearly_electricity_usage_xxxx state updated: 1691 kWh
182 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.yearly_electricity_charge_xxxx state updated: 758.57 CNY
183 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.month_electricity_usage_xxxx state updated: 169 kWh
184 | 2024-12-25 13:43:25 [INFO ] ---- Homeassistant sensor sensor.month_electricity_charge_xxxx state updated: 75.81 CNY
185 | 2024-12-25 13:43:25 [INFO ] ---- User xxxxxxx state-refresh task run successfully!
186 | ```
187 |
188 | **sensor.electricity_charge_balance_xxxx 为余额传感器**
189 |
190 | 5. 配置configuration.yaml文件, 将下面中的_xxxx 替换为自己log中的_xxxx后缀。
191 | 6. 由于是API方式传递传感器数据,所以要想重启ha实体ID可用,必须配置如下
192 |
193 | ```yaml
194 | template:
195 | - trigger:
196 | - platform: event
197 | event_type: state_changed
198 | event_data:
199 | entity_id: sensor.electricity_charge_balance_xxxx
200 | sensor:
201 | - name: electricity_charge_balance_xxxx
202 | unique_id: electricity_charge_balance_xxxx
203 | state: "{{ states('sensor.electricity_charge_balance_xxxx') }}"
204 | state_class: total
205 | unit_of_measurement: "CNY"
206 | device_class: monetary
207 |
208 | - trigger:
209 | - platform: event
210 | event_type: state_changed
211 | event_data:
212 | entity_id: sensor.last_electricity_usage_xxxx
213 | sensor:
214 | - name: last_electricity_usage_xxxx
215 | unique_id: last_electricity_usage_xxxx
216 | state: "{{ states('sensor.last_electricity_usage_xxxx') }}"
217 | state_class: measurement
218 | unit_of_measurement: "kWh"
219 | device_class: energy
220 |
221 | - trigger:
222 | - platform: event
223 | event_type: state_changed
224 | event_data:
225 | entity_id: sensor.month_electricity_usage_xxxx
226 | sensor:
227 | - name: month_electricity_usage_xxxx
228 | unique_id: month_electricity_usage_xxxx
229 | state: "{{ states('sensor.month_electricity_usage_xxxx') }}"
230 | state_class: measurement
231 | unit_of_measurement: "kWh"
232 | device_class: energy
233 |
234 | - trigger:
235 | - platform: event
236 | event_type: state_changed
237 | event_data:
238 | entity_id: sensor.month_electricity_charge_xxxx
239 | sensor:
240 | - name: month_electricity_charge_xxxx
241 | unique_id: month_electricity_charge_xxxx
242 | state: "{{ states('sensor.month_electricity_charge_xxxx') }}"
243 | state_class: measurement
244 | unit_of_measurement: "CNY"
245 | device_class: monetary
246 |
247 | - trigger:
248 | - platform: event
249 | event_type: state_changed
250 | event_data:
251 | entity_id: sensor.yearly_electricity_usage_xxxx
252 | sensor:
253 | - name: yearly_electricity_usage_xxxx
254 | unique_id: yearly_electricity_usage_xxxx
255 | state: "{{ states('sensor.yearly_electricity_usage_xxxx') }}"
256 | state_class: total_increasing
257 | unit_of_measurement: "kWh"
258 | device_class: energy
259 |
260 | - trigger:
261 | - platform: event
262 | event_type: state_changed
263 | event_data:
264 | entity_id: sensor.yearly_electricity_charge_xxxx
265 | sensor:
266 | - name: yearly_electricity_charge_xxxx
267 | unique_id: yearly_electricity_charge_xxxx
268 | state: "{{ states('sensor.yearly_electricity_charge_xxxx') }}"
269 | state_class: total_increasing
270 | unit_of_measurement: "CNY"
271 | device_class: monetary
272 | ```
273 |
274 | 配置完成后重启HA, 刷新一下HA界面
275 |
276 |
277 |
278 | 6. 更新容器及其代码(需要更新才需要)
279 |
280 | ```bash
281 | docker-compose down # 删除容器
282 | docker-compose pull # 更新镜像
283 | git pull --tags origin master:master #更新代码,代码不在容器中,所以要手动更新
284 | docker-compose up -d # 重新运行
285 | #如果git 拉取失败可以执行如下命令,重新拉取
286 | git fetch --all
287 | git reset --hard origin/master
288 | git pull
289 | ```
290 |
291 | ## 4)ha内数据展示
292 |
293 |
294 |
295 | 结合[mini-graph-card](https://github.com/kalkih/mini-graph-card) 和[mushroom](https://github.com/piitaya/lovelace-mushroom)实现美化效果:
296 |
297 |
298 |
299 | 将下面中的_xxxx 替换为自己log中的_xxxx后缀。
300 |
301 | ```yaml
302 | type: vertical-stack
303 | cards:
304 | - type: custom:mini-graph-card
305 | entities:
306 | - entity: sensor.last_electricity_usage_xxxx
307 | name: 国网每日用电量
308 | aggregate_func: first
309 | show_state: true
310 | show_points: true
311 | icon: mdi:lightning-bolt-outline
312 | - entity: sensor.electricity_charge_balance_xxxx
313 | name: 电费余额
314 | aggregate_func: first
315 | show_state: true
316 | show_points: true
317 | color: "#e74c3c"
318 | icon: mdi:cash
319 | y_axis: secondary
320 | group_by: date
321 | hour24: true
322 | hours_to_show: 240
323 | lower_bound: 0
324 | upper_bound: 10
325 | lower_bound_secondary: 0
326 | upper_bound_secondary: 120
327 | show:
328 | icon: false
329 | - type: horizontal-stack
330 | cards:
331 | - graph: none
332 | type: sensor
333 | entity: sensor.month_electricity_charge_xxxx
334 | detail: 1
335 | name: 上月电费
336 | icon: ""
337 | unit: 元
338 | - graph: none
339 | type: sensor
340 | entity: sensor.month_electricity_usage_xxxx
341 | detail: 1
342 | name: 上月用电量
343 | unit: 度
344 | icon: mdi:lightning-bolt-outline
345 | - type: horizontal-stack
346 | cards:
347 | - animate: true
348 | entities:
349 | - entity: sensor.yearly_electricity_usage_xxxx
350 | name: 今年总用电量
351 | aggregate_func: first
352 | show_state: true
353 | show_points: true
354 | group_by: date
355 | hour24: true
356 | hours_to_show: 240
357 | type: custom:mini-graph-card
358 | - animate: true
359 | entities:
360 | - entity: sensor.yearly_electricity_charge_xxxx
361 | name: 今年总用电费用
362 | aggregate_func: first
363 | show_state: true
364 | show_points: true
365 | group_by: date
366 | hour24: true
367 | hours_to_show: 240
368 | type: custom:mini-graph-card
369 | ```
370 |
371 | ## 5)电量通知
372 |
373 | 更新电费余额不足提醒,在.env里设置提醒余额。目前我是用[pushplus](https://www.pushplus.plus/)的方案,注册pushplus然后,获取token,通知给谁就让谁注册并将token填到.env中
374 | token获取方法参考[https://cloud.tencent.com/developer/article/2139538](https://cloud.tencent.com/developer/article/2139538)
375 |
376 | # 其他
377 |
378 | > 当前作者:[https://github.com/ARC-MX/sgcc_electricity_new](https://github.com/ARC-MX/sgcc_electricity_new)
379 | >
380 | > 原作者:[https://github.com/louisslee/sgcc_electricity](https://github.com/louisslee/sgcc_electricity),原始[README_origin.md](归档/README_origin.md)。
381 |
382 | ## 我的自定义部分包括:
383 |
384 | 增加的部分:
385 |
386 | - 增加近30天每日电量写入数据库(默认mongodb),其他数据库请自行配置。
387 | - 添加配置默认增加近 7 天每日电量写入数据, 可修改为 30 天, 因为国网目前[「要签约智能交费才能看到30天的数据,不然就只能看到7天的」](https://github.com/ARC-MX/sgcc_electricity_new/issues/11#issuecomment-2158973048)。【注意:开通智能缴费后电费可能从「后付费」变为「预付费」,也就是「欠费即停电」,习惯了每月定时按账单缴费的需要注意,谨防停电风险】
388 | - 将间歇执行设置为定时执行: JOB_START_TIME,24小时制,例如"07:00”则为每天早上7点执行,第一次启动程序立即执行一次, 每12小时执行一次
389 | - 给last_daily_usage增加present_date,用来确定更新的是哪一天的电量。一般查询的日期会晚一到两天。
390 | - 对configuration.yaml中自定义实体部分修改。
391 |
392 | ## 重要修改通知
393 |
394 | 2024-06-13:SQLite替换MongoDB,原因是python自带SQLite3,不需要额外安装,也不再需要MongoDB镜像。
395 | 2024-07-03:新增每天定时执行两次,添加配置默认增加近 7 天每日电量写入数据, 可修改为 30 天。
396 | 2024-07-05:新增余额不足提醒功能。
397 | 2024-12-10:新增忽略指定用户ID的功能:针对一些用户拥有充电或者发电账户,可以使用 IGNORE_USER_ID 环境变量忽略特定的ID。
398 | 2025-01-05:新增Homeassistant Add-on部署方式。
399 | TO-DO
400 |
401 | - [X] 增加离线滑动验证码识别方案
402 | - [X] 添加默认推送服务,电费余额不足提醒
403 | - [X] 添加Homeassistant Add-on安装方式,在此感谢[Ami8834671](https://github.com/Ami8834671), [DuanXDong](https://github.com/DuanXDong)等小伙伴的idea和贡献
404 | - [ ] 添加置Homeassistant integration
405 |
406 | ## **技术交流群**
407 |
408 | 由于现在用户越来越多,稍有问题大家就在github上发issue,我有点回复不过来了,故创建一个付费加入的QQ群。该群只是方便大家讨论,不承诺技术协助,我想大多数用户参考历史issue和文档都能解决自己的问题
409 |
410 | ### 入群方式
411 |
412 | 通过为项目点star并微信打赏备注QQ名或QQ号等信息,入群会审核这些信息
413 |
414 |
415 |
416 | ### 再次说明,希望大家通过认真看文档和浏览历史issue解决问题,毕竟收费群不是开源项目的本意。
417 |
--------------------------------------------------------------------------------
/assets/Alipay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/Alipay.png
--------------------------------------------------------------------------------
/assets/Ha-mini-card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/Ha-mini-card.jpg
--------------------------------------------------------------------------------
/assets/QQ_group.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/QQ_group.jpg
--------------------------------------------------------------------------------
/assets/WeiChat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/WeiChat.jpg
--------------------------------------------------------------------------------
/assets/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/background.png
--------------------------------------------------------------------------------
/assets/badge.svg:
--------------------------------------------------------------------------------
1 |
2 | Docker Image CI - passing
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Docker Image CI
18 |
19 |
20 | Docker Image CI
21 |
22 |
23 |
24 |
25 |
26 | passing
27 |
28 |
29 | passing
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/assets/database.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/database.png
--------------------------------------------------------------------------------
/assets/edit1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/edit1.jpg
--------------------------------------------------------------------------------
/assets/image-20230730135540291.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/image-20230730135540291.png
--------------------------------------------------------------------------------
/assets/image-20240514.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/image-20240514.jpg
--------------------------------------------------------------------------------
/assets/restart.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/assets/restart.jpg
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | name: "SGCC Electricity"
2 | version: "v1.6.6"
3 | slug: "sgcc_electricity"
4 | description: "获取国网电费数据的插件"
5 | url: "https://github.com/ARC-MX/sgcc_electricity_new"
6 | arch:
7 | - aarch64
8 | - amd64
9 | host_network: true
10 | startup: application
11 | boot: auto
12 | init: false
13 | image: "registry.cn-hangzhou.aliyuncs.com/arcw/sgcc_electricity-{arch}"
14 | map:
15 | - config:rw
16 | options:
17 | PHONE_NUMBER: ""
18 | PASSWORD: ""
19 | IGNORE_USER_ID: "xxxx,xxxx"
20 | ENABLE_DATABASE_STORAGE: false
21 | DB_NAME: "homeassistant.db"
22 | HASS_URL: "http://homeassistant.local:8123/"
23 | HASS_TOKEN: ""
24 | JOB_START_TIME: "07:00"
25 | RETRY_WAIT_TIME_OFFSET_UNIT: 15
26 | DATA_RETENTION_DAYS: 7
27 | RECHARGE_NOTIFY: false
28 | BALANCE: 5.0
29 | PUSHPLUS_TOKEN: "xxxx,xxxx"
30 | schema:
31 | PHONE_NUMBER: str
32 | PASSWORD: password
33 | IGNORE_USER_ID: str
34 | ENABLE_DATABASE_STORAGE: bool
35 | DB_NAME: str
36 | HASS_URL: url
37 | HASS_TOKEN: str
38 | JOB_START_TIME: str
39 | RETRY_WAIT_TIME_OFFSET_UNIT: int(2,30)
40 | DATA_RETENTION_DAYS: int
41 | RECHARGE_NOTIFY: bool
42 | BALANCE: float
43 | PUSHPLUS_TOKEN: str
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | sgcc_electricity_app:
3 | env_file:
4 | - .env
5 | image: registry.cn-hangzhou.aliyuncs.com/arcw/sgcc_electricity:latest # for use docker.io: arcw/sgcc_electricity:latest
6 | container_name: sgcc_electricity
7 | network_mode: "host"
8 | environment:
9 | - SET_CONTAINER_TIMEZONE=true
10 | - CONTAINER_TIMEZONE=Asia/Shanghai
11 | restart: unless-stopped
12 | volumes:
13 | - ./:/data # if you want to read homeassistant.db, homeassistant.db is in the container at /data/
14 | command: python3 main.py
15 | init: true
16 |
--------------------------------------------------------------------------------
/example.env:
--------------------------------------------------------------------------------
1 | ### 以下项都需要修改
2 | ## 国网登录信息
3 | # 修改为自己的登录账号
4 | PHONE_NUMBER="xxx"
5 | # 修改为自己的登录密码
6 | PASSWORD="xxxx"
7 | # 排除指定用户ID,如果出现一些不想检测的ID或者有些充电、发电帐号、可以使用这个环境变量,如果有多个就用","分隔,","之间不要有空格
8 | IGNORE_USER_ID=xxxxxxx,xxxxxxx,xxxxxxx
9 |
10 | # SQLite 数据库配置
11 | # or False 不启用数据库储存每日用电量数据。
12 | ENABLE_DATABASE_STORAGE=False
13 | # 数据库名,默认为homeassistant
14 | DB_NAME="homeassistant.db"
15 |
16 | ## homeassistant配置
17 | # 改为你的localhost为你的homeassistant地址
18 | HASS_URL="http://localhost:8123/"
19 | # homeassistant的长期令牌
20 | HASS_TOKEN="eyxxxxx"
21 |
22 | ## selenium运行参数
23 | # 任务开始时间,24小时制,例如"07:00”则为每天早上7点执行,第一次启动程序如果时间晚于早上7点则会立即执行一次,每隔12小时执行一次。
24 | JOB_START_TIME="07:00"
25 | # 每次操作等待时间,推荐设定范围为[2,30],该值表示每次点击网页后所要等待数据加载的时间,如果出现“no such element”诸如此类的错误可适当调大该值,如果硬件性能较好可以适当调小该值
26 | RETRY_WAIT_TIME_OFFSET_UNIT=15
27 |
28 |
29 | ## 记录的天数, 仅支持填写 7 或 30
30 | # 国网原本可以记录 30 天,现在不开通智能缴费只能查询 7 天造成错误
31 | DATA_RETENTION_DAYS=7
32 |
33 | ## 余额提醒
34 | # 是否缴费提醒
35 | RECHARGE_NOTIFY=False
36 | # 余额
37 | BALANCE=5.0
38 | # pushplus token 如果有多个就用","分隔,","之间不要有空格
39 | PUSHPLUS_TOKEN=xxxxxxx,xxxxxxx,xxxxxxx
--------------------------------------------------------------------------------
/ha_addons_doc/Add-on教程.md:
--------------------------------------------------------------------------------
1 | # Home Assistant sgcc electricity new Add-on 安装教程
2 |
3 | ## 安装步骤
4 |
5 | ### 1. 添加add-on存储库
6 |
7 | - 打开 Home Assistant
8 | - 进入设置页面,点击 "Add-ons"或"加载项"
9 | 
10 | - 点击右下角的 "ADD-ON STORE"或"加载项商店"
11 | 
12 | - 点击右上角的三个点菜单
13 | - 选择 "Repositories"或"仓库"
14 | 
15 | - 在弹出的对话框中输入sgcc electricity new存储库地址
16 | - 地址:https://github.com/ARC-MX/sgcc_electricity_new
17 | - 推荐使用国内源:[https://gitee.com/ARC-MX/sgcc_electricity_new](https://gitee.com/ARC-MX/sgcc_electricity_new)
18 | - 点击 "ADD" 或 "添加" 确认添加
19 | 
20 |
21 | ### 2. 安装 Add-on
22 |
23 | - 点击右上角的三个点菜单
24 | - 选择 "Refresh" 或 "检查更新"
25 | 
26 | - 在列表中找到新添加的第三方 Add-on
27 | 
28 | - 点击想要安装的 Add-on
29 | - 点击 "INSTALL" 或 "安装" 开始安装
30 | 
31 | - 等待安装完成
32 | 
33 |
34 | ### 3. 配置和启动
35 |
36 | - 安装完成后,点击 "CONFIGURATION" 或 "配置" 标签
37 | - 根据需要修改配置参数
38 | 
39 | - 点击显示未使用的可选配置选项按钮,可以配置ignore_user_id等可选参数
40 | 
41 | - 点击 "SAVE" 或 "保存" 保存配置
42 | - 返回 "Info" 或 "信息" 标签页
43 | - 点击 "START" 或 "启动" 启动 Add-on
44 | 
45 | - 启动后,点击 "日志" 标签页,可以看到Add-on的运行状态
46 | 
47 |
48 | ## 常见问题
49 |
50 | - 如果无法找到新添加的 Add-on,请尝试刷新页面
51 | - 如果安装失败,检查存储库地址是否正确
52 | - 遇到问题可以查看 "日志" 标签页的日志信息
53 |
--------------------------------------------------------------------------------
/ha_addons_doc/img/add-repository.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/add-repository.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/addon-running-status.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/addon-running-status.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/addon-store.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/addon-store.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/addons-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/addons-page.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/configuration-ignore_user_id.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/configuration-ignore_user_id.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/configuration.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/configuration.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/find-addon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/find-addon.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/install-addon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/install-addon.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/installation-complete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/installation-complete.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/installation-progress.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/installation-progress.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/refresh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/refresh.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/repositories-menu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/repositories-menu.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/show-unused-options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/show-unused-options.png
--------------------------------------------------------------------------------
/ha_addons_doc/img/start-addon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/ha_addons_doc/img/start-addon.png
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/icon.png
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/logo.png
--------------------------------------------------------------------------------
/repository.yaml:
--------------------------------------------------------------------------------
1 | name: sgcc_electricity add-on repository
2 | url: 'https://github.com/ARC-MX/sgcc_electricity_new'
3 | maintainer: sgcc_electricity
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests==2.31.0
2 | selenium==4.19.0
3 | schedule==1.2.1
4 | Pillow==10.1.0
5 | undetected_chromedriver==3.5.4
6 | onnxruntime==1.18.1
7 | numpy==1.26.2
8 | # python-dotenv
9 | # python-dateutil
--------------------------------------------------------------------------------
/scripts/captcha.onnx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ARC-MX/sgcc_electricity_new/aa69ff7dfc50dc6413304f294a03b50254f0482a/scripts/captcha.onnx
--------------------------------------------------------------------------------
/scripts/const.py:
--------------------------------------------------------------------------------
1 | # 填写普通参数 不要填写密码等敏感信息
2 | # 国网电力官网
3 | LOGIN_URL = "https://www.95598.cn/osgweb/login"
4 | ELECTRIC_USAGE_URL = "https://www.95598.cn/osgweb/electricityCharge"
5 | BALANCE_URL = "https://www.95598.cn/osgweb/userAcc"
6 |
7 |
8 | # Home Assistant
9 | SUPERVISOR_URL = "http://supervisor/core"
10 | API_PATH = "/api/states/" # https://developers.home-assistant.io/docs/api/rest/
11 |
12 | BALANCE_SENSOR_NAME = "sensor.electricity_charge_balance"
13 | DAILY_USAGE_SENSOR_NAME = "sensor.last_electricity_usage"
14 | YEARLY_USAGE_SENSOR_NAME = "sensor.yearly_electricity_usage"
15 | YEARLY_CHARGE_SENSOR_NAME = "sensor.yearly_electricity_charge"
16 | MONTH_USAGE_SENSOR_NAME = "sensor.month_electricity_usage"
17 | MONTH_CHARGE_SENSOR_NAME = "sensor.month_electricity_charge"
18 | BALANCE_UNIT = "CNY"
19 | USAGE_UNIT = "KWH"
20 |
21 |
--------------------------------------------------------------------------------
/scripts/data_fetcher.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import re
4 | import subprocess
5 | import time
6 |
7 | import random
8 | import base64
9 | import sqlite3
10 | import undetected_chromedriver as uc
11 | from datetime import datetime
12 | from selenium import webdriver
13 | from selenium.webdriver import ActionChains
14 | from selenium.webdriver.chrome.options import Options
15 | from selenium.webdriver.common.by import By
16 | from selenium.webdriver.support import expected_conditions as EC
17 | from selenium.webdriver.support.wait import WebDriverWait
18 | from sensor_updator import SensorUpdator
19 |
20 | from const import *
21 |
22 | import numpy as np
23 | # import cv2
24 | from io import BytesIO
25 | from PIL import Image
26 | from onnx import ONNX
27 | import platform
28 |
29 |
30 | def base64_to_PLI(base64_str: str):
31 | base64_data = re.sub('^data:image/.+;base64,', '', base64_str)
32 | byte_data = base64.b64decode(base64_data)
33 | image_data = BytesIO(byte_data)
34 | img = Image.open(image_data)
35 | return img
36 |
37 | def get_transparency_location(image):
38 | '''获取基于透明元素裁切图片的左上角、右下角坐标
39 |
40 | :param image: cv2加载好的图像
41 | :return: (left, upper, right, lower)元组
42 | '''
43 | # 1. 扫描获得最左边透明点和最右边透明点坐标
44 | height, width, channel = image.shape # 高、宽、通道数
45 | assert channel == 4 # 无透明通道报错
46 | first_location = None # 最先遇到的透明点
47 | last_location = None # 最后遇到的透明点
48 | first_transparency = [] # 从左往右最先遇到的透明点,元素个数小于等于图像高度
49 | last_transparency = [] # 从左往右最后遇到的透明点,元素个数小于等于图像高度
50 | for y, rows in enumerate(image):
51 | for x, BGRA in enumerate(rows):
52 | alpha = BGRA[3]
53 | if alpha != 0:
54 | if not first_location or first_location[1] != y: # 透明点未赋值或为同一列
55 | first_location = (x, y) # 更新最先遇到的透明点
56 | first_transparency.append(first_location)
57 | last_location = (x, y) # 更新最后遇到的透明点
58 | if last_location:
59 | last_transparency.append(last_location)
60 |
61 | # 2. 矩形四个边的中点
62 | top = first_transparency[0]
63 | bottom = first_transparency[-1]
64 | left = None
65 | right = None
66 | for first, last in zip(first_transparency, last_transparency):
67 | if not left:
68 | left = first
69 | if not right:
70 | right = last
71 | if first[0] < left[0]:
72 | left = first
73 | if last[0] > right[0]:
74 | right = last
75 |
76 | # 3. 左上角、右下角
77 | upper_left = (left[0], top[1]) # 左上角
78 | bottom_right = (right[0], bottom[1]) # 右下角
79 |
80 | return upper_left[0], upper_left[1], bottom_right[0], bottom_right[1]
81 |
82 | class DataFetcher:
83 |
84 | def __init__(self, username: str, password: str):
85 | if 'PYTHON_IN_DOCKER' not in os.environ:
86 | import dotenv
87 | dotenv.load_dotenv(verbose=True)
88 | self._username = username
89 | self._password = password
90 | self.onnx = ONNX("./captcha.onnx")
91 | if platform.system() == 'Windows':
92 | pass
93 | else:
94 | self._chromium_version = self._get_chromium_version()
95 |
96 | # 获取 ENABLE_DATABASE_STORAGE 的值,默认为 False
97 | self.enable_database_storage = os.getenv("ENABLE_DATABASE_STORAGE", "false").lower() == "true"
98 | self.DRIVER_IMPLICITY_WAIT_TIME = int(os.getenv("DRIVER_IMPLICITY_WAIT_TIME", 60))
99 | self.RETRY_TIMES_LIMIT = int(os.getenv("RETRY_TIMES_LIMIT", 5))
100 | self.LOGIN_EXPECTED_TIME = int(os.getenv("LOGIN_EXPECTED_TIME", 10))
101 | self.RETRY_WAIT_TIME_OFFSET_UNIT = int(os.getenv("RETRY_WAIT_TIME_OFFSET_UNIT", 10))
102 | self.IGNORE_USER_ID = os.getenv("IGNORE_USER_ID", "xxxxx,xxxxx").split(",")
103 |
104 | # @staticmethod
105 | def _click_button(self, driver, button_search_type, button_search_key):
106 | '''wrapped click function, click only when the element is clickable'''
107 | click_element = driver.find_element(button_search_type, button_search_key)
108 | # logging.info(f"click_element:{button_search_key}.is_displayed() = {click_element.is_displayed()}\r")
109 | # logging.info(f"click_element:{button_search_key}.is_enabled() = {click_element.is_enabled()}\r")
110 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.element_to_be_clickable(click_element))
111 | driver.execute_script("arguments[0].click();", click_element)
112 |
113 | # @staticmethod
114 | def _is_captcha_legal(self, captcha):
115 | ''' check the ddddocr result, justify whether it's legal'''
116 | if (len(captcha) != 4):
117 | return False
118 | for s in captcha:
119 | if (not s.isalpha() and not s.isdigit()):
120 | return False
121 | return True
122 |
123 | # @staticmethod
124 | def _get_chromium_version(self):
125 | result = str(subprocess.check_output(["chromium", "--product-version"]))
126 | version = re.findall(r"(\d*)\.", result)[0]
127 | logging.info(f"chromium-driver version is {version}")
128 | return int(version)
129 |
130 | # @staticmethod
131 | def _sliding_track(self, driver, distance):# 机器模拟人工滑动轨迹
132 | # 获取按钮
133 | slider = driver.find_element(By.CLASS_NAME, "slide-verify-slider-mask-item")
134 | ActionChains(driver).click_and_hold(slider).perform()
135 | # 获取轨迹
136 | # tracks = _get_tracks(distance)
137 | # for t in tracks:
138 | yoffset_random = random.uniform(-2, 4)
139 | ActionChains(driver).move_by_offset(xoffset=distance, yoffset=yoffset_random).perform()
140 | # time.sleep(0.2)
141 | ActionChains(driver).release().perform()
142 |
143 | def connect_user_db(self, user_id):
144 | """创建数据库集合,db_name = electricity_daily_usage_{user_id}
145 | :param user_id: 用户ID"""
146 | try:
147 | # 创建数据库
148 | DB_NAME = os.getenv("DB_NAME", "homeassistant.db")
149 | if 'PYTHON_IN_DOCKER' in os.environ:
150 | DB_NAME = "/data/" + DB_NAME
151 | self.connect = sqlite3.connect(DB_NAME)
152 | self.connect.cursor()
153 | logging.info(f"Database of {DB_NAME} created successfully.")
154 | # 创建表名
155 | self.table_name = f"daily{user_id}"
156 | sql = f'''CREATE TABLE IF NOT EXISTS {self.table_name} (
157 | date DATE PRIMARY KEY NOT NULL,
158 | usage REAL NOT NULL)'''
159 | self.connect.execute(sql)
160 | logging.info(f"Table {self.table_name} created successfully")
161 |
162 | # 创建data表名
163 | self.table_expand_name = f"data{user_id}"
164 | sql = f'''CREATE TABLE IF NOT EXISTS {self.table_expand_name} (
165 | name TEXT PRIMARY KEY NOT NULL,
166 | value TEXT NOT NULL)'''
167 | self.connect.execute(sql)
168 | logging.info(f"Table {self.table_expand_name} created successfully")
169 |
170 | # 如果表已存在,则不会创建
171 | except sqlite3.Error as e:
172 | logging.debug(f"Create db or Table error:{e}")
173 | return False
174 | return True
175 |
176 | def insert_data(self, data:dict):
177 | if self.connect is None:
178 | logging.error("Database connection is not established.")
179 | return
180 | # 创建索引
181 | try:
182 | sql = f"INSERT OR REPLACE INTO {self.table_name} VALUES(strftime('%Y-%m-%d','{data['date']}'),{data['usage']});"
183 | self.connect.execute(sql)
184 | self.connect.commit()
185 | except BaseException as e:
186 | logging.debug(f"Data update failed: {e}")
187 |
188 | def insert_expand_data(self, data:dict):
189 | if self.connect is None:
190 | logging.error("Database connection is not established.")
191 | return
192 | # 创建索引
193 | try:
194 | sql = f"INSERT OR REPLACE INTO {self.table_expand_name} VALUES('{data['name']}','{data['value']}');"
195 | self.connect.execute(sql)
196 | self.connect.commit()
197 | except BaseException as e:
198 | logging.debug(f"Data update failed: {e}")
199 |
200 |
201 | def _get_webdriver(self):
202 | chrome_options = Options()
203 | chrome_options.add_argument('--incognito')
204 | chrome_options.add_argument('--window-size=4000,1600')
205 | chrome_options.add_argument('--headless')
206 | chrome_options.add_argument('--no-sandbox')
207 | chrome_options.add_argument('--disable-gpu')
208 | chrome_options.add_argument('--disable-dev-shm-usage')
209 | driver = uc.Chrome(driver_executable_path="/usr/bin/chromedriver", options=chrome_options, version_main=self._chromium_version)
210 | driver.implicitly_wait(self.DRIVER_IMPLICITY_WAIT_TIME)
211 | return driver
212 |
213 | def _login(self, driver, phone_code = False):
214 |
215 | driver.get(LOGIN_URL)
216 | logging.info(f"Open LOGIN_URL:{LOGIN_URL}.\r")
217 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
218 | # swtich to username-password login page
219 | driver.find_element(By.CLASS_NAME, "user").click()
220 | logging.info("find_element 'user'.\r")
221 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[1]/div[1]/div[2]/span')
222 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
223 | # click agree button
224 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[2]/div[1]/form/div[1]/div[3]/div/span[2]')
225 | logging.info("Click the Agree option.\r")
226 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
227 | if phone_code:
228 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[1]/div[1]/div[3]/span')
229 | input_elements = driver.find_elements(By.CLASS_NAME, "el-input__inner")
230 | input_elements[2].send_keys(self._username)
231 | logging.info(f"input_elements username : {self._username}\r")
232 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[2]/div[2]/form/div[1]/div[2]/div[2]/div/a')
233 | code = input("Input your phone verification code: ")
234 | input_elements[3].send_keys(code)
235 | logging.info(f"input_elements verification code: {code}.\r")
236 | # click login button
237 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[2]/div[2]/form/div[2]/div/button/span')
238 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT*2)
239 | logging.info("Click login button.\r")
240 |
241 | return True
242 | else :
243 | # input username and password
244 | input_elements = driver.find_elements(By.CLASS_NAME, "el-input__inner")
245 | input_elements[0].send_keys(self._username)
246 | logging.info(f"input_elements username : {self._username}\r")
247 | input_elements[1].send_keys(self._password)
248 | logging.info(f"input_elements password : {self._password}\r")
249 |
250 | # click login button
251 | self._click_button(driver, By.CLASS_NAME, "el-button.el-button--primary")
252 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT*2)
253 | logging.info("Click login button.\r")
254 | # sometimes ddddOCR may fail, so add retry logic)
255 | for retry_times in range(1, self.RETRY_TIMES_LIMIT + 1):
256 |
257 | self._click_button(driver, By.XPATH, '//*[@id="login_box"]/div[1]/div[1]/div[2]/span')
258 | #get canvas image
259 | background_JS = 'return document.getElementById("slideVerify").childNodes[0].toDataURL("image/png");'
260 | # targe_JS = 'return document.getElementsByClassName("slide-verify-block")[0].toDataURL("image/png");'
261 | # get base64 image data
262 | im_info = driver.execute_script(background_JS)
263 | background = im_info.split(',')[1]
264 | background_image = base64_to_PLI(background)
265 | logging.info(f"Get electricity canvas image successfully.\r")
266 | distance = self.onnx.get_distance(background_image)
267 | logging.info(f"Image CaptCHA distance is {distance}.\r")
268 |
269 | self._sliding_track(driver, round(distance*1.06)) #1.06是补偿
270 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
271 | if (driver.current_url == LOGIN_URL): # if login not success
272 | try:
273 | logging.info(f"Sliding CAPTCHA recognition failed and reloaded.\r")
274 | self._click_button(driver, By.CLASS_NAME, "el-button.el-button--primary")
275 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT*2)
276 | continue
277 | except:
278 | logging.debug(
279 | f"Login failed, maybe caused by invalid captcha, {self.RETRY_TIMES_LIMIT - retry_times} retry times left.")
280 | else:
281 | return True
282 | logging.error(f"Login failed, maybe caused by Sliding CAPTCHA recognition failed")
283 | return False
284 |
285 | raise Exception(
286 | "Login failed, maybe caused by 1.incorrect phone_number and password, please double check. or 2. network, please mnodify LOGIN_EXPECTED_TIME in .env and run docker compose up --build.")
287 |
288 | def fetch(self):
289 |
290 | """main logic here"""
291 | if platform.system() == 'Windows':
292 | driverfile_path = r'C:\Users\mxwang\Project\msedgedriver.exe'
293 | driver = webdriver.Edge(executable_path=driverfile_path)
294 | else:
295 | driver = self._get_webdriver()
296 |
297 | driver.maximize_window()
298 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
299 | logging.info("Webdriver initialized.")
300 | updator = SensorUpdator()
301 |
302 | try:
303 | if os.getenv("DEBUG_MODE", "false").lower() == "true":
304 | if self._login(driver,phone_code=True):
305 | logging.info("login successed !")
306 | else:
307 | logging.info("login unsuccessed !")
308 | raise Exception("login unsuccessed")
309 | else:
310 | if self._login(driver):
311 | logging.info("login successed !")
312 | else:
313 | logging.info("login unsuccessed !")
314 | raise Exception("login unsuccessed")
315 | except Exception as e:
316 | logging.error(
317 | f"Webdriver quit abnormly, reason: {e}. {self.RETRY_TIMES_LIMIT} retry times left.")
318 | driver.quit()
319 | return
320 |
321 | logging.info(f"Login successfully on {LOGIN_URL}")
322 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
323 | logging.info(f"Try to get the userid list")
324 | user_id_list = self._get_user_ids(driver)
325 | logging.info(f"Here are a total of {len(user_id_list)} userids, which are {user_id_list} among which {self.IGNORE_USER_ID} will be ignored.")
326 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
327 |
328 |
329 | for userid_index, user_id in enumerate(user_id_list):
330 | try:
331 | # switch to electricity charge balance page
332 | driver.get(BALANCE_URL)
333 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
334 | self._choose_current_userid(driver,userid_index)
335 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
336 | current_userid = self._get_current_userid(driver)
337 | if current_userid in self.IGNORE_USER_ID:
338 | logging.info(f"The user ID {current_userid} will be ignored in user_id_list")
339 | continue
340 | else:
341 | ### get data
342 | balance, last_daily_date, last_daily_usage, yearly_charge, yearly_usage, month_charge, month_usage = self._get_all_data(driver, user_id, userid_index)
343 | updator.update_one_userid(user_id, balance, last_daily_date, last_daily_usage, yearly_charge, yearly_usage, month_charge, month_usage)
344 |
345 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
346 | except Exception as e:
347 | if (userid_index != len(user_id_list)):
348 | logging.info(f"The current user {user_id} data fetching failed {e}, the next user data will be fetched.")
349 | else:
350 | logging.info(f"The user {user_id} data fetching failed, {e}")
351 | logging.info("Webdriver quit after fetching data successfully.")
352 | continue
353 |
354 | driver.quit()
355 |
356 |
357 | def _get_current_userid(self, driver):
358 | current_userid = driver.find_element(By.XPATH, '//*[@id="app"]/div/div/article/div/div/div[2]/div/div/div[1]/div[2]/div/div/div/div[2]/div/div[1]/div/ul/div/li[1]/span[2]').text
359 | return current_userid
360 |
361 | def _choose_current_userid(self, driver, userid_index):
362 | elements = driver.find_elements(By.CLASS_NAME, "button_confirm")
363 | if elements:
364 | self._click_button(driver, By.XPATH, f'''//*[@id="app"]/div/div[2]/div/div/div/div[2]/div[2]/div/button''')
365 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
366 | self._click_button(driver, By.CLASS_NAME, "el-input__suffix")
367 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
368 | self._click_button(driver, By.XPATH, f"/html/body/div[2]/div[1]/div[1]/ul/li[{userid_index+1}]/span")
369 |
370 |
371 | def _get_all_data(self, driver, user_id, userid_index):
372 | balance = self._get_electric_balance(driver)
373 | if (balance is None):
374 | logging.info(f"Get electricity charge balance for {user_id} failed, Pass.")
375 | else:
376 | logging.info(
377 | f"Get electricity charge balance for {user_id} successfully, balance is {balance} CNY.")
378 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
379 | # swithc to electricity usage page
380 | driver.get(ELECTRIC_USAGE_URL)
381 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
382 | self._choose_current_userid(driver, userid_index)
383 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
384 | # get data for each user id
385 | yearly_usage, yearly_charge = self._get_yearly_data(driver)
386 |
387 | if yearly_usage is None:
388 | logging.error(f"Get year power usage for {user_id} failed, pass")
389 | else:
390 | logging.info(
391 | f"Get year power usage for {user_id} successfully, usage is {yearly_usage} kwh")
392 | if yearly_charge is None:
393 | logging.error(f"Get year power charge for {user_id} failed, pass")
394 | else:
395 | logging.info(
396 | f"Get year power charge for {user_id} successfully, yealrly charge is {yearly_charge} CNY")
397 |
398 | # 按月获取数据
399 | month, month_usage, month_charge = self._get_month_usage(driver)
400 | if month is None:
401 | logging.error(f"Get month power usage for {user_id} failed, pass")
402 | else:
403 | for m in range(len(month)):
404 | logging.info(f"Get month power charge for {user_id} successfully, {month[m]} usage is {month_usage[m]} KWh, charge is {month_charge[m]} CNY.")
405 | # get yesterday usage
406 | last_daily_date, last_daily_usage = self._get_yesterday_usage(driver)
407 | if last_daily_usage is None:
408 | logging.error(f"Get daily power consumption for {user_id} failed, pass")
409 | else:
410 | logging.info(
411 | f"Get daily power consumption for {user_id} successfully, , {last_daily_date} usage is {last_daily_usage} kwh.")
412 | if month is None:
413 | logging.error(f"Get month power usage for {user_id} failed, pass")
414 |
415 | # 新增储存用电量
416 | if self.enable_database_storage:
417 | # 将数据存储到数据库
418 | logging.info("enable_database_storage is true, we will store the data to the database.")
419 | # 按天获取数据 7天/30天
420 | date, usages = self._get_daily_usage_data(driver)
421 | self._save_user_data(user_id, balance, last_daily_date, last_daily_usage, date, usages, month, month_usage, month_charge, yearly_charge, yearly_usage)
422 | else:
423 | logging.info("enable_database_storage is false, we will not store the data to the database.")
424 |
425 |
426 | if month_charge:
427 | month_charge = month_charge[-1]
428 | else:
429 | month_charge = None
430 | if month_usage:
431 | month_usage = month_usage[-1]
432 | else:
433 | month_usage = None
434 |
435 | return balance, last_daily_date, last_daily_usage, yearly_charge, yearly_usage, month_charge, month_usage
436 |
437 | def _get_user_ids(self, driver):
438 | try:
439 | # 刷新网页
440 | driver.refresh()
441 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT*2)
442 | element = WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.presence_of_element_located((By.CLASS_NAME, 'el-dropdown')))
443 | # click roll down button for user id
444 | self._click_button(driver, By.XPATH, "//div[@class='el-dropdown']/span")
445 | logging.debug(f'''self._click_button(driver, By.XPATH, "//div[@class='el-dropdown']/span")''')
446 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
447 | # wait for roll down menu displayed
448 | target = driver.find_element(By.CLASS_NAME, "el-dropdown-menu.el-popper").find_element(By.TAG_NAME, "li")
449 | logging.debug(f'''target = driver.find_element(By.CLASS_NAME, "el-dropdown-menu.el-popper").find_element(By.TAG_NAME, "li")''')
450 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
451 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(target))
452 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
453 | logging.debug(f'''WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(target))''')
454 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(
455 | EC.text_to_be_present_in_element((By.XPATH, "//ul[@class='el-dropdown-menu el-popper']/li"), ":"))
456 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
457 |
458 | # get user id one by one
459 | userid_elements = driver.find_element(By.CLASS_NAME, "el-dropdown-menu.el-popper").find_elements(By.TAG_NAME, "li")
460 | userid_list = []
461 | for element in userid_elements:
462 | userid_list.append(re.findall("[0-9]+", element.text)[-1])
463 | return userid_list
464 | except Exception as e:
465 | logging.error(
466 | f"Webdriver quit abnormly, reason: {e}. get user_id list failed.")
467 | driver.quit()
468 |
469 | def _get_electric_balance(self, driver):
470 | try:
471 | balance = driver.find_element(By.CLASS_NAME, "num").text
472 | balance_text = driver.find_element(By.CLASS_NAME, "amttxt").text
473 | if "欠费" in balance_text :
474 | return -float(balance)
475 | else:
476 | return float(balance)
477 | except:
478 | return None
479 |
480 | def _get_yearly_data(self, driver):
481 |
482 | try:
483 | if datetime.now().month == 1:
484 | self._click_button(driver, By.XPATH, '//*[@id="pane-first"]/div[1]/div/div[1]/div/div/input')
485 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
486 | span_element = driver.find_element(By.XPATH, f"//span[contains(text(), '{datetime.now().year - 1}')]")
487 | span_element.click()
488 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
489 | self._click_button(driver, By.XPATH, "//div[@class='el-tabs__nav is-top']/div[@id='tab-first']")
490 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
491 | # wait for data displayed
492 | target = driver.find_element(By.CLASS_NAME, "total")
493 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(target))
494 | except Exception as e:
495 | logging.error(f"The yearly data get failed : {e}")
496 | return None, None
497 |
498 | # get data
499 | try:
500 | yearly_usage = driver.find_element(By.XPATH, "//ul[@class='total']/li[1]/span").text
501 | except Exception as e:
502 | logging.error(f"The yearly_usage data get failed : {e}")
503 | yearly_usage = None
504 |
505 | try:
506 | yearly_charge = driver.find_element(By.XPATH, "//ul[@class='total']/li[2]/span").text
507 | except Exception as e:
508 | logging.error(f"The yearly_charge data get failed : {e}")
509 | yearly_charge = None
510 |
511 | return yearly_usage, yearly_charge
512 |
513 | def _get_yesterday_usage(self, driver):
514 | """获取最近一次用电量"""
515 | try:
516 | # 点击日用电量
517 | self._click_button(driver, By.XPATH, "//div[@class='el-tabs__nav is-top']/div[@id='tab-second']")
518 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
519 | # wait for data displayed
520 | usage_element = driver.find_element(By.XPATH,
521 | "//div[@class='el-tab-pane dayd']//div[@class='el-table__body-wrapper is-scrolling-none']/table/tbody/tr[1]/td[2]/div")
522 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(usage_element)) # 等待用电量出现
523 |
524 | # 增加是哪一天
525 | date_element = driver.find_element(By.XPATH,
526 | "//div[@class='el-tab-pane dayd']//div[@class='el-table__body-wrapper is-scrolling-none']/table/tbody/tr[1]/td[1]/div")
527 | last_daily_date = date_element.text # 获取最近一次用电量的日期
528 | return last_daily_date, float(usage_element.text)
529 | except Exception as e:
530 | logging.error(f"The yesterday data get failed : {e}")
531 | return None
532 |
533 | def _get_month_usage(self, driver):
534 | """获取每月用电量"""
535 |
536 | try:
537 | self._click_button(driver, By.XPATH, "//div[@class='el-tabs__nav is-top']/div[@id='tab-first']")
538 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
539 | if datetime.now().month == 1:
540 | self._click_button(driver, By.XPATH, '//*[@id="pane-first"]/div[1]/div/div[1]/div/div/input')
541 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
542 | span_element = driver.find_element(By.XPATH, f"//span[contains(text(), '{datetime.now().year - 1}')]")
543 | span_element.click()
544 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
545 | # wait for month displayed
546 | target = driver.find_element(By.CLASS_NAME, "total")
547 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(target))
548 | month_element = driver.find_element(By.XPATH, "//*[@id='pane-first']/div[1]/div[2]/div[2]/div/div[3]/table/tbody").text
549 | month_element = month_element.split("\n")
550 | month_element.remove("MAX")
551 | month_element = np.array(month_element).reshape(-1, 3)
552 | # 将每月的用电量保存为List
553 | month = []
554 | usage = []
555 | charge = []
556 | for i in range(len(month_element)):
557 | month.append(month_element[i][0])
558 | usage.append(month_element[i][1])
559 | charge.append(month_element[i][2])
560 | return month, usage, charge
561 | except Exception as e:
562 | logging.error(f"The month data get failed : {e}")
563 | return None,None,None
564 |
565 | # 增加获取每日用电量的函数
566 | def _get_daily_usage_data(self, driver):
567 | """储存指定天数的用电量"""
568 | retention_days = int(os.getenv("DATA_RETENTION_DAYS", 7)) # 默认值为7天
569 | self._click_button(driver, By.XPATH, "//div[@class='el-tabs__nav is-top']/div[@id='tab-second']")
570 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
571 |
572 | # 7 天在第一个 label, 30 天 开通了智能缴费之后才会出现在第二个, (sb sgcc)
573 | if retention_days == 7:
574 | self._click_button(driver, By.XPATH, "//*[@id='pane-second']/div[1]/div/label[1]/span[1]")
575 | elif retention_days == 30:
576 | self._click_button(driver, By.XPATH, "//*[@id='pane-second']/div[1]/div/label[2]/span[1]")
577 | else:
578 | logging.error(f"Unsupported retention days value: {retention_days}")
579 | return
580 |
581 | time.sleep(self.RETRY_WAIT_TIME_OFFSET_UNIT)
582 |
583 | # 等待用电量的数据出现
584 | usage_element = driver.find_element(By.XPATH,
585 | "//div[@class='el-tab-pane dayd']//div[@class='el-table__body-wrapper is-scrolling-none']/table/tbody/tr[1]/td[2]/div")
586 | WebDriverWait(driver, self.DRIVER_IMPLICITY_WAIT_TIME).until(EC.visibility_of(usage_element))
587 |
588 | # 获取用电量的数据
589 | days_element = driver.find_elements(By.XPATH,
590 | "//*[@id='pane-second']/div[2]/div[2]/div[1]/div[3]/table/tbody/tr") # 用电量值列表
591 | date = []
592 | usages = []
593 | # 将用电量保存为字典
594 | for i in days_element:
595 | day = i.find_element(By.XPATH, "td[1]/div").text
596 | usage = i.find_element(By.XPATH, "td[2]/div").text
597 | if usage != "":
598 | usages.append(usage)
599 | date.append(day)
600 | else:
601 | logging.info(f"The electricity consumption of {usage} get nothing")
602 | return date, usages
603 |
604 | def _save_user_data(self, user_id, balance, last_daily_date, last_daily_usage, date, usages, month, month_usage, month_charge, yearly_charge, yearly_usage):
605 | # 连接数据库集合
606 | if self.connect_user_db(user_id):
607 | # 写入当前户号
608 | dic = {'name': 'user', 'value': f"{user_id}"}
609 | self.insert_expand_data(dic)
610 | # 写入剩余金额
611 | dic = {'name': 'balance', 'value': f"{balance}"}
612 | self.insert_expand_data(dic)
613 | # 写入最近一次更新时间
614 | dic = {'name': f"daily_date", 'value': f"{last_daily_date}"}
615 | self.insert_expand_data(dic)
616 | # 写入最近一次更新时间用电量
617 | dic = {'name': f"daily_usage", 'value': f"{last_daily_usage}"}
618 | self.insert_expand_data(dic)
619 |
620 | # 写入年用电量
621 | dic = {'name': 'yearly_usage', 'value': f"{yearly_usage}"}
622 | self.insert_expand_data(dic)
623 | # 写入年用电电费
624 | dic = {'name': 'yearly_charge', 'value': f"{yearly_charge} "}
625 | self.insert_expand_data(dic)
626 |
627 | for index in range(len(date)):
628 | dic = {'date': date[index], 'usage': float(usages[index])}
629 | # 插入到数据库
630 | try:
631 | self.insert_data(dic)
632 | logging.info(f"The electricity consumption of {usages[index]}KWh on {date[index]} has been successfully deposited into the database")
633 | except Exception as e:
634 | logging.debug(f"The electricity consumption of {date[index]} failed to save to the database, which may already exist: {str(e)}")
635 |
636 | for index in range(len(month)):
637 | try:
638 | dic = {'name': f"{month[index]}usage", 'value': f"{month_usage[index]}"}
639 | self.insert_expand_data(dic)
640 | dic = {'name': f"{month[index]}charge", 'value': f"{month_charge[index]}"}
641 | self.insert_expand_data(dic)
642 | except Exception as e:
643 | logging.debug(f"The electricity consumption of {month[index]} failed to save to the database, which may already exist: {str(e)}")
644 | if month_charge:
645 | month_charge = month_charge[-1]
646 | else:
647 | month_charge = None
648 |
649 | if month_usage:
650 | month_usage = month_usage[-1]
651 | else:
652 | month_usage = None
653 | # 写入本月电量
654 | dic = {'name': f"month_usage", 'value': f"{month_usage}"}
655 | self.insert_expand_data(dic)
656 | # 写入本月电费
657 | dic = {'name': f"month_charge", 'value': f"{month_charge}"}
658 | self.insert_expand_data(dic)
659 | # dic = {'date': month[index], 'usage': float(month_usage[index]), 'charge': float(month_charge[index])}
660 | self.connect.close()
661 | else:
662 | logging.info("The database creation failed and the data was not written correctly.")
663 | return
664 |
665 | if __name__ == "__main__":
666 | with open("bg.jpg", "rb") as f:
667 | test1 = f.read()
668 | print(type(test1))
669 | print(test1)
670 |
--------------------------------------------------------------------------------
/scripts/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import logging.config
3 | import os
4 | import sys
5 | import time
6 | import schedule
7 | import json
8 | from datetime import datetime,timedelta
9 | from const import *
10 | from data_fetcher import DataFetcher
11 |
12 |
13 | def main():
14 | global RETRY_TIMES_LIMIT
15 | if 'PYTHON_IN_DOCKER' not in os.environ:
16 | # 读取 .env 文件
17 | import dotenv
18 | dotenv.load_dotenv(verbose=True)
19 | if os.path.isfile('/data/options.json'):
20 | with open('/data/options.json') as f:
21 | options = json.load(f)
22 | try:
23 | PHONE_NUMBER = options.get("PHONE_NUMBER")
24 | PASSWORD = options.get("PASSWORD")
25 | HASS_URL = options.get("HASS_URL")
26 | JOB_START_TIME = options.get("JOB_START_TIME", "07:00")
27 | LOG_LEVEL = options.get("LOG_LEVEL", "INFO")
28 | VERSION = os.getenv("VERSION")
29 | RETRY_TIMES_LIMIT = int(options.get("RETRY_TIMES_LIMIT", 5))
30 |
31 | logger_init(LOG_LEVEL)
32 | os.environ["HASS_URL"] = options.get("HASS_URL", "http://homeassistant.local:8123/")
33 | os.environ["HASS_TOKEN"] = options.get("HASS_TOKEN", "")
34 | os.environ["ENABLE_DATABASE_STORAGE"] = str(options.get("ENABLE_DATABASE_STORAGE", "false")).lower()
35 | os.environ["IGNORE_USER_ID"] = options.get("IGNORE_USER_ID", "xxxxx,xxxxx")
36 | os.environ["DB_NAME"] = options.get("DB_NAME", "homeassistant.db")
37 | os.environ["RETRY_TIMES_LIMIT"] = str(options.get("RETRY_TIMES_LIMIT", 5))
38 | os.environ["DRIVER_IMPLICITY_WAIT_TIME"] = str(options.get("DRIVER_IMPLICITY_WAIT_TIME", 60))
39 | os.environ["LOGIN_EXPECTED_TIME"] = str(options.get("LOGIN_EXPECTED_TIME", 10))
40 | os.environ["RETRY_WAIT_TIME_OFFSET_UNIT"] = str(options.get("RETRY_WAIT_TIME_OFFSET_UNIT", 10))
41 | os.environ["DATA_RETENTION_DAYS"] = str(options.get("DATA_RETENTION_DAYS", 7))
42 | os.environ["RECHARGE_NOTIFY"] = str(options.get("RECHARGE_NOTIFY", "false")).lower()
43 | os.environ["BALANCE"] = str(options.get("BALANCE", 5.0))
44 | os.environ["PUSHPLUS_TOKEN"] = options.get("PUSHPLUS_TOKEN", "")
45 | logging.info(f"当前以Homeassistant Add-on 形式运行.")
46 | except Exception as e:
47 | logging.error(f"Failing to read the options.json file, the program will exit with an error message: {e}.")
48 | sys.exit()
49 | else:
50 | try:
51 | PHONE_NUMBER = os.getenv("PHONE_NUMBER")
52 | PASSWORD = os.getenv("PASSWORD")
53 | HASS_URL = os.getenv("HASS_URL")
54 | JOB_START_TIME = os.getenv("JOB_START_TIME","07:00" )
55 | LOG_LEVEL = os.getenv("LOG_LEVEL","INFO")
56 | VERSION = os.getenv("VERSION")
57 | RETRY_TIMES_LIMIT = int(os.getenv("RETRY_TIMES_LIMIT", 5))
58 |
59 | logger_init(LOG_LEVEL)
60 | logging.info(f"The current run runs as a docker image.")
61 | except Exception as e:
62 | logging.error(f"Failing to read the .env file, the program will exit with an error message: {e}.")
63 | sys.exit()
64 |
65 | logging.info(f"The current repository version is {VERSION}, and the repository address is https://github.com/ARC-MX/sgcc_electricity_new.git")
66 | current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
67 | logging.info(f"The current date is {current_datetime}.")
68 |
69 | fetcher = DataFetcher(PHONE_NUMBER, PASSWORD)
70 | logging.info(f"The current logged-in user name is {PHONE_NUMBER}, the homeassistant address is {HASS_URL}, and the program will be executed every day at {JOB_START_TIME}.")
71 |
72 | next_run_time = datetime.strptime(JOB_START_TIME, "%H:%M") + timedelta(hours=12)
73 | logging.info(f'Run job now! The next run will be at {JOB_START_TIME} and {next_run_time.strftime("%H:%M")} every day')
74 | schedule.every().day.at(JOB_START_TIME).do(run_task, fetcher)
75 | schedule.every().day.at(next_run_time.strftime("%H:%M")).do(run_task, fetcher)
76 | run_task(fetcher)
77 |
78 | while True:
79 | schedule.run_pending()
80 | time.sleep(1)
81 |
82 |
83 | def run_task(data_fetcher: DataFetcher):
84 | for retry_times in range(1, RETRY_TIMES_LIMIT + 1):
85 | try:
86 | data_fetcher.fetch()
87 | return
88 | except Exception as e:
89 | logging.error(f"state-refresh task failed, reason is [{e}], {RETRY_TIMES_LIMIT - retry_times} retry times left.")
90 | continue
91 |
92 | def logger_init(level: str):
93 | logger = logging.getLogger()
94 | logger.setLevel(level)
95 | logging.getLogger("urllib3").setLevel(logging.CRITICAL)
96 | format = logging.Formatter("%(asctime)s [%(levelname)-8s] ---- %(message)s", "%Y-%m-%d %H:%M:%S")
97 | sh = logging.StreamHandler(stream=sys.stdout)
98 | sh.setFormatter(format)
99 | logger.addHandler(sh)
100 |
101 |
102 | if __name__ == "__main__":
103 | main()
104 |
--------------------------------------------------------------------------------
/scripts/onnx.py:
--------------------------------------------------------------------------------
1 | # import cv2
2 | from PIL import ImageDraw,Image,ImageOps
3 | import numpy as np
4 | import onnxruntime
5 |
6 | anchors = [[(116,90),(156,198),(373,326)],[(30,61),(62,45),(59,119)],[(10,13),(16,30),(33,23)]]
7 | anchors_yolo_tiny = [[(81, 82), (135, 169), (344, 319)], [(10, 14), (23, 27), (37, 58)]]
8 | CLASSES=["target"]
9 |
10 |
11 |
12 | class ONNX:
13 | def __init__(self,onnx_file_name="captcha.onnx"):
14 | self.onnx_session = onnxruntime.InferenceSession(onnx_file_name)
15 |
16 | # sigmoid函数
17 | def sigmoid(self,x):
18 | s = 1 / (1 + np.exp(-1 * x))
19 | return s
20 |
21 |
22 | # 获取预测正确的类别,以及概率和索引;
23 | def get_result(self,class_scores):
24 | class_score = 0
25 | class_index = 0
26 | for i in range(len(class_scores)):
27 | if class_scores[i] > class_score:
28 | class_index += 1
29 | class_score = class_scores[i]
30 | return class_score, class_index
31 |
32 |
33 | def xywh2xyxy(self,x):
34 | # [x, y, w, h] to [x1, y1, x2, y2]
35 | y = np.copy(x)
36 | y[:, 0] = x[:, 0] - x[:, 2] / 2
37 | y[:, 1] = x[:, 1] - x[:, 3] / 2
38 | y[:, 2] = x[:, 0] + x[:, 2] / 2
39 | y[:, 3] = x[:, 1] + x[:, 3] / 2
40 | return y
41 |
42 | # dets: array [x,6] 6个值分别为x1,y1,x2,y2,score,class
43 | # thresh: 阈值
44 | def nms(self,dets, thresh):
45 | # dets:x1 y1 x2 y2 score class
46 | # x[:,n]就是取所有集合的第n个数据
47 | x1 = dets[:, 0]
48 | y1 = dets[:, 1]
49 | x2 = dets[:, 2]
50 | y2 = dets[:, 3]
51 | # -------------------------------------------------------
52 | # 计算框的面积
53 | # 置信度从大到小排序
54 | # -------------------------------------------------------
55 | areas = (y2 - y1 + 1) * (x2 - x1 + 1)
56 | scores = dets[:, 4]
57 | # print(scores)
58 | keep = []
59 | index = scores.argsort()[::-1] # np.argsort()对某维度从小到大排序
60 | # [::-1] 从最后一个元素到第一个元素复制一遍。倒序从而从大到小排序
61 |
62 | while index.size > 0:
63 | i = index[0]
64 | keep.append(i)
65 | # -------------------------------------------------------
66 | # 计算相交面积
67 | # 1.相交
68 | # 2.不相交
69 | # -------------------------------------------------------
70 | x11 = np.maximum(x1[i], x1[index[1:]])
71 | y11 = np.maximum(y1[i], y1[index[1:]])
72 | x22 = np.minimum(x2[i], x2[index[1:]])
73 | y22 = np.minimum(y2[i], y2[index[1:]])
74 |
75 | w = np.maximum(0, x22 - x11 + 1)
76 | h = np.maximum(0, y22 - y11 + 1)
77 |
78 | overlaps = w * h
79 | # -------------------------------------------------------
80 | # 计算该框与其它框的IOU,去除掉重复的框,即IOU值大的框
81 | # IOU小于thresh的框保留下来
82 | # -------------------------------------------------------
83 | ious = overlaps / (areas[i] + areas[index[1:]] - overlaps)
84 | idx = np.where(ious <= thresh)[0]
85 | index = index[idx + 1]
86 | return keep
87 |
88 |
89 | def draw(self,image, box_data):
90 | # -------------------------------------------------------
91 | # 取整,方便画框
92 | # -------------------------------------------------------
93 |
94 | boxes = box_data[..., :4].astype(np.int32) # x1 x2 y1 y2
95 | scores = box_data[..., 4]
96 | classes = box_data[..., 5].astype(np.int32)
97 | for box, score, cl in zip(boxes, scores, classes):
98 | top, left, right, bottom = box
99 | # print('class: {}, score: {}'.format(CLASSES[cl], score))
100 | # print('box coordinate left,top,right,down: [{}, {}, {}, {}]'.format(top, left, right, bottom))
101 | # image = cv2.rectangle(image, (top, left), (right, bottom), (0, 0, 255), 1)
102 | draw = ImageDraw.Draw(image)
103 | draw.rectangle([(top, left), (right, bottom)], outline ="red")
104 | # cv2.imwrite("result"+str(left)+".jpg",image)
105 | # font = ImageFont.truetype(font='PingFang.ttc', size=40)
106 | draw.text(xy=(top, left),text='{0} {1:.2f}'.format(CLASSES[cl], score), fill=(255, 0, 0))
107 |
108 | # image = cv2.putText(image, '{0} {1:.2f}'.format(CLASSES[cl], score),
109 | # (top, left),
110 | # cv2.FONT_HERSHEY_SIMPLEX,
111 | # 0.6, (0, 0, 255), 2)
112 | return image
113 |
114 | # 获取预测框
115 | def get_boxes(self, prediction, confidence_threshold=0.7, nms_threshold=0.6):
116 | # 过滤掉无用的框
117 | # -------------------------------------------------------
118 | # 删除为1的维度
119 | # 删除置信度小于conf_thres的BOX
120 | # -------------------------------------------------------
121 | # for i in range(len(prediction)):
122 | feature_map = np.squeeze(prediction)# 删除数组形状中单维度条目(shape中为1的维度)
123 | # […,4]:代表了取最里边一层的所有第4号元素,…代表了对:,:,:,等所有的的省略。此处生成:25200个第四号元素组成的数组
124 | conf = feature_map[..., 4] > confidence_threshold # 0 1 2 3 4 4是置信度,只要置信度 > conf_thres 的
125 | box = feature_map[conf == True] # 根据objectness score生成(n, 5+class_nm),只留下符合要求的框
126 |
127 | # -------------------------------------------------------
128 | # 通过argmax获取置信度最大的类别
129 | # -------------------------------------------------------
130 | cls_cinf = box[..., 5:] # 左闭右开(5 6 7 8),就只剩下了每个grid cell中各类别的概率
131 | cls = []
132 | for i in range(len(cls_cinf)):
133 | cls.append(int(np.argmax(cls_cinf[i]))) # 剩下的objecctness score比较大的grid cell,分别对应的预测类别列表
134 | all_cls = list(set(cls)) # 去重,找出图中都有哪些类别
135 | # set() 函数创建一个无序不重复元素集,可进行关系测试,删除重复数据,还可以计算交集、差集、并集等。
136 | # -------------------------------------------------------
137 | # 分别对每个类别进行过滤
138 | # 1.将第6列元素替换为类别下标
139 | # 2.xywh2xyxy 坐标转换
140 | # 3.经过非极大抑制后输出的BOX下标
141 | # 4.利用下标取出非极大抑制后的BOX
142 | # -------------------------------------------------------
143 | output = []
144 | for i in range(len(all_cls)):
145 | curr_cls = all_cls[i]
146 | curr_cls_box = []
147 | curr_out_box = []
148 |
149 | for j in range(len(cls)):
150 | if cls[j] == curr_cls:
151 | box[j][5] = curr_cls
152 | curr_cls_box.append(box[j][:6]) # 左闭右开,0 1 2 3 4 5
153 |
154 | curr_cls_box = np.array(curr_cls_box) # 0 1 2 3 4 5 分别是 x y w h score class
155 | curr_cls_box = self.xywh2xyxy(curr_cls_box) # 0 1 2 3 4 5 分别是 x1 y1 x2 y2 score class
156 | curr_out_box = self.nms(curr_cls_box, nms_threshold) # 获得nms后,剩下的类别在curr_cls_box中的下标
157 |
158 | for k in curr_out_box:
159 | output.append(curr_cls_box[k])
160 | output = np.array(output)
161 | return output
162 |
163 | def letterbox(self, img, new_shape=(640, 640), color=(114, 114, 114), auto=False, scaleFill=False, scaleup=True,
164 | stride=32):
165 | '''图片归一化'''
166 | # Resize and pad image while meeting stride-multiple constraints
167 | shape = img.shape[:2] # current shape [height, width]
168 | if isinstance(new_shape, int):
169 | new_shape = (new_shape, new_shape)
170 |
171 | # Scale ratio (new / old)
172 | r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
173 | if not scaleup: # only scale down, do not scale up (for better test mAP)
174 | r = min(r, 1.0)
175 |
176 | # Compute padding
177 | ratio = r, r # width, height ratios
178 |
179 | new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
180 | dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] # wh padding
181 |
182 | if auto: # minimum rectangle
183 | dw, dh = np.mod(dw, stride), np.mod(dh, stride) # wh padding
184 | elif scaleFill: # stretch
185 | dw, dh = 0.0, 0.0
186 | new_unpad = (new_shape[1], new_shape[0])
187 | ratio = new_shape[1] / shape[1], new_shape[0] / shape[0] # width, height ratios
188 |
189 | dw /= 2 # divide padding into 2 sides
190 | dh /= 2
191 |
192 | if shape[::-1] != new_unpad: # resize
193 | # img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
194 | img = img.resize(new_unpad)
195 | top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
196 | left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
197 |
198 | # img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) # add border
199 | img = ImageOps.expand(img, border=(left, top, right, bottom), fill=0)##left,top,right,bottom
200 | return img, ratio, (dw, dh)
201 |
202 | def _inference(self,image):
203 | # org_img = cv2.resize(image, [416, 416]) # resize后的原图 (640, 640, 3)
204 | org_img = image.resize((416,416))
205 | # img = cv2.cvtColor(org_img, cv2.COLOR_BGR2RGB).transpose(2, 0, 1)
206 | img = org_img.convert("RGB")
207 | img = np.array(img).transpose(2, 0, 1)
208 | img = img.astype(dtype=np.float32) # onnx模型的类型是type: float32[ , , , ]
209 | img /= 255.0
210 | img = np.expand_dims(img, axis=0) # [3, 640, 640]扩展为[1, 3, 640, 640]
211 |
212 | inputs = {self.onnx_session.get_inputs()[0].name: img}
213 | prediction = self.onnx_session.run(None, inputs)[0]
214 | return prediction, org_img
215 |
216 | def get_distance(self,image,draw=False):
217 | prediction, org_img = self._inference(image)
218 | boxes = self.get_boxes(prediction=prediction)
219 | if len(boxes) == 0:
220 | print('No gaps were detected.')
221 | return 0
222 | else:
223 | if draw:
224 | org_img = self.draw(org_img, boxes)
225 | # cv2.imshow('result', org_img)
226 | # cv2.imwrite('result.png', org_img)
227 | org_img.save('result.png')
228 | # cv2.waitKey(0)
229 | return int(boxes[..., :4].astype(np.int32)[0][0])
230 |
231 | if __name__ == "__main__":
232 | onnx = ONNX()
233 | img_path="../assets/background.png"
234 | # img = cv2.imread(img_path)
235 | img = Image.open(img_path)
236 | print(onnx.get_distance(img,True))
--------------------------------------------------------------------------------
/scripts/sensor_updator.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from datetime import datetime,timedelta
4 |
5 | import requests
6 | from sympy import true
7 |
8 | from const import *
9 |
10 |
11 | class SensorUpdator:
12 |
13 | def __init__(self):
14 | HASS_URL = os.getenv("HASS_URL")
15 | HASS_TOKEN = os.getenv("HASS_TOKEN")
16 | self.base_url = HASS_URL[:-1] if HASS_URL.endswith("/") else HASS_URL
17 | self.token = HASS_TOKEN
18 | self.RECHARGE_NOTIFY = os.getenv("RECHARGE_NOTIFY", "false").lower() == "true"
19 |
20 | def update_one_userid(self, user_id: str, balance: float, last_daily_date: str, last_daily_usage: float, yearly_charge: float, yearly_usage: float, month_charge: float, month_usage: float):
21 | postfix = f"_{user_id[-4:]}"
22 | if balance is not None:
23 | self.balance_notify(user_id, balance)
24 | self.update_balance(postfix, balance)
25 | if last_daily_usage is not None:
26 | self.update_last_daily_usage(postfix, last_daily_date, last_daily_usage)
27 | if yearly_usage is not None:
28 | self.update_yearly_data(postfix, yearly_usage, usage=True)
29 | if yearly_charge is not None:
30 | self.update_yearly_data(postfix, yearly_charge)
31 | if month_usage is not None:
32 | self.update_month_data(postfix, month_usage, usage=True)
33 | if month_charge is not None:
34 | self.update_month_data(postfix, month_charge)
35 |
36 | logging.info(f"User {user_id} state-refresh task run successfully!")
37 |
38 | def update_last_daily_usage(self, postfix: str, last_daily_date: str, sensorState: float):
39 | sensorName = DAILY_USAGE_SENSOR_NAME + postfix
40 | request_body = {
41 | "state": sensorState,
42 | "unique_id": sensorName,
43 | "attributes": {
44 | "last_reset": last_daily_date,
45 | "unit_of_measurement": "kWh",
46 | "icon": "mdi:lightning-bolt",
47 | "device_class": "energy",
48 | "state_class": "measurement",
49 | },
50 | }
51 |
52 | self.send_url(sensorName, request_body)
53 | logging.info(f"Homeassistant sensor {sensorName} state updated: {sensorState} kWh")
54 |
55 | def update_balance(self, postfix: str, sensorState: float):
56 | sensorName = BALANCE_SENSOR_NAME + postfix
57 | last_reset = datetime.now().strftime("%Y-%m-%d, %H:%M:%S")
58 | request_body = {
59 | "state": sensorState,
60 | "unique_id": sensorName,
61 | "attributes": {
62 | "last_reset": last_reset,
63 | "unit_of_measurement": "CNY",
64 | "icon": "mdi:cash",
65 | "device_class": "monetary",
66 | "state_class": "total",
67 | },
68 | }
69 |
70 | self.send_url(sensorName, request_body)
71 | logging.info(f"Homeassistant sensor {sensorName} state updated: {sensorState} CNY")
72 |
73 | def update_month_data(self, postfix: str, sensorState: float, usage=False):
74 | sensorName = (
75 | MONTH_USAGE_SENSOR_NAME + postfix
76 | if usage
77 | else MONTH_CHARGE_SENSOR_NAME + postfix
78 | )
79 | current_date = datetime.now()
80 | first_day_of_current_month = current_date.replace(day=1)
81 | last_day_of_previous_month = first_day_of_current_month - timedelta(days=1)
82 | last_reset = last_day_of_previous_month.strftime("%Y-%m")
83 | request_body = {
84 | "state": sensorState,
85 | "unique_id": sensorName,
86 | "attributes": {
87 | "last_reset": last_reset,
88 | "unit_of_measurement": "kWh" if usage else "CNY",
89 | "icon": "mdi:lightning-bolt" if usage else "mdi:cash",
90 | "device_class": "energy" if usage else "monetary",
91 | "state_class": "measurement",
92 | },
93 | }
94 |
95 | self.send_url(sensorName, request_body)
96 | logging.info(f"Homeassistant sensor {sensorName} state updated: {sensorState} {'kWh' if usage else 'CNY'}")
97 |
98 | def update_yearly_data(self, postfix: str, sensorState: float, usage=False):
99 | sensorName = (
100 | YEARLY_USAGE_SENSOR_NAME + postfix
101 | if usage
102 | else YEARLY_CHARGE_SENSOR_NAME + postfix
103 | )
104 | if datetime.now().month == 1:
105 | last_year = datetime.now().year -1
106 | last_reset = datetime.now().replace(year=last_year).strftime("%Y")
107 | else:
108 | last_reset = datetime.now().strftime("%Y")
109 | request_body = {
110 | "state": sensorState,
111 | "unique_id": sensorName,
112 | "attributes": {
113 | "last_reset": last_reset,
114 | "unit_of_measurement": "kWh" if usage else "CNY",
115 | "icon": "mdi:lightning-bolt" if usage else "mdi:cash",
116 | "device_class": "energy" if usage else "monetary",
117 | "state_class": "total_increasing",
118 | },
119 | }
120 | self.send_url(sensorName, request_body)
121 | logging.info(f"Homeassistant sensor {sensorName} state updated: {sensorState} {'kWh' if usage else 'CNY'}")
122 |
123 | def send_url(self, sensorName, request_body):
124 | headers = {
125 | "Content-Type": "application-json",
126 | "Authorization": "Bearer " + self.token,
127 | }
128 | url = self.base_url + API_PATH + sensorName # /api/states/
129 | try:
130 | response = requests.post(url, json=request_body, headers=headers)
131 | logging.debug(
132 | f"Homeassistant REST API invoke, POST on {url}. response[{response.status_code}]: {response.content}"
133 | )
134 | except Exception as e:
135 | logging.error(f"Homeassistant REST API invoke failed, reason is {e}")
136 |
137 | def balance_notify(self, user_id, balance):
138 |
139 | if self.RECHARGE_NOTIFY :
140 | BALANCE = float(os.getenv("BALANCE", 10.0))
141 | PUSHPLUS_TOKEN = os.getenv("PUSHPLUS_TOKEN").split(",")
142 | logging.info(f"Check the electricity bill balance. When the balance is less than {BALANCE} CNY, the notification will be sent = {self.RECHARGE_NOTIFY}")
143 | if balance < BALANCE :
144 | for token in PUSHPLUS_TOKEN:
145 | title = "电费余额不足提醒"
146 | content = (f"您用户号{user_id}的当前电费余额为:{balance}元,请及时充值。" )
147 | url = ("http://www.pushplus.plus/send?token="+ token+ "&title="+ title+ "&content="+ content)
148 | requests.get(url)
149 | logging.info(
150 | f"The current balance of user id {user_id} is {balance} CNY less than {BALANCE} CNY, notice has been sent, please pay attention to check and recharge."
151 | )
152 | else :
153 | logging.info(
154 | f"Check the electricity bill balance, the notification will be sent = {self.RECHARGE_NOTIFY}")
155 | return
156 |
157 |
--------------------------------------------------------------------------------