Compare commits
44 commits
Author | SHA1 | Date | |
---|---|---|---|
23c0a49a26 | |||
66cb355392 | |||
0d26752550 | |||
9a36e59d38 | |||
e14e5afa34 | |||
c682c172d8 | |||
dda687d25b | |||
659546a35b | |||
235f6b26df | |||
8c7bb58c6b | |||
950548a9c3 | |||
32fde540f8 | |||
f0e797f2d6 | |||
59ddf1a2c1 | |||
b521c25dd9 | |||
be80f12825 | |||
83db956dfd | |||
2469ebaed0 | |||
8bc6764988 | |||
414a761918 | |||
f960699d2d | |||
0f299386d9 | |||
945a1c5a5f | |||
c179cbc7c8 | |||
29f60a58f1 | |||
d74b2ec66b | |||
b8057275a8 | |||
808ca9b9e5 | |||
2bf9b909cb | |||
c1c60fd7ed | |||
a5701f88b6 | |||
65818066e0 | |||
19a4a9d029 | |||
28322e5897 | |||
dfd9e39637 | |||
1d6c27a9e8 | |||
886b8a9114 | |||
68a6f152de | |||
deb494a57e | |||
77fb254781 | |||
13e987cf02 | |||
fa06afacab | |||
e3130abc13 | |||
d4556042a5 |
18 changed files with 2664 additions and 98 deletions
27
.drone.yml
Normal file
27
.drone.yml
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
kind: pipeline
|
||||||
|
name: default
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: fetch
|
||||||
|
image: alpine/git
|
||||||
|
failure: ignore
|
||||||
|
commands:
|
||||||
|
- git fetch --tags
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
image: python
|
||||||
|
commands:
|
||||||
|
- pip3 install -r requirements.txt
|
||||||
|
- python3 setup.py bdist
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: python
|
||||||
|
commands:
|
||||||
|
- pip3 install -r requirements.txt
|
||||||
|
- pip3 install pytest flake8
|
||||||
|
- pytest vedirect
|
||||||
|
- flake8 vedirect
|
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.eggs
|
||||||
|
.mypy*
|
||||||
|
.vscode
|
||||||
|
build
|
||||||
|
dis
|
||||||
|
*.pyc
|
||||||
|
*.egg-info
|
||||||
|
*.tar.*
|
28
CONTRIBUTING.md
Normal file
28
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# How to Contribute
|
||||||
|
|
||||||
|
We'd love to accept your patches and contributions to this project. There are
|
||||||
|
just a few small guidelines you need to follow.
|
||||||
|
|
||||||
|
## Contributor License Agreement
|
||||||
|
|
||||||
|
Contributions to this project must be accompanied by a Contributor License
|
||||||
|
Agreement. You (or your employer) retain the copyright to your contribution;
|
||||||
|
this simply gives us permission to use and redistribute your contributions as
|
||||||
|
part of the project. Head over to <https://cla.developers.google.com/> to see
|
||||||
|
your current agreements on file or to sign a new one.
|
||||||
|
|
||||||
|
You generally only need to submit a CLA once, so if you've already submitted one
|
||||||
|
(even if it was for a different project), you probably don't need to do it
|
||||||
|
again.
|
||||||
|
|
||||||
|
## Code reviews
|
||||||
|
|
||||||
|
All submissions, including submissions by project members, require review. We
|
||||||
|
use GitHub pull requests for this purpose. Consult
|
||||||
|
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
|
||||||
|
information on using pull requests.
|
||||||
|
|
||||||
|
## Community Guidelines
|
||||||
|
|
||||||
|
This project follows [Google's Open Source Community
|
||||||
|
Guidelines](https://opensource.google/conduct/).
|
202
LICENSE
Normal file
202
LICENSE
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
71
README.md
Normal file
71
README.md
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# vedirect - export Victron metrics
|
||||||
|
|
||||||
|
This tool parses the Victron [VE.Direct][ved] TEXT protocol and
|
||||||
|
exports the metrics over [Prometheus][prom] and [MQTT][mqtt]. This can
|
||||||
|
be used to monitor your solar installation and view statistics in
|
||||||
|
tools like [Grafana](https://grafana.com/) or [Home Assistant][hass].
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 setup.py install
|
||||||
|
|
||||||
|
vedirect --port=/dev/ttyAMA4 \
|
||||||
|
--prometheus_port=7099 \
|
||||||
|
--mqtt_host=localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
This will connect to the Victron module on ttyAMA4, export the metrics
|
||||||
|
at http://localhost:7099/metrics, and push the metrics to the MQTT
|
||||||
|
server at `localhost:1889`.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This tool has been tested with a Victron BlueSolar 75/15 running
|
||||||
|
firmware 1.56 with protocol v3.29. See `vedirect/defs.py` to
|
||||||
|
enable new types.
|
||||||
|
|
||||||
|
## Prometheus
|
||||||
|
|
||||||
|
Each field appears as a separate Prometheus metric. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
wget -nv -O - http://localhost:7099/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
gives
|
||||||
|
|
||||||
|
```
|
||||||
|
victron_fw_info{fw="1.53",product_id="0xA042",serial_number="HQ1123I8XGA"} 1.0
|
||||||
|
victron_v_volt{product_id="0xA042",serial_number="HQ1123I8XGA"} 12.235
|
||||||
|
victron_i_ampere{product_id="0xA042",serial_number="HQ1123I8XGA"} -0.401
|
||||||
|
victron_cs{product_id="0xA042",serial_number="HQ1123I8XGA",victron_cs="off"} 1.0
|
||||||
|
victron_cs{product_id="0xA042",serial_number="HQ1123I8XGA",victron_cs="low_power"} 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Gauges are put through a first order filter before exporting. This increases the apparent resolution of low resolution signals like the load current.
|
||||||
|
|
||||||
|
## MQTT
|
||||||
|
|
||||||
|
Each field appears as a separate, single valued MQTT topic. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
tele/victron_HQ1123I8XGA/pid 0xA042
|
||||||
|
tele/victron_HQ1123I8XGA/fw 1.53
|
||||||
|
tele/victron_HQ1123I8XGA/v 12.24
|
||||||
|
tele/victron_HQ1123I8XGA/i -0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
This tool exports MQTT discovery records and should be automatically
|
||||||
|
detected by Home Assistant.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
This is not an official Google product.
|
||||||
|
|
||||||
|
\-- Michael Hope <michaelh@juju.nz> <mlhx@google.com>
|
||||||
|
|
||||||
|
[hass]: https://www.home-assistant.io/
|
||||||
|
[mqtt]: https://mqtt.org/
|
||||||
|
[prom]: https://prometheus.io/
|
||||||
|
[ved]: https://www.victronenergy.com/live/vedirect_protocol:faq
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
Pint>=0.16.1
|
||||||
|
click>=7.1.2
|
||||||
|
paho-mqtt>=1.5.1
|
||||||
|
prometheus-client>=0.8.0
|
||||||
|
pyserial>=3.4
|
||||||
|
git+https://juju.nz/src/michaelh/janet.git#egg=janet
|
||||||
|
systemd-python>=234
|
31
setup.py
Normal file
31
setup.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='vedirect',
|
||||||
|
version_format='{tag}.dev{commitcount}+{gitsha}',
|
||||||
|
setup_requires=['setuptools-git-version'],
|
||||||
|
url='https://juju.nz/src/michaelh/vedirect',
|
||||||
|
author='Michael Hope',
|
||||||
|
author_email='michaelh@juju.nz',
|
||||||
|
description='VE.Direct parser and exporter',
|
||||||
|
zip_safe=True,
|
||||||
|
packages=find_packages(),
|
||||||
|
entry_points='''
|
||||||
|
[console_scripts]
|
||||||
|
vedirect=vedirect.cli:app
|
||||||
|
''',
|
||||||
|
)
|
266
vedirect/cli.py
Normal file
266
vedirect/cli.py
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import pprint
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import metadata
|
||||||
|
from typing import Any, List, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
import janet
|
||||||
|
import janet.mqtt
|
||||||
|
import janet.prometheus
|
||||||
|
import prometheus_client
|
||||||
|
import serial
|
||||||
|
import systemd.daemon
|
||||||
|
|
||||||
|
from . import hex, phoenix
|
||||||
|
from .hex import Register
|
||||||
|
from . import mppt
|
||||||
|
|
||||||
|
logger = logging.getLogger('vedirect.cli')
|
||||||
|
|
||||||
|
|
||||||
|
class Echo(janet.Publisher):
|
||||||
|
def publish(self, device: janet.Device, entity: Any, unused_setter):
|
||||||
|
pprint.pprint(dataclasses.asdict(entity))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Frame:
|
||||||
|
received: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Get:
|
||||||
|
register: int
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Done:
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _issubclass(typ, cls) -> bool:
|
||||||
|
try:
|
||||||
|
return issubclass(typ, cls)
|
||||||
|
except TypeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Poller:
|
||||||
|
def __init__(self, port: serial.Serial):
|
||||||
|
self._port = port
|
||||||
|
|
||||||
|
self._rx: 'queue.Queue[Union[Get,Done]]' = queue.Queue(100)
|
||||||
|
self._notify = threading.Condition()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._received = 0
|
||||||
|
|
||||||
|
def _discover(self):
|
||||||
|
while True:
|
||||||
|
with self._lock:
|
||||||
|
hex.get_product_id(self._port)
|
||||||
|
try:
|
||||||
|
resp = self._rx.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(resp, Done):
|
||||||
|
continue
|
||||||
|
pid = struct.unpack('<H', resp.data)[0]
|
||||||
|
|
||||||
|
for product in phoenix.PRODUCT_IDS:
|
||||||
|
if pid == product.id:
|
||||||
|
logger.info(f'Discovered a {product.name}')
|
||||||
|
return phoenix.GROUPS
|
||||||
|
|
||||||
|
if pid in mppt.PRODUCT_IDS:
|
||||||
|
logger.info('Discovered a %s' % mppt.PRODUCT_IDS[pid])
|
||||||
|
return mppt.GROUPS
|
||||||
|
|
||||||
|
raise RuntimeError(f'Unrecognised product ID {pid:X}')
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
threading.Thread(target=self._getter).start()
|
||||||
|
|
||||||
|
groups = self._discover()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
for group in groups:
|
||||||
|
values = {}
|
||||||
|
for field in dataclasses.fields(group):
|
||||||
|
register = field.metadata['vedirect.Register']
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
hex.get(self._port, register.id)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
resp = self._rx.get(timeout=1)
|
||||||
|
if not isinstance(resp, Get):
|
||||||
|
continue
|
||||||
|
if resp.register == register.id:
|
||||||
|
values[field.name] = hex.unpack(
|
||||||
|
register, resp.data)
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
logger.warning(
|
||||||
|
f'No response on {field.name}, skipping')
|
||||||
|
values[field.name] = None
|
||||||
|
break
|
||||||
|
yield group(**values)
|
||||||
|
|
||||||
|
yield Frame(self._received)
|
||||||
|
|
||||||
|
with self._notify:
|
||||||
|
self._notify.wait(60)
|
||||||
|
|
||||||
|
def set(self, field: dataclasses.Field, value: bytes) -> None:
|
||||||
|
value = value.decode('LATIN-1')
|
||||||
|
|
||||||
|
if _issubclass(field.type, enum.IntEnum):
|
||||||
|
try:
|
||||||
|
v = field.type[value.upper()]
|
||||||
|
except (KeyError, ValueError) as ex:
|
||||||
|
names = ' '.join(x.name.lower() for x in field.type)
|
||||||
|
logger.error(
|
||||||
|
f'Invalid enum value {value}, should be one of {names}',
|
||||||
|
exc_info=ex)
|
||||||
|
return
|
||||||
|
elif field.type in (float, int):
|
||||||
|
try:
|
||||||
|
v = float(value)
|
||||||
|
except ValueError as ex:
|
||||||
|
logger.warning(
|
||||||
|
(f'Value {value!r} for {field.name} is not a number, '
|
||||||
|
'dropping'),
|
||||||
|
exc_info=ex)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
logger.warning(f'Unsupported field type {field.type} '
|
||||||
|
f'for {field.name}, dropping')
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'vedirect.Register' not in field.metadata:
|
||||||
|
logger.error(
|
||||||
|
f'Field {field.name} is not a MPPT register, dropping')
|
||||||
|
return
|
||||||
|
|
||||||
|
register: Register = field.metadata['vedirect.Register']
|
||||||
|
|
||||||
|
if register.scale is not None:
|
||||||
|
v /= register.scale
|
||||||
|
|
||||||
|
v = int(round(v))
|
||||||
|
logger.warning(f'set {field.name}={v}')
|
||||||
|
payload = struct.pack('<' + register.kind, v)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
hex.set(self._port, register.id, payload)
|
||||||
|
# TODO(michaelh): hack to give the set time to propagate.
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
with self._notify:
|
||||||
|
self._notify.notify()
|
||||||
|
|
||||||
|
def _getter(self):
|
||||||
|
for frame in hex.decoder(self._port):
|
||||||
|
self._received += 1
|
||||||
|
if frame.command == hex.Response.GET:
|
||||||
|
self._get(frame)
|
||||||
|
elif frame.command == hex.Response.DONE:
|
||||||
|
self._done(frame)
|
||||||
|
elif frame.command == hex.Response.ASYNC:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
logger.warning(f'Dropped {frame}')
|
||||||
|
|
||||||
|
def _get(self, frame):
|
||||||
|
register, flags = struct.unpack_from('<HB', frame.data, 0)
|
||||||
|
flags = hex.Flags(flags)
|
||||||
|
value = frame.data[3:]
|
||||||
|
if flags != hex.Flags.OK:
|
||||||
|
logger.warning(f'GET on {register:X} gave error {flags!r}')
|
||||||
|
return
|
||||||
|
self._rx.put(Get(register, value))
|
||||||
|
|
||||||
|
def _done(self, frame):
|
||||||
|
self._rx.put(Done(frame.data))
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option('--port',
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help='Serial port connected to the controller')
|
||||||
|
@click.option('--prometheus-port',
|
||||||
|
type=int,
|
||||||
|
help='If supplied, export metrics on this port')
|
||||||
|
@click.option('--mqtt-host',
|
||||||
|
help='If supplied, export metrics to this MQTT host')
|
||||||
|
@click.option('--echo',
|
||||||
|
is_flag=True,
|
||||||
|
help='If supplied, echo metrics to stdout')
|
||||||
|
def app(port: str, prometheus_port: int, mqtt_host: str, echo: bool):
|
||||||
|
logging.basicConfig(format='%(name)-12s %(levelname)-8s %(message)s',
|
||||||
|
level=logging.INFO)
|
||||||
|
|
||||||
|
s = serial.serial_for_url(port, baudrate=19200, timeout=0.7)
|
||||||
|
publishers: List[janet.Publisher] = []
|
||||||
|
|
||||||
|
if prometheus_port:
|
||||||
|
prometheus_client.start_http_server(prometheus_port)
|
||||||
|
publishers.append(janet.prometheus.Client())
|
||||||
|
info = prometheus_client.Gauge('vedirect_info', 'Tool information',
|
||||||
|
('version', ))
|
||||||
|
info.labels(metadata.version('vedirect')).set(1)
|
||||||
|
|
||||||
|
if mqtt_host:
|
||||||
|
publishers.append(janet.mqtt.Client(mqtt_host))
|
||||||
|
|
||||||
|
if echo:
|
||||||
|
publishers.append(Echo())
|
||||||
|
|
||||||
|
device = None
|
||||||
|
poller = Poller(s)
|
||||||
|
for g in poller.poll():
|
||||||
|
if isinstance(g, phoenix.Information) or isinstance(
|
||||||
|
g, mppt.Information):
|
||||||
|
if not g.serial_number:
|
||||||
|
continue
|
||||||
|
|
||||||
|
device = janet.Device(identifiers=[g.serial_number],
|
||||||
|
available=True,
|
||||||
|
model=g.model_name,
|
||||||
|
name=f'VE.Direct {g.serial_number}',
|
||||||
|
manufacturer='Vicron',
|
||||||
|
kind='vedirect')
|
||||||
|
systemd.daemon.notify('WATCHDOG=1')
|
||||||
|
|
||||||
|
if device is not None:
|
||||||
|
for publisher in publishers:
|
||||||
|
publisher.publish(device, g, poller.set)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app()
|
|
@ -1,3 +1,18 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Protocol definitions including fields, enums, and types."""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
@ -83,7 +98,9 @@ class Err(enum.IntEnum):
|
||||||
|
|
||||||
|
|
||||||
class Load(enum.IntEnum):
|
class Load(enum.IntEnum):
|
||||||
"""Load is the state of the output to the load. Sent in the `LOAD` field."""
|
"""Load is the state of the output to the load.
|
||||||
|
|
||||||
|
Sent in the `LOAD` field."""
|
||||||
# Off
|
# Off
|
||||||
OFF = 0
|
OFF = 0
|
||||||
# On
|
# On
|
||||||
|
@ -124,7 +141,7 @@ VM = Field('VM', 'mV', 'Mid-point voltage of the battery bank')
|
||||||
DM = Field('DM', '%', 'Mid-point deviation of the battery bank')
|
DM = Field('DM', '%', 'Mid-point deviation of the battery bank')
|
||||||
VPV = Field('VPV', 'mV', 'Panel voltage')
|
VPV = Field('VPV', 'mV', 'Panel voltage')
|
||||||
PPV = Field('PPV', 'W', 'Panel power')
|
PPV = Field('PPV', 'W', 'Panel power')
|
||||||
I = Field('I', 'mA', 'Main or channel 1 battery current')
|
I = Field('I', 'mA', 'Main or channel 1 battery current') # noqa: E741
|
||||||
I2 = Field('I2', 'mA', 'Channel 2 battery current')
|
I2 = Field('I2', 'mA', 'Channel 2 battery current')
|
||||||
I3 = Field('I3', 'mA', 'Channel 3 battery current')
|
I3 = Field('I3', 'mA', 'Channel 3 battery current')
|
||||||
IL = Field('IL', 'mA', 'Load current')
|
IL = Field('IL', 'mA', 'Load current')
|
||||||
|
|
259
vedirect/hex.py
Normal file
259
vedirect/hex.py
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Iterator, List, Sequence, Union, Type, Optional
|
||||||
|
|
||||||
|
import pint
|
||||||
|
|
||||||
|
logger = logging.getLogger('vedirect.hex')
|
||||||
|
|
||||||
|
_CR = ord('\n')
|
||||||
|
_COLON = ord(':')
|
||||||
|
|
||||||
|
_NIBBLES = {ord('0') + x: x for x in range(0, 10)}
|
||||||
|
_NIBBLES.update({ord('A') + x: 10 + x for x in range(0, 6)})
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(enum.IntEnum):
|
||||||
|
UNSET = 0
|
||||||
|
R = 1
|
||||||
|
W = 2
|
||||||
|
RW = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Register:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
kind: str = ''
|
||||||
|
mode: Optional[Mode] = None
|
||||||
|
scale: Optional[float] = None
|
||||||
|
units: Union[str, Type[bool], Type[enum.Enum], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def Field(reg: Register) -> dataclasses.Field:
|
||||||
|
return dataclasses.field(default=dataclasses.MISSING,
|
||||||
|
metadata={'vedirect.Register': reg})
|
||||||
|
|
||||||
|
|
||||||
|
class Command(enum.IntEnum):
|
||||||
|
# 0x51FA51FA51FA51FA51FA as payload will enable bootloader mode.
|
||||||
|
ENTER_BOOT = 0
|
||||||
|
# Check for presence, the response is an ‘Rsp ping’ containing version and
|
||||||
|
# firmware type.
|
||||||
|
PING = 1
|
||||||
|
# Returns the version of the firmware as stored in the header in an ‘Rsp
|
||||||
|
# Done’ message.
|
||||||
|
VERSION = 3
|
||||||
|
# Returns the Product Id of the firmware as stored in the header in an
|
||||||
|
# ‘Rsp,
|
||||||
|
PRODUCT_ID = 4
|
||||||
|
# Restarts the device, no response is sent.
|
||||||
|
RESTART = 6
|
||||||
|
# Returns a get response with the requested data or error is returned.
|
||||||
|
# Arguments are ID of the value to get (uint16) and flags (uint8, must be
|
||||||
|
# zero).
|
||||||
|
GET = 7
|
||||||
|
# Sets a value. Arguments are the value ID (uint16), flags (uint8, must be
|
||||||
|
# zero), and a type specific value.
|
||||||
|
SET = 8
|
||||||
|
# Asynchronous data message. Should not be replied. Arguments are flags
|
||||||
|
# (uint8) and a type specific value.
|
||||||
|
ASYNC = 0xA
|
||||||
|
|
||||||
|
|
||||||
|
class Response(enum.IntEnum):
|
||||||
|
DONE = 1
|
||||||
|
UNKNOWN = 3
|
||||||
|
ERROR = 4
|
||||||
|
PING = 5
|
||||||
|
GET = 7
|
||||||
|
SET = 8
|
||||||
|
ASYNC = 0xA
|
||||||
|
|
||||||
|
|
||||||
|
class Flags(enum.IntFlag):
|
||||||
|
OK = 0
|
||||||
|
UNKNOWN_ID = 1
|
||||||
|
NOT_SUPPORTED = 2
|
||||||
|
PARAMETER_ERROR = 4
|
||||||
|
|
||||||
|
|
||||||
|
class Port(abc.ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def in_waiting(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def read(self, want: int) -> bytes:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _reader(src: Port) -> Iterator[int]:
|
||||||
|
"""Generates bytes from a serial port."""
|
||||||
|
while True:
|
||||||
|
ready = max(1, src.in_waiting)
|
||||||
|
yield from src.read(ready)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_line(src: Port) -> Iterator[Sequence[int]]:
|
||||||
|
"""Read a line from a port with None on idle."""
|
||||||
|
r = _reader(src)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
while True:
|
||||||
|
ch = next(r)
|
||||||
|
if ch == _COLON:
|
||||||
|
break
|
||||||
|
|
||||||
|
line: List[int] = []
|
||||||
|
while True:
|
||||||
|
ch = next(r)
|
||||||
|
if ch == _CR:
|
||||||
|
yield line
|
||||||
|
break
|
||||||
|
elif ch == _COLON:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
nibble = _NIBBLES.get(ch, None)
|
||||||
|
if nibble is None:
|
||||||
|
logger.warning(
|
||||||
|
f'Invalid character {ch!r} in {line!r}, dropping')
|
||||||
|
break
|
||||||
|
|
||||||
|
line.append(nibble)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Frame:
|
||||||
|
command: int
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
def decoder(src: Port) -> Iterator[Frame]:
|
||||||
|
"""Reads frames from the port yielding None on idle."""
|
||||||
|
for line in _get_line(src):
|
||||||
|
if len(line) <= 3:
|
||||||
|
logger.warning(f'Line is too short {line!r}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(line) % 2 != 1:
|
||||||
|
logger.warning(
|
||||||
|
f'Line should be an odd number of characters: {line!r}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
command = Response(line[0])
|
||||||
|
data = bytes(
|
||||||
|
(line[x] << 4) + line[x + 1] for x in range(1, len(line), 2))
|
||||||
|
chk = (command + sum(data)) & 0xFF
|
||||||
|
if chk != 0x55:
|
||||||
|
logger.error(f'Checksum error sum={chk:x}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = data[:-1]
|
||||||
|
yield Frame(command, data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_uint32(data, offset: int) -> int:
|
||||||
|
return get_uint16(data, offset) | (get_uint16(data, offset + 2) << 16)
|
||||||
|
|
||||||
|
|
||||||
|
def get_uint16(data, offset: int) -> int:
|
||||||
|
return (data[offset + 1] << 8) | data[offset + 0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_uint8(data, offset: int) -> int:
|
||||||
|
return data[offset]
|
||||||
|
|
||||||
|
|
||||||
|
class Packer:
|
||||||
|
def __init__(self):
|
||||||
|
self.payload = []
|
||||||
|
|
||||||
|
def add(self, ch: Union[int, bytes]) -> Packer:
|
||||||
|
if isinstance(ch, bytes):
|
||||||
|
self.payload.extend(ch)
|
||||||
|
else:
|
||||||
|
self.payload.append(ch)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_uint16(self, v: int) -> Packer:
|
||||||
|
return self.add(v & 0xFF).add(v >> 8)
|
||||||
|
|
||||||
|
def add_uint32(self, v: int) -> Packer:
|
||||||
|
return self.add_uint16(v & 0xFFFF).add_uint16(v >> 16)
|
||||||
|
|
||||||
|
def sum(self) -> int:
|
||||||
|
return sum(self.payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _issubclass(c, klass):
|
||||||
|
try:
|
||||||
|
return issubclass(c, klass)
|
||||||
|
except TypeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def unpack(register, value: bytes) -> Union[str, float, int]:
|
||||||
|
"""Unpacks a register value."""
|
||||||
|
if register.kind == 'S':
|
||||||
|
data = value.decode('LATIN-1')
|
||||||
|
# Might be null-terminated.
|
||||||
|
return data.split('\0')[0]
|
||||||
|
|
||||||
|
data = struct.unpack_from('<' + register.kind, value, 0)[0]
|
||||||
|
|
||||||
|
if register.scale is not None:
|
||||||
|
data *= register.scale
|
||||||
|
data = round(data, 6)
|
||||||
|
|
||||||
|
if register.units is not None:
|
||||||
|
if _issubclass(register.units, enum.Enum):
|
||||||
|
data = register.units(int(data))
|
||||||
|
elif register.units in (bool, ):
|
||||||
|
data = register.units(data)
|
||||||
|
elif isinstance(register.units, pint.Unit):
|
||||||
|
data = pint.Quantity(data, register.units)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _send(port: Port, packer: Packer):
|
||||||
|
packer.add((0x55 - packer.sum()) & 0xFF)
|
||||||
|
payload = packer.payload
|
||||||
|
out = f':{payload[0]:X}' + ''.join('%02X' % x for x in payload[1:])
|
||||||
|
port.write(out.encode('LATIN-1') + b'\n')
|
||||||
|
|
||||||
|
|
||||||
|
def set(port: Port, register: int, value: bytes):
|
||||||
|
packer = Packer().add(
|
||||||
|
Command.SET.value).add_uint16(register).add(0).add(value)
|
||||||
|
_send(port, packer)
|
||||||
|
|
||||||
|
|
||||||
|
def get(port: Port, register: int) -> None:
|
||||||
|
packer = Packer().add(Command.GET.value).add_uint16(register).add(0)
|
||||||
|
_send(port, packer)
|
||||||
|
|
||||||
|
|
||||||
|
def get_version(port: Port) -> None:
|
||||||
|
packer = Packer().add(Command.VERSION.value)
|
||||||
|
_send(port, packer)
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_id(port: Port) -> None:
|
||||||
|
packer = Packer().add(Command.PRODUCT_ID.value)
|
||||||
|
_send(port, packer)
|
694
vedirect/mppt.py
Normal file
694
vedirect/mppt.py
Normal file
|
@ -0,0 +1,694 @@
|
||||||
|
"""MPPT HEX Protocol specification."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from . import schema
|
||||||
|
from .hex import Register, Field
|
||||||
|
|
||||||
|
import pint
|
||||||
|
|
||||||
|
_ureg = pint.get_application_registry()
|
||||||
|
|
||||||
|
|
||||||
|
class ID(enum.IntEnum):
|
||||||
|
# Product information registers
|
||||||
|
# 0x0100,Product Id,-,un32,-
|
||||||
|
PRODUCT_ID = 0x0100
|
||||||
|
# 0x0104,Group Id,-,un8,-
|
||||||
|
GROUP_ID = 0x0104
|
||||||
|
# 0x010A,Serial number,-,string,-
|
||||||
|
SERIAL_NUMBER = 0x010A
|
||||||
|
# 0x010B,Model name,-,string,-
|
||||||
|
MODEL_NAME = 0x010B
|
||||||
|
# 0x0140,Capabilities,-,un32,-
|
||||||
|
CAPABILITIES = 0x0140
|
||||||
|
|
||||||
|
# Generic device control registers
|
||||||
|
# 0x0200,Device mode,-,un8,-
|
||||||
|
DEVICE_MODE = 0x0200
|
||||||
|
# 0x0201,Device state,-,un8,-
|
||||||
|
DEVICE_STATE = 0x0201
|
||||||
|
# 0x0202,Remote control used,-,un32,-
|
||||||
|
REMOTE_CONTROL_USED = 0x0202
|
||||||
|
# 0x0205,Device off reason,-,un8,-
|
||||||
|
DEVICE_OFF_REASON = 0x0205
|
||||||
|
# 0x0207,Device off reason,-,un32,-
|
||||||
|
DEVICE_OFF_REASON_EXTENDED = 0x0207
|
||||||
|
|
||||||
|
# Battery settings registers
|
||||||
|
# 0xEDFF,Batterysafe mode,-,un8,0=off, 1=on
|
||||||
|
BATTERYSAFE_MODE = 0xEDFF
|
||||||
|
# 0xEDFE,Adaptive mode,-,un8,0=off, 1=on
|
||||||
|
ADAPTIVE_MODE = 0xEDFE
|
||||||
|
# 0xEDFD,Automatic equalisation mode,-,un8,0=off, 1..250
|
||||||
|
AUTOMATIC_EQUALISATION_MODE = 0xEDFD
|
||||||
|
# 0xEDFC,Battery bulk time limit,0.01,un16,hours
|
||||||
|
BATTERY_BULK_TIME_LIMIT = 0xEDFC
|
||||||
|
# 0xEDFB,Battery absorption time limit,0.01,un16,hours
|
||||||
|
BATTERY_ABSORPTION_TIME_LIMIT = 0xEDFB
|
||||||
|
# 0xEDF7,Battery absorption voltage,0.01,un16,V
|
||||||
|
BATTERY_ABSORPTION_VOLTAGE = 0xEDF7
|
||||||
|
# 0xEDF6,Battery float voltage,0.01,un16,V
|
||||||
|
BATTERY_FLOAT_VOLTAGE = 0xEDF6
|
||||||
|
# 0xEDF4,Battery equalisation voltage,0.01,un16,V
|
||||||
|
BATTERY_EQUALISATION_VOLTAGE = 0xEDF4
|
||||||
|
# 0xEDF2,Battery temp. compensation,0.01,sn16,mV/K
|
||||||
|
BATTERY_TEMP_COMPENSATION = 0xEDF2
|
||||||
|
# 0XEDF1,BATTERY TYPE,1,UN8,0XFF = user
|
||||||
|
BATTERY_TYPE = 0xEDF1
|
||||||
|
# 0xEDF0,Battery maximum current,0.1,un16,A
|
||||||
|
BATTERY_MAXIMUM_CURRENT = 0xEDF0
|
||||||
|
# 0xEDEF,Battery voltage,1,un8,V
|
||||||
|
BATTERY_VOLTAGE = 0xEDEF
|
||||||
|
# 0xEDEA,Battery voltage setting,1,un8,V
|
||||||
|
BATTERY_VOLTAGE_SETTING = 0xEDEA
|
||||||
|
# 0xEDE8,BMS present,-,un8,0=no, 1=yes
|
||||||
|
BMS_PRESENT = 0xEDE8
|
||||||
|
# 0xEDE7,Tail current,0.1,un16,
|
||||||
|
TAIL_CURRENT = 0xEDE7
|
||||||
|
# 0xEDE6,Low temperature charge current,0.1,un16,A, 0xFFFF=use max
|
||||||
|
LOW_TEMPERATURE_CHARGE_CURRENT = 0xEDE6
|
||||||
|
# 0xEDE5,Auto equalise stop on voltage,-,un8,0=no, 1=yes
|
||||||
|
AUTO_EQUALISE_STOP_ON_VOLTAGE = 0xEDE5
|
||||||
|
# 0xEDE4,Equalisation current level,1,un8,% (of 0xEDF0)
|
||||||
|
EQUALISATION_CURRENT_LEVEL = 0xEDE4
|
||||||
|
# 0xEDE3,Equalisation duration,0.01,un16,hours
|
||||||
|
EQUALISATION_DURATION = 0xEDE3
|
||||||
|
# 0xED2E,Re-bulk voltage offset,0.01,un16,V
|
||||||
|
RE_BULK_VOLTAGE_OFFSET = 0xED2E
|
||||||
|
# 0xEDE0,Battery low temperature level,0.01,sn16,°C
|
||||||
|
BATTERY_LOW_TEMPERATURE_LEVEL = 0xEDE0
|
||||||
|
# 0xEDCA,Voltage compensation,0.01,un16,V
|
||||||
|
VOLTAGE_COMPENSATION = 0xEDCA
|
||||||
|
|
||||||
|
# Charger data registers
|
||||||
|
# 0xEDEC,Battery temperature,0.01,un16,K
|
||||||
|
BATTERY_TEMPERATURE = 0xEDEC
|
||||||
|
# 0xEDDF,Charger maximum current,0.01,un16,A
|
||||||
|
CHARGER_MAXIMUM_CURRENT = 0xEDDF
|
||||||
|
# 0xEDDD,System yield,0.01,un32,kWh
|
||||||
|
SYSTEM_YIELD = 0xEDDD
|
||||||
|
# 0xEDDC,User yield (*2),0.01,un32,kWh
|
||||||
|
USER_YIELD = 0xEDDC
|
||||||
|
# 0xEDDB,Charger internal temperature,0.01,sn16,°C
|
||||||
|
CHARGER_INTERNAL_TEMPERATURE = 0xEDDB
|
||||||
|
# 0xEDDA,Charger error code,-,un8,-
|
||||||
|
CHARGER_ERROR_CODE = 0xEDDA
|
||||||
|
# 0xEDD7,Charger current,0.1,un16,A
|
||||||
|
CHARGER_CURRENT = 0xEDD7
|
||||||
|
# 0xEDD5,Charger voltage,0.01,un16,V
|
||||||
|
CHARGER_VOLTAGE = 0xEDD5
|
||||||
|
# 0xEDD4,Additional charger state info,-,un8,-
|
||||||
|
ADDITIONAL_CHARGER_STATE_INFO = 0xEDD4
|
||||||
|
# 0xEDD3,Yield today,0.01,(*4),kWh
|
||||||
|
YIELD_TODAY = 0xEDD3
|
||||||
|
# 0xEDD2,Maximum power today,1,un16,W
|
||||||
|
MAXIMUM_POWER_TODAY = 0xEDD2
|
||||||
|
# 0xEDD1,Yield yesterday,0.01,(*4),kWh
|
||||||
|
YIELD_YESTERDAY = 0xEDD1
|
||||||
|
# 0xEDD0,Maximum power yesterday,1,un16,W
|
||||||
|
MAXIMUM_POWER_YESTERDAY = 0xEDD0
|
||||||
|
# 0xEDCE,Voltage settings range,-,un16,-
|
||||||
|
VOLTAGE_SETTINGS_RANGE = 0xEDCE
|
||||||
|
# 0xEDCD,History version,-,un8,-
|
||||||
|
HISTORY_VERSION = 0xEDCD
|
||||||
|
# 0xEDCC,Streetlight version,-,un8,-
|
||||||
|
STREETLIGHT_VERSION = 0xEDCC
|
||||||
|
# 0x2211,Adjustable voltage minimum,0.01,un16,V
|
||||||
|
ADJUSTABLE_VOLTAGE_MINIMUM = 0x2211
|
||||||
|
# 0x2212 Adjustable voltage maximum 0.01 un16 V
|
||||||
|
ADJUSTABLE_VOLTAGE_MAXIMUM = 0x2212
|
||||||
|
|
||||||
|
# Solar panel data registers
|
||||||
|
# 0xEDBC,Panel power,0.01,un32,W
|
||||||
|
PANEL_POWER = 0xEDBC
|
||||||
|
# 0xEDBB,Panel voltage,0.01,un16,V
|
||||||
|
PANEL_VOLTAGE = 0xEDBB
|
||||||
|
# 0xEDBD,Panel current,0.1,un16,A
|
||||||
|
PANEL_CURRENT = 0xEDBD
|
||||||
|
# 0xEDB8,Panel maximum voltage,0.01,un16,V
|
||||||
|
PANEL_MAXIMUM_VOLTAGE = 0xEDB8
|
||||||
|
# 0xEDB3,Tracker mode,-,un8,-
|
||||||
|
TRACKER_MODE = 0xEDB3
|
||||||
|
|
||||||
|
# Load output data/settings registers
|
||||||
|
# 0xEDAD,Load current,0.1,un16,A
|
||||||
|
LOAD_CURRENT = 0xEDAD
|
||||||
|
# 0xEDAC,Load offset voltage,0.01,un16,V
|
||||||
|
LOAD_OFFSET_VOLTAGE = 0xEDAC
|
||||||
|
# 0xEDAB,Load output control,-,un8,-
|
||||||
|
LOAD_OUTPUT_CONTROL = 0xEDAB
|
||||||
|
# 0xEDA9,Load output voltage,0.01,un16,V
|
||||||
|
LOAD_OUTPUT_VOLTAGE = 0xEDA9
|
||||||
|
# 0xEDA8,Load output state,-,un8,-
|
||||||
|
LOAD_OUTPUT_STATE = 0xEDA8
|
||||||
|
# 0xED9D,Load switch high level,0.01,un16,V
|
||||||
|
LOAD_SWITCH_HIGH_LEVEL = 0xED9D
|
||||||
|
# 0xED9C,Load switch low level,0.01,un16,V
|
||||||
|
LOAD_SWITCH_LOW_LEVEL = 0xED9C
|
||||||
|
# 0xED91,Load output off reason,-,un8,-
|
||||||
|
LOAD_OUTPUT_OFF_REASON = 0xED91
|
||||||
|
# 0xED90,Load AES timer,1,un16,minute
|
||||||
|
LOAD_AES_TIMER = 0xED90
|
||||||
|
|
||||||
|
# Relay settings registers
|
||||||
|
# 0xEDD9,Relay operation mode,-,un8,-
|
||||||
|
RELAY_OPERATION_MODE = 0xEDD9
|
||||||
|
# 0x0350,Relay battery low voltage set,0.01,un16,V
|
||||||
|
RELAY_BATTERY_LOW_VOLTAGE_SET = 0x0350
|
||||||
|
# 0x0351,Relay battery low voltage clear,0.01,un16,V
|
||||||
|
RELAY_BATTERY_LOW_VOLTAGE_CLEAR = 0x0351
|
||||||
|
# 0x0352,Relay battery high voltage set,0.01,un16,V
|
||||||
|
RELAY_BATTERY_HIGH_VOLTAGE_SET = 0x0352
|
||||||
|
# 0x0353,Relay battery high voltage clear,0.01,un16,V
|
||||||
|
RELAY_BATTERY_HIGH_VOLTAGE_CLEAR = 0x0353
|
||||||
|
# 0xEDBA,Relay panel high voltage set,0.01,un16,V
|
||||||
|
RELAY_PANEL_HIGH_VOLTAGE_SET = 0xEDBA
|
||||||
|
# 0xEDB9,Relay panel high voltage clear,0.01,un16,V
|
||||||
|
RELAY_PANEL_HIGH_VOLTAGE_CLEAR = 0xEDB9
|
||||||
|
# 0x100A Relay minimum enabled time 1 un16 minute
|
||||||
|
RELAY_MINIMUM_ENABLED_TIME = 0x100A
|
||||||
|
|
||||||
|
# Lighting controller timer
|
||||||
|
# Timer events 0..5,-,un32,-
|
||||||
|
TIMER_EVENT_0 = 0xEDA0
|
||||||
|
# 0xEDA7,Mid-point shift,1,sn16,min
|
||||||
|
MID_POINT_SHIFT = 0xEDA7
|
||||||
|
# 0xED9B,Gradual dim speed,1,un8,s
|
||||||
|
GRADUAL_DIM_SPEED = 0xED9B
|
||||||
|
# 0xED9A,Panel voltage night,0.01,un16,V
|
||||||
|
PANEL_VOLTAGE_NIGHT = 0xED9A
|
||||||
|
# 0xED99,Panel voltage day,0.01,un16,V
|
||||||
|
PANEL_VOLTAGE_DAY = 0xED99
|
||||||
|
# 0xED96,Sunset delay,1,un16,min
|
||||||
|
SUNSET_DELAY = 0xED96
|
||||||
|
# 0xED97,Sunrise delay,1,un16,min
|
||||||
|
SUNRISE_DELAY = 0xED97
|
||||||
|
# 0xED90,AES Timer,1,un16,min
|
||||||
|
AES_TIMER = 0xED90
|
||||||
|
# 0x2030,Solar activity,-,un8,0=dark, 1=light
|
||||||
|
SOLAR_ACTIVITY = 0x2030
|
||||||
|
# 0x2031 Time-of-day 1 un16 min, 0=mid-night
|
||||||
|
TIME_OF_DAY = 0x2031
|
||||||
|
|
||||||
|
# VE.Direct port functions
|
||||||
|
# 0xED9E,TX Port operation mode,-,un8,-
|
||||||
|
TX_PORT_OPERATION_MODE = 0xED9E
|
||||||
|
# 0xED98 RX Port operation mode - un8 -
|
||||||
|
RX_PORT_OPERATION_MODE = 0xED98
|
||||||
|
|
||||||
|
# Restore factory defaults
|
||||||
|
# 0x0004,Restore default
|
||||||
|
RESTORE_DEFAULT = 0x0004
|
||||||
|
|
||||||
|
# History data
|
||||||
|
# 0x1030,Clear history
|
||||||
|
CLEAR_HISTORY = 0x1030
|
||||||
|
# 0x104F,Total history
|
||||||
|
TOTAL_HISTORY = 0x104F
|
||||||
|
# Items
|
||||||
|
HISTORY_0 = 0x1050
|
||||||
|
|
||||||
|
# Pluggable display settings
|
||||||
|
# 0x0400,Display backlight mode,,,un8
|
||||||
|
DISPLAY_BACKLIGHT_MODE = 0x0400
|
||||||
|
# 0x0401,Display backlight intensity,,un8,
|
||||||
|
DISPLAY_BACKLIGHT_INTENSITY = 0x0401
|
||||||
|
# 0x0402,Display scroll text speed,,un8,
|
||||||
|
DISPLAY_SCROLL_TEXT_SPEED = 0x0402
|
||||||
|
# 0x0403,Display setup lock (*2),,,un8
|
||||||
|
DISPLAY_SETUP_LOCK = 0x0403
|
||||||
|
# 0x0404,Display temperature unit (*2),,,un8
|
||||||
|
DISPLAY_TEMPERATURE_UNIT = 0x0404
|
||||||
|
|
||||||
|
# Remote control registers
|
||||||
|
# 0x2000,Charge algorithm version,-,un8,-
|
||||||
|
CHARGE_ALGORITHM_VERSION = 0x2000
|
||||||
|
# 0x2001,Charge voltage set-point,0.01,un16,V
|
||||||
|
CHARGE_VOLTAGE_SET_POINT = 0x2001
|
||||||
|
# 0x2002,Battery voltage sense,0.01,un16,V
|
||||||
|
BATTERY_VOLTAGE_SENSE = 0x2002
|
||||||
|
# 0x2003,Battery temperature sense,0.01,sn16,°C
|
||||||
|
BATTERY_TEMPERATURE_SENSE = 0x2003
|
||||||
|
# 0x2004,Remote command,-,un8,-
|
||||||
|
REMOTE_COMMAND = 0x2004
|
||||||
|
# 0x2007,Charge state elapsed time,1,un32,ms
|
||||||
|
CHARGE_STATE_ELAPSED_TIME = 0x2007
|
||||||
|
# 0x2008,Absorption time,0.01,un16,hours
|
||||||
|
ABSORPTION_TIME = 0x2008
|
||||||
|
# 0x2009,Error code,-,un8,-
|
||||||
|
ERROR_CODE = 0x2009
|
||||||
|
# 0x200A,Battery charge current,0.001,sn32,A
|
||||||
|
BATTERY_CHARGE_CURRENT = 0x200A
|
||||||
|
# 0x200B,Battery idle voltage,0.01,un16,V
|
||||||
|
BATTERY_IDLE_VOLTAGE = 0x200B
|
||||||
|
# 0x200C,Device state,-,un8,-
|
||||||
|
REMOTE_DEVICE_STATE = 0x200C
|
||||||
|
# 0x200D,Network info,-,un8,-
|
||||||
|
NETWORK_INFO = 0x200D
|
||||||
|
# 0x200E,Network mode,-,un8,-
|
||||||
|
NETWORK_MODE = 0x200E
|
||||||
|
# 0x200F,Network status register,-,un8,-
|
||||||
|
NETWORK_STATUS_REGISTER = 0x200F
|
||||||
|
# 0x2013,Total charge current,0.001,sn32,A
|
||||||
|
TOTAL_CHARGE_CURRENT = 0x2013
|
||||||
|
# 0x2014,Charge current percentage,1,un8,%
|
||||||
|
CHARGE_CURRENT_PERCENTAGE = 0x2014
|
||||||
|
# 0x2015,Charge current limit,0.1,un16,A
|
||||||
|
CHARGE_CURRENT_LIMIT = 0x2015
|
||||||
|
# 0x2018,Manual equalisation pending,-,un8,-
|
||||||
|
MANUAL_EQUALISATION_PENDING = 0x2018
|
||||||
|
# 0x2027,Total DC input power,0.01,un32,W
|
||||||
|
TOTAL_DC_INPUT_POWER = 0x2027
|
||||||
|
|
||||||
|
|
||||||
|
# Product information registers
|
||||||
|
PRODUCT_ID = Register(0x0100, 'Product ID', 'xHx')
|
||||||
|
GROUP_ID = Register(0x0104, 'Group ID', 'B')
|
||||||
|
SERIAL_NUMBER = Register(0x010A, 'Serial number', 'S')
|
||||||
|
MODEL_NAME = Register(0x010B, 'Model name', 'S')
|
||||||
|
CAPABILITIES = Register(0x0140, 'Capabilities', 'I', None, None,
|
||||||
|
schema.Capabilities)
|
||||||
|
|
||||||
|
# Generic device control registers
|
||||||
|
DEVICE_MODE = Register(0x0200, 'Device mode', 'B')
|
||||||
|
DEVICE_STATE = Register(0x0201, 'Device state', 'B', None, None, schema.State)
|
||||||
|
REMOTE_CONTROL_USED = Register(0x0202, 'Remote control used', 'I')
|
||||||
|
DEVICE_OFF_REASON = Register(0x0205, 'Device off reason', 'B')
|
||||||
|
DEVICE_OFF_REASON_EXTENDED = Register(0x0207, 'Device off reason', 'I')
|
||||||
|
|
||||||
|
# Battery settings registers
|
||||||
|
BATTERYSAFE_MODE = Register(0xEDFF, '')
|
||||||
|
ADAPTIVE_MODE = Register(0xEDFE, 'Adaptive mode', 'B', None, None, bool)
|
||||||
|
AUTOMATIC_EQUALISATION_MODE = Register(0xEDFD, 'Automatic equalisation mode',
|
||||||
|
'B')
|
||||||
|
|
||||||
|
BATTERY_BULK_TIME_LIMIT = Register(0xEDFC, 'Battery bulk time limit', 'H',
|
||||||
|
None, 0.01, _ureg.hour)
|
||||||
|
BATTERY_ABSORPTION_TIME_LIMIT = Register(0xEDFB,
|
||||||
|
'Battery absorption time limit', 'H',
|
||||||
|
None, 0.01, _ureg.hour)
|
||||||
|
BATTERY_ABSORPTION_VOLTAGE = Register(0xEDF7, 'Battery absorption voltage',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
BATTERY_FLOAT_VOLTAGE = Register(0xEDF6, 'Battery float voltage', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
BATTERY_EQUALISATION_VOLTAGE = Register(0xEDF4, 'Battery equalisation voltage',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
BATTERY_TEMP_COMPENSATION = Register(0xEDF2, 'Battery temp. compensation', 'h',
|
||||||
|
None, 0.01, 'mV/K')
|
||||||
|
BATTERY_TYPE = Register(0XEDF1, 'Battery type', 'B')
|
||||||
|
BATTERY_MAXIMUM_CURRENT = Register(0xEDF0, 'Battery maximum current', 'H',
|
||||||
|
None, 0.1, _ureg.ampere)
|
||||||
|
BATTERY_VOLTAGE = Register(0xEDEF, 'Battery voltage', 'B', None, None,
|
||||||
|
_ureg.volt)
|
||||||
|
BATTERY_VOLTAGE_SETTING = Register(0xEDEA, 'Battery voltage setting', 'B',
|
||||||
|
None, None, _ureg.volt)
|
||||||
|
BMS_PRESENT = Register(0xEDE8, 'BMS present', 'B', None, 1, bool)
|
||||||
|
TAIL_CURRENT = Register(0xEDE7, 'Tail current', 'H', None, 0.1, '')
|
||||||
|
LOW_TEMPERATURE_CHARGE_CURRENT = Register(0xEDE6,
|
||||||
|
'Low temperature charge current',
|
||||||
|
'H', None, 0.1, _ureg.ampere)
|
||||||
|
AUTO_EQUALISE_STOP_ON_VOLTAGE = Register(0xEDE5,
|
||||||
|
'Auto equalise stop on voltage', 'B',
|
||||||
|
None, 1, bool)
|
||||||
|
EQUALISATION_CURRENT_LEVEL = Register(0xEDE4, 'Equalisation current level',
|
||||||
|
'B')
|
||||||
|
EQUALISATION_DURATION = Register(0xEDE3, 'Equalisation duration', 'H', None,
|
||||||
|
0.01, _ureg.hour)
|
||||||
|
RE_BULK_VOLTAGE_OFFSET = Register(0xED2E, 'Re-bulk voltage offset', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
BATTERY_LOW_TEMPERATURE_LEVEL = Register(0xEDE0,
|
||||||
|
'Battery low temperature level', 'h',
|
||||||
|
None, 0.01, _ureg.degC)
|
||||||
|
VOLTAGE_COMPENSATION = Register(0xEDCA, 'Voltage compensation', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
|
||||||
|
# Charger data registers
|
||||||
|
BATTERY_TEMPERATURE = Register(0xEDEC, 'Battery temperature', 'H', None, 0.01,
|
||||||
|
_ureg.kelvin)
|
||||||
|
CHARGER_MAXIMUM_CURRENT = Register(0xEDDF, 'Charger maximum current', 'H',
|
||||||
|
None, 0.01, _ureg.ampere)
|
||||||
|
SYSTEM_YIELD = Register(0xEDDD, 'System yield', 'I', None, 10, _ureg.watt_hour)
|
||||||
|
USER_YIELD = Register(0xEDDC, 'User yield', 'I', None, 10, _ureg.watt_hour)
|
||||||
|
CHARGER_INTERNAL_TEMPERATURE = Register(0xEDDB, 'Charger internal temperature',
|
||||||
|
'h', None, 0.01, _ureg.degC)
|
||||||
|
CHARGER_ERROR_CODE = Register(0xEDDA, 'Charger error code', 'B', None, None,
|
||||||
|
schema.Err)
|
||||||
|
CHARGER_CURRENT = Register(0xEDD7, 'Charger current', 'H', None, 0.1,
|
||||||
|
_ureg.ampere)
|
||||||
|
CHARGER_VOLTAGE = Register(0xEDD5, 'Charger voltage', 'H', None, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
ADDITIONAL_CHARGER_STATE_INFO = Register(0xEDD4,
|
||||||
|
'Additional charger state info', 'B',
|
||||||
|
None, None,
|
||||||
|
schema.AdditionalChargerState)
|
||||||
|
YIELD_TODAY = Register(0xEDD3, 'Yield today', 'H', None, 10, _ureg.watt_hour)
|
||||||
|
MAXIMUM_POWER_TODAY = Register(0xEDD2, 'Maximum power today', 'H', None, 1,
|
||||||
|
_ureg.watt)
|
||||||
|
YIELD_YESTERDAY = Register(0xEDD1, 'Yield yesterday', 'H', None, 10,
|
||||||
|
_ureg.watt_hour)
|
||||||
|
MAXIMUM_POWER_YESTERDAY = Register(0xEDD0, 'Maximum power yesterday', 'H',
|
||||||
|
None, 1, _ureg.watt)
|
||||||
|
VOLTAGE_SETTINGS_RANGE = Register(
|
||||||
|
0xEDCE,
|
||||||
|
'Voltage settings range',
|
||||||
|
'H',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
HISTORY_VERSION = Register(0xEDCD, 'History version', 'B')
|
||||||
|
STREETLIGHT_VERSION = Register(0xEDCC, 'Streetlight version', 'B')
|
||||||
|
ADJUSTABLE_VOLTAGE_MINIMUM = Register(0x2211, 'Adjustable voltage minimum',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
ADJUSTABLE_VOLTAGE_MAXIMUM = Register(0x2212, 'Adjustable voltage maximum',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
|
||||||
|
# Solar panel data registers
|
||||||
|
PANEL_POWER = Register(0xEDBC, 'Panel power', 'I', None, 0.01, _ureg.watt)
|
||||||
|
PANEL_VOLTAGE = Register(0xEDBB, 'Panel voltage', 'H', None, 0.01, _ureg.volt)
|
||||||
|
PANEL_CURRENT = Register(0xEDBD, 'Panel current', 'H', None, 0.1, _ureg.ampere)
|
||||||
|
PANEL_MAXIMUM_VOLTAGE = Register(0xEDB8, 'Panel maximum voltage', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
TRACKER_MODE = Register(0xEDB3, 'Tracker mode', 'B', None, None,
|
||||||
|
schema.TrackerMode)
|
||||||
|
|
||||||
|
# Load output data/settings registers
|
||||||
|
LOAD_CURRENT = Register(0xEDAD, 'Load current', 'H', None, 0.1, _ureg.ampere)
|
||||||
|
# TODO: spec says H.
|
||||||
|
LOAD_OFFSET_VOLTAGE = Register(0xEDAC, 'Load offset voltage', 'B', None, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
LOAD_OUTPUT_CONTROL = Register(0xEDAB, 'Load output control', 'B', None, None,
|
||||||
|
schema.LoadOutputControl)
|
||||||
|
LOAD_OUTPUT_VOLTAGE = Register(0xEDA9, 'Load output voltage', 'H', None, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
LOAD_OUTPUT_STATE = Register(0xEDA8, 'Load output state', 'B')
|
||||||
|
LOAD_SWITCH_HIGH_LEVEL = Register(0xED9D, 'Load switch high level', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
LOAD_SWITCH_LOW_LEVEL = Register(0xED9C, 'Load switch low level', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
LOAD_OUTPUT_OFF_REASON = Register(0xED91, 'Load output off reason', 'B', None,
|
||||||
|
None, schema.OffReason)
|
||||||
|
LOAD_AES_TIMER = Register(0xED90, 'Load AES timer', 'H', None, 1, _ureg.minute)
|
||||||
|
|
||||||
|
# Relay settings registers
|
||||||
|
RELAY_OPERATION_MODE = Register(0xEDD9, 'Relay operation mode', 'B', None,
|
||||||
|
None, schema.RelayMode)
|
||||||
|
RELAY_BATTERY_LOW_VOLTAGE_SET = Register(0x0350,
|
||||||
|
'Relay battery low voltage set', 'H',
|
||||||
|
None, 0.01, _ureg.volt)
|
||||||
|
RELAY_BATTERY_LOW_VOLTAGE_CLEAR = Register(0x0351,
|
||||||
|
'Relay battery low voltage clear',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
RELAY_BATTERY_HIGH_VOLTAGE_SET = Register(0x0352,
|
||||||
|
'Relay battery high voltage set',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
RELAY_BATTERY_HIGH_VOLTAGE_CLEAR = Register(
|
||||||
|
0x0353, 'Relay battery high voltage clear', 'H', None, 0.01, _ureg.volt)
|
||||||
|
RELAY_PANEL_HIGH_VOLTAGE_SET = Register(0xEDBA, 'Relay panel high voltage set',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
RELAY_PANEL_HIGH_VOLTAGE_CLEAR = Register(0xEDB9,
|
||||||
|
'Relay panel high voltage clear',
|
||||||
|
'H', None, 0.01, _ureg.volt)
|
||||||
|
RELAY_MINIMUM_ENABLED_TIME = Register(0x100A, 'Relay minimum enabled time',
|
||||||
|
'H', None, 1, _ureg.minute)
|
||||||
|
|
||||||
|
# Lighting controller timer
|
||||||
|
# TIMER_EVENT_0 = Register(, 'Timer events None, 0..5,un32,-,-')
|
||||||
|
MID_POINT_SHIFT = Register(0xEDA7, 'Mid-point shift', 'h', None, 60,
|
||||||
|
_ureg.second)
|
||||||
|
GRADUAL_DIM_SPEED = Register(0xED9B, 'Gradual dim speed', 'B', None, 1,
|
||||||
|
_ureg.second)
|
||||||
|
PANEL_VOLTAGE_NIGHT = Register(0xED9A, 'Panel voltage night', 'H', None, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
PANEL_VOLTAGE_DAY = Register(0xED99, 'Panel voltage day', 'H', None, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
SUNSET_DELAY = Register(0xED96, 'Sunset delay', 'H', None, 60, _ureg.second)
|
||||||
|
SUNRISE_DELAY = Register(0xED97, 'Sunrise delay', 'H', None, 60, _ureg.second)
|
||||||
|
AES_TIMER = Register(0xED90, 'AES Timer', 'H', None, 60, _ureg.second)
|
||||||
|
SOLAR_ACTIVITY = Register(0x2030, 'Solar activity', 'B', None, 1, bool)
|
||||||
|
TIME_OF_DAY = Register(0x2031, 'Time-of-day', 'H')
|
||||||
|
|
||||||
|
# VE.Direct port functions
|
||||||
|
TX_PORT_OPERATION_MODE = Register(0xED9E, 'TX Port operation mode', 'B', None,
|
||||||
|
None, schema.TXPortMode)
|
||||||
|
RX_PORT_OPERATION_MODE = Register(0xED98, 'RX Port operation mode', 'B', None,
|
||||||
|
None, schema.RXPortMode)
|
||||||
|
|
||||||
|
# Restore factory defaults
|
||||||
|
RESTORE_DEFAULT = Register(0x0004, 'Restore default')
|
||||||
|
|
||||||
|
# History data
|
||||||
|
# CLEAR_HISTORY = Register(0x1030, 'Total history')
|
||||||
|
# HISTORY_0 = Register(0, 'Clear history')
|
||||||
|
# TOTAL_HISTORY = Register(0x104F, 'Items')
|
||||||
|
|
||||||
|
# Pluggable display settings
|
||||||
|
DISPLAY_BACKLIGHT_MODE = Register(0x0400, 'Display backlight mode,,,un8')
|
||||||
|
DISPLAY_BACKLIGHT_INTENSITY = Register(0x0401, 'Display backlight intensity',
|
||||||
|
'B')
|
||||||
|
DISPLAY_SCROLL_TEXT_SPEED = Register(0x0402, 'Display scroll text speed', 'B',
|
||||||
|
None)
|
||||||
|
DISPLAY_SETUP_LOCK = Register(0x0403, 'Display setup lock', 'B')
|
||||||
|
DISPLAY_TEMPERATURE_UNIT = Register(0x0404, 'Display temperature unit', 'B',
|
||||||
|
None)
|
||||||
|
|
||||||
|
# Remote control registers
|
||||||
|
CHARGE_ALGORITHM_VERSION = Register(0x2000, 'Charge algorithm version', 'B',
|
||||||
|
None)
|
||||||
|
CHARGE_VOLTAGE_SET_POINT = Register(0x2001, 'Charge voltage set-point', 'H',
|
||||||
|
None, 0.01, _ureg.volt)
|
||||||
|
BATTERY_VOLTAGE_SENSE = Register(0x2002, 'Battery voltage sense', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
BATTERY_TEMPERATURE_SENSE = Register(0x2003, 'Battery temperature sense', 'h',
|
||||||
|
None, 0.01, _ureg.degC)
|
||||||
|
REMOTE_COMMAND = Register(0x2004, 'Remote command', 'B')
|
||||||
|
CHARGE_STATE_ELAPSED_TIME = Register(0x2007, 'Charge state elapsed time', 'I',
|
||||||
|
None, 1e-3, _ureg.second)
|
||||||
|
ABSORPTION_TIME = Register(0x2008, 'Absorption time', 'H', None, 0.01,
|
||||||
|
_ureg.hour)
|
||||||
|
ERROR_CODE = Register(0x2009, 'Error code', 'B')
|
||||||
|
BATTERY_CHARGE_CURRENT = Register(0x200A, 'Battery charge current', 'i', None,
|
||||||
|
0.001, _ureg.ampere)
|
||||||
|
BATTERY_IDLE_VOLTAGE = Register(0x200B, 'Battery idle voltage', 'H', None,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
REMOTE_DEVICE_STATE = Register(0x200C, 'Device state', 'B')
|
||||||
|
NETWORK_INFO = Register(0x200D, 'Network info', 'B')
|
||||||
|
NETWORK_MODE = Register(0x200E, 'Network mode', 'B')
|
||||||
|
NETWORK_STATUS_REGISTER = Register(0x200F, 'Network status register', 'B',
|
||||||
|
None)
|
||||||
|
TOTAL_CHARGE_CURRENT = Register(0x2013, 'Total charge current', 'i', None,
|
||||||
|
0.001, _ureg.ampere)
|
||||||
|
CHARGE_CURRENT_PERCENTAGE = Register(0x2014, 'Charge current percentage', 'B')
|
||||||
|
CHARGE_CURRENT_LIMIT = Register(0x2015, 'Charge current limit', 'H', None, 0.1,
|
||||||
|
_ureg.ampere)
|
||||||
|
MANUAL_EQUALISATION_PENDING = Register(0x2018, 'Manual equalisation pending',
|
||||||
|
'B')
|
||||||
|
TOTAL_DC_INPUT_POWER = Register(0x2027, 'Total DC input power', 'I', None,
|
||||||
|
0.01, _ureg.watt)
|
||||||
|
|
||||||
|
REGISTERS = {
|
||||||
|
ID(r.id): r
|
||||||
|
for r in globals().values() if isinstance(r, Register)
|
||||||
|
}
|
||||||
|
|
||||||
|
for register in REGISTERS.values():
|
||||||
|
assert register.mode is None, register
|
||||||
|
if register.scale is not None:
|
||||||
|
assert isinstance(register.scale, (float, int)), register
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Information:
|
||||||
|
product_id: int = Field(PRODUCT_ID)
|
||||||
|
# group_id: float = Field(GROUP_ID)
|
||||||
|
serial_number: str = Field(SERIAL_NUMBER)
|
||||||
|
model_name: str = Field(MODEL_NAME)
|
||||||
|
capabilities: schema.Capabilities = Field(CAPABILITIES)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Generic:
|
||||||
|
mode: int = Field(DEVICE_MODE)
|
||||||
|
state: schema.State = Field(DEVICE_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Settings:
|
||||||
|
absorption_time_limit: float = Field(BATTERY_ABSORPTION_TIME_LIMIT)
|
||||||
|
absorption_voltage: float = Field(BATTERY_ABSORPTION_VOLTAGE)
|
||||||
|
float_voltage: float = Field(BATTERY_FLOAT_VOLTAGE)
|
||||||
|
equalisation_voltage: float = Field(BATTERY_EQUALISATION_VOLTAGE)
|
||||||
|
temp_compensation: float = Field(BATTERY_TEMP_COMPENSATION)
|
||||||
|
type: int = Field(BATTERY_TYPE)
|
||||||
|
maximum_current: float = Field(BATTERY_MAXIMUM_CURRENT)
|
||||||
|
voltage: float = Field(BATTERY_VOLTAGE)
|
||||||
|
voltage_setting: float = Field(BATTERY_VOLTAGE_SETTING)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Charger:
|
||||||
|
maximum_current: float = Field(CHARGER_MAXIMUM_CURRENT)
|
||||||
|
system_yield: float = Field(SYSTEM_YIELD)
|
||||||
|
user_yield: float = Field(USER_YIELD)
|
||||||
|
internal_temperature: float = Field(CHARGER_INTERNAL_TEMPERATURE)
|
||||||
|
error_code: schema.Err = Field(CHARGER_ERROR_CODE)
|
||||||
|
current: float = Field(CHARGER_CURRENT)
|
||||||
|
voltage: float = Field(CHARGER_VOLTAGE)
|
||||||
|
additional_state_info: schema.AdditionalChargerState = Field(
|
||||||
|
ADDITIONAL_CHARGER_STATE_INFO)
|
||||||
|
yield_today: float = Field(YIELD_TODAY)
|
||||||
|
maximum_power_today: float = Field(MAXIMUM_POWER_TODAY)
|
||||||
|
yield_yesterday: float = Field(YIELD_YESTERDAY)
|
||||||
|
maximum_power_yesterday: float = Field(MAXIMUM_POWER_YESTERDAY)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Solar:
|
||||||
|
power: float = Field(PANEL_POWER)
|
||||||
|
voltage: float = Field(PANEL_VOLTAGE)
|
||||||
|
maximum_voltage: float = Field(PANEL_MAXIMUM_VOLTAGE)
|
||||||
|
tracker_mode: schema.TrackerMode = Field(TRACKER_MODE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Load:
|
||||||
|
current: float = Field(LOAD_CURRENT)
|
||||||
|
offset_voltage: float = Field(LOAD_OFFSET_VOLTAGE)
|
||||||
|
output_control: float = Field(LOAD_OUTPUT_CONTROL)
|
||||||
|
output_state: float = Field(LOAD_OUTPUT_STATE)
|
||||||
|
switch_high_level: float = Field(LOAD_SWITCH_HIGH_LEVEL)
|
||||||
|
mswitch_low_level: float = Field(LOAD_SWITCH_LOW_LEVEL)
|
||||||
|
|
||||||
|
|
||||||
|
GROUPS = (Information, Generic, Settings, Charger, Solar, Load)
|
||||||
|
|
||||||
|
PRODUCT_IDS = {
|
||||||
|
0x203: 'BMV-700',
|
||||||
|
0x204: 'BMV-702',
|
||||||
|
0x205: 'BMV-700H',
|
||||||
|
0x0300: 'BlueSolar MPPT 70|15',
|
||||||
|
0xA040: 'BlueSolar MPPT 75|50',
|
||||||
|
0xA041: 'BlueSolar MPPT 150|35',
|
||||||
|
0xA042: 'BlueSolar MPPT 75|15',
|
||||||
|
0xA043: 'BlueSolar MPPT 100|15',
|
||||||
|
0xA044: 'BlueSolar MPPT 100|30',
|
||||||
|
0xA045: 'BlueSolar MPPT 100|50',
|
||||||
|
0xA046: 'BlueSolar MPPT 150|70',
|
||||||
|
0xA047: 'BlueSolar MPPT 150|100',
|
||||||
|
0xA049: 'BlueSolar MPPT 100|50 rev2',
|
||||||
|
0xA04A: 'BlueSolar MPPT 100|30 rev2',
|
||||||
|
0xA04B: 'BlueSolar MPPT 150|35 rev2',
|
||||||
|
0xA04C: 'BlueSolar MPPT 75|10',
|
||||||
|
0xA04D: 'BlueSolar MPPT 150|45',
|
||||||
|
0xA04E: 'BlueSolar MPPT 150|60',
|
||||||
|
0xA04F: 'BlueSolar MPPT 150|85',
|
||||||
|
0xA050: 'SmartSolar MPPT 250|100',
|
||||||
|
0xA051: 'SmartSolar MPPT 150|100',
|
||||||
|
0xA052: 'SmartSolar MPPT 150|85',
|
||||||
|
0xA053: 'SmartSolar MPPT 75|15',
|
||||||
|
0xA054: 'SmartSolar MPPT 75|10',
|
||||||
|
0xA055: 'SmartSolar MPPT 100|15',
|
||||||
|
0xA056: 'SmartSolar MPPT 100|30',
|
||||||
|
0xA057: 'SmartSolar MPPT 100|50',
|
||||||
|
0xA058: 'SmartSolar MPPT 150|35',
|
||||||
|
0xA059: 'SmartSolar MPPT 150|100 rev2',
|
||||||
|
0xA05A: 'SmartSolar MPPT 150|85 rev2',
|
||||||
|
0xA05B: 'SmartSolar MPPT 250|70',
|
||||||
|
0xA05C: 'SmartSolar MPPT 250|85',
|
||||||
|
0xA05D: 'SmartSolar MPPT 250|60',
|
||||||
|
0xA05E: 'SmartSolar MPPT 250|45',
|
||||||
|
0xA05F: 'SmartSolar MPPT 100|20',
|
||||||
|
0xA060: 'SmartSolar MPPT 100|20 48V',
|
||||||
|
0xA061: 'SmartSolar MPPT 150|45',
|
||||||
|
0xA062: 'SmartSolar MPPT 150|60',
|
||||||
|
0xA063: 'SmartSolar MPPT 150|70',
|
||||||
|
0xA064: 'SmartSolar MPPT 250|85 rev2',
|
||||||
|
0xA065: 'SmartSolar MPPT 250|100 rev2',
|
||||||
|
0xA066: 'BlueSolar MPPT 100|20',
|
||||||
|
0xA067: 'BlueSolar MPPT 100|20 48V',
|
||||||
|
0xA068: 'SmartSolar MPPT 250|60 rev2',
|
||||||
|
0xA069: 'SmartSolar MPPT 250|70 rev2',
|
||||||
|
0xA06A: 'SmartSolar MPPT 150|45 rev2',
|
||||||
|
0xA06B: 'SmartSolar MPPT 150|60 rev2',
|
||||||
|
0xA06C: 'SmartSolar MPPT 150|70 rev2',
|
||||||
|
0xA06D: 'SmartSolar MPPT 150|85 rev3',
|
||||||
|
0xA06E: 'SmartSolar MPPT 150|100 rev3',
|
||||||
|
0xA06F: 'BlueSolar MPPT 150|45 rev2',
|
||||||
|
0xA070: 'BlueSolar MPPT 150|60 rev2',
|
||||||
|
0xA071: 'BlueSolar MPPT 150|70 rev2',
|
||||||
|
0xA102: 'SmartSolar MPPT VE.Can 150/70',
|
||||||
|
0xA103: 'SmartSolar MPPT VE.Can 150/45',
|
||||||
|
0xA104: 'SmartSolar MPPT VE.Can 150/60',
|
||||||
|
0xA105: 'SmartSolar MPPT VE.Can 150/85',
|
||||||
|
0xA106: 'SmartSolar MPPT VE.Can 150/100',
|
||||||
|
0xA107: 'SmartSolar MPPT VE.Can 250/45',
|
||||||
|
0xA108: 'SmartSolar MPPT VE.Can 250/60',
|
||||||
|
0xA109: 'SmartSolar MPPT VE.Can 250/70',
|
||||||
|
0xA10A: 'SmartSolar MPPT VE.Can 250/85',
|
||||||
|
0xA10B: 'SmartSolar MPPT VE.Can 250/100',
|
||||||
|
0xA10C: 'SmartSolar MPPT VE.Can 150/70 rev2',
|
||||||
|
0xA10D: 'SmartSolar MPPT VE.Can 150/85 rev2',
|
||||||
|
0xA10E: 'SmartSolar MPPT VE.Can 150/100 rev2',
|
||||||
|
0xA10F: 'BlueSolar MPPT VE.Can 150/100',
|
||||||
|
0xA112: 'BlueSolar MPPT VE.Can 250/70',
|
||||||
|
0xA113: 'BlueSolar MPPT VE.Can 250/100',
|
||||||
|
0xA114: 'SmartSolar MPPT VE.Can 250/70 rev2',
|
||||||
|
0xA115: 'SmartSolar MPPT VE.Can 250/100 rev2',
|
||||||
|
0xA116: 'SmartSolar MPPT VE.Can 250/85 rev2',
|
||||||
|
0xA201: 'Phoenix Inverter 12V 250VA 230V',
|
||||||
|
0xA202: 'Phoenix Inverter 24V 250VA 230V',
|
||||||
|
0xA204: 'Phoenix Inverter 48V 250VA 230V',
|
||||||
|
0xA211: 'Phoenix Inverter 12V 375VA 230V',
|
||||||
|
0xA212: 'Phoenix Inverter 24V 375VA 230V',
|
||||||
|
0xA214: 'Phoenix Inverter 48V 375VA 230V',
|
||||||
|
0xA221: 'Phoenix Inverter 12V 500VA 230V',
|
||||||
|
0xA222: 'Phoenix Inverter 24V 500VA 230V',
|
||||||
|
0xA224: 'Phoenix Inverter 48V 500VA 230V',
|
||||||
|
0xA231: 'Phoenix Inverter 12V 250VA 230V',
|
||||||
|
0xA232: 'Phoenix Inverter 24V 250VA 230V',
|
||||||
|
0xA234: 'Phoenix Inverter 48V 250VA 230V',
|
||||||
|
0xA239: 'Phoenix Inverter 12V 250VA 120V',
|
||||||
|
0xA23A: 'Phoenix Inverter 24V 250VA 120V',
|
||||||
|
0xA23C: 'Phoenix Inverter 48V 250VA 120V',
|
||||||
|
0xA241: 'Phoenix Inverter 12V 375VA 230V',
|
||||||
|
0xA242: 'Phoenix Inverter 24V 375VA 230V',
|
||||||
|
0xA244: 'Phoenix Inverter 48V 375VA 230V',
|
||||||
|
0xA249: 'Phoenix Inverter 12V 375VA 120V',
|
||||||
|
0xA24A: 'Phoenix Inverter 24V 375VA 120V',
|
||||||
|
0xA24C: 'Phoenix Inverter 48V 375VA 120V',
|
||||||
|
0xA251: 'Phoenix Inverter 12V 500VA 230V',
|
||||||
|
0xA252: 'Phoenix Inverter 24V 500VA 230V',
|
||||||
|
0xA254: 'Phoenix Inverter 48V 500VA 230V',
|
||||||
|
0xA259: 'Phoenix Inverter 12V 500VA 120V',
|
||||||
|
0xA25A: 'Phoenix Inverter 24V 500VA 120V',
|
||||||
|
0xA25C: 'Phoenix Inverter 48V 500VA 120V',
|
||||||
|
0xA261: 'Phoenix Inverter 12V 800VA 230V',
|
||||||
|
0xA262: 'Phoenix Inverter 24V 800VA 230V',
|
||||||
|
0xA264: 'Phoenix Inverter 48V 800VA 230V',
|
||||||
|
0xA269: 'Phoenix Inverter 12V 800VA 120V',
|
||||||
|
0xA26A: 'Phoenix Inverter 24V 800VA 120V',
|
||||||
|
0xA26C: 'Phoenix Inverter 48V 800VA 120V',
|
||||||
|
0xA271: 'Phoenix Inverter 12V 1200VA 230V',
|
||||||
|
0xA272: 'Phoenix Inverter 24V 1200VA 230V',
|
||||||
|
0xA274: 'Phoenix Inverter 48V 1200VA 230V',
|
||||||
|
0xA279: 'Phoenix Inverter 12V 1200VA 120V',
|
||||||
|
0xA27A: 'Phoenix Inverter 24V 1200VA 120V',
|
||||||
|
0xA27C: 'Phoenix Inverter 48V 1200VA 120V',
|
||||||
|
0xA281: 'Phoenix Inverter 12V 1600VA 230V',
|
||||||
|
0xA282: 'Phoenix Inverter 24V 1600VA 230V',
|
||||||
|
0xA284: 'Phoenix Inverter 48V 1600VA 230V',
|
||||||
|
0xA291: 'Phoenix Inverter 12V 2000VA 230V',
|
||||||
|
0xA292: 'Phoenix Inverter 24V 2000VA 230V',
|
||||||
|
0xA294: 'Phoenix Inverter 48V 2000VA 230V',
|
||||||
|
0xA2A1: 'Phoenix Inverter 12V 3000VA 230V',
|
||||||
|
0xA2A2: 'Phoenix Inverter 24V 3000VA 230V',
|
||||||
|
0xA2A4: 'Phoenix Inverter 48V 3000VA 230V',
|
||||||
|
0xA340: 'Phoenix Smart IP43 Charger 12|50 (1+1)',
|
||||||
|
0xA341: 'Phoenix Smart IP43 Charger 12|50 (3)',
|
||||||
|
0xA342: 'Phoenix Smart IP43 Charger 24|25 (1+1)',
|
||||||
|
0xA343: 'Phoenix Smart IP43 Charger 24|25 (3)',
|
||||||
|
0xA344: 'Phoenix Smart IP43 Charger 12|30 (1+1)',
|
||||||
|
0xA345: 'Phoenix Smart IP43 Charger 12|30 (3)',
|
||||||
|
0xA346: 'Phoenix Smart IP43 Charger 24|16 (1+1)',
|
||||||
|
0xA347: 'Phoenix Smart IP43 Charger 24|16 (3)',
|
||||||
|
}
|
96
vedirect/mqtt.py
Normal file
96
vedirect/mqtt.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Exports fields over MQTT with discovery."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import pint
|
||||||
|
|
||||||
|
from . import defs
|
||||||
|
|
||||||
|
_UNITS = {
|
||||||
|
'volt': ('V', 'voltage'),
|
||||||
|
'ampere': ('A', 'current'),
|
||||||
|
'watt': ('W', 'power'),
|
||||||
|
'hour * watt': ('Wh', 'energy'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Exporter:
|
||||||
|
def __init__(self, host: str, port: int = 1883):
|
||||||
|
self._last = None # type: Optional[float]
|
||||||
|
|
||||||
|
self._client = mqtt.Client()
|
||||||
|
self._client.connect_async(host, port, 60)
|
||||||
|
self._client.loop_start()
|
||||||
|
|
||||||
|
def _config(self, ser: str, fields: dict) -> None:
|
||||||
|
for label, value in fields.items():
|
||||||
|
f = defs.FIELD_MAP[label]
|
||||||
|
labelc = f.label.replace('#', '').lower()
|
||||||
|
device = {
|
||||||
|
'ids': [ser],
|
||||||
|
} # type: Dict[str, object]
|
||||||
|
if f == defs.PID:
|
||||||
|
device.update({
|
||||||
|
'manufacturer': 'Victron',
|
||||||
|
'model': defs.PIDS[int(value, 16)],
|
||||||
|
'name': f'Victron {ser}',
|
||||||
|
'sw_version': fields[defs.FW.label],
|
||||||
|
})
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'name': f'Victron {label}',
|
||||||
|
'state_topic': f'tele/victron_{ser}/{labelc}',
|
||||||
|
'unique_id': f'victron_{ser}_{labelc}',
|
||||||
|
'expire_after': 600,
|
||||||
|
'device': device,
|
||||||
|
}
|
||||||
|
if isinstance(value, pint.Quantity):
|
||||||
|
unit = str(value.units)
|
||||||
|
unit, klass = _UNITS.get(unit, (unit, None))
|
||||||
|
config['unit_of_measurement'] = unit
|
||||||
|
if klass:
|
||||||
|
config['device_class'] = klass
|
||||||
|
|
||||||
|
self._client.publish(
|
||||||
|
f'homeassistant/sensor/victron_{ser}_{labelc}/config',
|
||||||
|
json.dumps(config),
|
||||||
|
retain=True)
|
||||||
|
|
||||||
|
def export(self, fields: dict) -> None:
|
||||||
|
ser = fields[defs.SER.label]
|
||||||
|
if self._last is None:
|
||||||
|
self._config(ser, fields)
|
||||||
|
elif (time.time() - self._last) < 60:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last = time.time()
|
||||||
|
|
||||||
|
for label, value in fields.items():
|
||||||
|
name = label.replace('#', '').lower()
|
||||||
|
topic = f'tele/victron_{ser}/{name}'
|
||||||
|
|
||||||
|
if isinstance(value, pint.Quantity):
|
||||||
|
payload = round(value.m, 3)
|
||||||
|
elif isinstance(value, enum.IntEnum):
|
||||||
|
payload = int(value)
|
||||||
|
else:
|
||||||
|
payload = str(value)
|
||||||
|
|
||||||
|
self._client.publish(topic, payload)
|
429
vedirect/phoenix.py
Normal file
429
vedirect/phoenix.py
Normal file
|
@ -0,0 +1,429 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import dataclasses
|
||||||
|
from typing import Optional
|
||||||
|
import enum
|
||||||
|
|
||||||
|
import pint
|
||||||
|
|
||||||
|
from .hex import Register, Mode, Field
|
||||||
|
|
||||||
|
_ureg = pint.get_application_registry()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Product:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_IDS = (
|
||||||
|
Product(0xA201, 'Phoenix Inverter 12V 250VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA202, 'Phoenix Inverter 24V 250VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA204, 'Phoenix Inverter 48V 250VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA211, 'Phoenix Inverter 12V 375VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA212, 'Phoenix Inverter 24V 375VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA214, 'Phoenix Inverter 48V 375VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA221, 'Phoenix Inverter 12V 500VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA222, 'Phoenix Inverter 24V 500VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA224, 'Phoenix Inverter 48V 500VA 230Vac', 'obsolete (32k)'),
|
||||||
|
Product(0xA231, 'Phoenix Inverter 12V 250VA 230Vac 64k'),
|
||||||
|
Product(0xA232, 'Phoenix Inverter 24V 250VA 230Vac 64k'),
|
||||||
|
Product(0xA234, 'Phoenix Inverter 48V 250VA 230Vac 64k'),
|
||||||
|
Product(0xA239, 'Phoenix Inverter 12V 250VA 120Vac 64k'),
|
||||||
|
Product(0xA23A, 'Phoenix Inverter 24V 250VA 120Vac 64k'),
|
||||||
|
Product(0xA23C, 'Phoenix Inverter 48V 250VA 120Vac 64k'),
|
||||||
|
Product(0xA241, 'Phoenix Inverter 12V 375VA 230Vac 64k'),
|
||||||
|
Product(0xA242, 'Phoenix Inverter 24V 375VA 230Vac 64k'),
|
||||||
|
Product(0xA244, 'Phoenix Inverter 48V 375VA 230Vac 64k'),
|
||||||
|
Product(0xA249, 'Phoenix Inverter 12V 375VA 120Vac 64k'),
|
||||||
|
Product(0xA24A, 'Phoenix Inverter 24V 375VA 120Vac 64k'),
|
||||||
|
Product(0xA24C, 'Phoenix Inverter 48V 375VA 120Vac 64k'),
|
||||||
|
Product(0xA251, 'Phoenix Inverter 12V 500VA 230Vac 64k'),
|
||||||
|
Product(0xA252, 'Phoenix Inverter 24V 500VA 230Vac 64k'),
|
||||||
|
Product(0xA254, 'Phoenix Inverter 48V 500VA 230Vac 64k'),
|
||||||
|
Product(0xA259, 'Phoenix Inverter 12V 500VA 120Vac 64k'),
|
||||||
|
Product(0xA25A, 'Phoenix Inverter 24V 500VA 120Vac 64k'),
|
||||||
|
Product(0xA25C, 'Phoenix Inverter 48V 500VA 120Vac 64k'),
|
||||||
|
Product(0xA261, 'Phoenix Inverter 12V 800VA 230Vac 64k'),
|
||||||
|
Product(0xA262, 'Phoenix Inverter 24V 800VA 230Vac 64k'),
|
||||||
|
Product(0xA264, 'Phoenix Inverter 48V 800VA 230Vac 64k'),
|
||||||
|
Product(0xA269, 'Phoenix Inverter 12V 800VA 120Vac 64k'),
|
||||||
|
Product(0xA26A, 'Phoenix Inverter 24V 800VA 120Vac 64k'),
|
||||||
|
Product(0xA26C, 'Phoenix Inverter 48V 800VA 120Vac 64k'),
|
||||||
|
Product(0xA271, 'Phoenix Inverter 12V 1200VA 230Vac 64k'),
|
||||||
|
Product(0xA272, 'Phoenix Inverter 24V 1200VA 230Vac 64k'),
|
||||||
|
Product(0xA274, 'Phoenix Inverter 48V 1200VA 230Vac 64k'),
|
||||||
|
Product(0xA279, 'Phoenix Inverter 12V 1200VA 120Vac 64k'),
|
||||||
|
Product(0xA27A, 'Phoenix Inverter 24V 1200VA 120Vac 64k'),
|
||||||
|
Product(0xA27C, 'Phoenix Inverter 48V 1200VA 120Vac 64k'),
|
||||||
|
Product(0xA281, 'Phoenix Inverter Smart 12V 1600VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA282, 'Phoenix Inverter Smart 24V 1600VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA284, 'Phoenix Inverter Smart 48V 1600VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA291, 'Phoenix Inverter Smart 12V 2000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA292, 'Phoenix Inverter Smart 24V 2000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA294, 'Phoenix Inverter Smart 48V 2000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA2A1, 'Phoenix Inverter Smart 12V 3000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA2A2, 'Phoenix Inverter Smart 24V 3000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA2A4, 'Phoenix Inverter Smart 48V 3000VA 230Vac 64k',
|
||||||
|
'Smart, integrated Bluetooth'),
|
||||||
|
Product(0xA2E1, 'Phoenix Inverter 12V 800VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A261)'),
|
||||||
|
Product(0xA2E2, 'Phoenix Inverter 24V 800VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A262)'),
|
||||||
|
Product(0xA2E4, 'Phoenix Inverter 48V 800VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A264)'),
|
||||||
|
Product(0xA2E9, 'Phoenix Inverter 12V 800VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A269)'),
|
||||||
|
Product(0xA2EA, 'Phoenix Inverter 24V 800VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A26A)'),
|
||||||
|
Product(0xA2EC, 'Phoenix Inverter 48V 800VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A26C)'),
|
||||||
|
Product(0xA2F1, 'Phoenix Inverter 12V 1200VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A271)'),
|
||||||
|
Product(0xA2F2, 'Phoenix Inverter 24V 1200VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A272)'),
|
||||||
|
Product(0xA2F4, 'Phoenix Inverter 48V 1200VA 230Vac 64k HS',
|
||||||
|
'redesign (replaces A274)'),
|
||||||
|
Product(0xA2F9, 'Phoenix Inverter 12V 1200VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A279)'),
|
||||||
|
Product(0xA2FA, 'Phoenix Inverter 24V 1200VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A27A)'),
|
||||||
|
Product(0xA2FC, 'Phoenix Inverter 48V 1200VA 120Vac 64k HS',
|
||||||
|
'redesign (replaces A27C)'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# NVM registers
|
||||||
|
INV_NVM_COMMAND = Register(0xEB99, 'INV_NVM_COMMAND', 'B', Mode.W)
|
||||||
|
RESTORE_DEFAULT = Register(0x0004, 'RESTORE_DEFAULT', '-', Mode.W)
|
||||||
|
|
||||||
|
# class NVMCommand(enum.IntEnum):
|
||||||
|
# # 1,NvmSave,Save current user settings to NVM,,,,
|
||||||
|
# # 2,NvmRevert,Cancel modified settings.,,,,
|
||||||
|
# # ,,Load most recent saved user settings.,,,,
|
||||||
|
# # 3,NvmBackup,Undo last save. Load second last time saved settings.,,,,
|
||||||
|
# # 4,NvmDefault,Load the factory default values.,,,,
|
||||||
|
|
||||||
|
|
||||||
|
# Product information registers
|
||||||
|
class Capabilities(enum.IntFlag):
|
||||||
|
# 8,Remote input available
|
||||||
|
REMOTE_INPUT_AVAILABLE = 1 << 8
|
||||||
|
# 17,Build in user-relay available
|
||||||
|
# TYPO
|
||||||
|
BUILD_IN_USER_RELAY_AVAILABLE = 1 << 17
|
||||||
|
# 28,Support of device hibernation
|
||||||
|
SUPPORT_OF_DEVICE_HIBERNATION = 1 << 28
|
||||||
|
# 29,Improved load current measurement
|
||||||
|
IMPROVED_LOAD_CURRENT_MEASUREMENT = 1 << 29
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_ID = Register(0x0100, 'PRODUCT_ID', 'xHx', Mode.R)
|
||||||
|
PRODUCT_REVISION = Register(0x0101, 'PRODUCT_REVISION', 'xH', Mode.R, None,
|
||||||
|
None)
|
||||||
|
APP_VER = Register(0x0102, 'APP_VER', 'xH', Mode.R)
|
||||||
|
SERIAL_NUMBER = Register(0x010A, 'SERIAL_NUMBER', 'S', Mode.R)
|
||||||
|
MODEL_NAME = Register(0x010B, 'MODEL_NAME', 'S', Mode.R)
|
||||||
|
AC_OUT_RATED_POWER = Register(0x2203, 'AC_OUT_RATED_POWER', 'h', Mode.R, 1,
|
||||||
|
'VA')
|
||||||
|
# Typo.
|
||||||
|
CAPABILITIES = Register(0x0140, 'CAPABILITIES', 'I', Mode.R, None,
|
||||||
|
Capabilities)
|
||||||
|
CAPABILITIES_BLE = Register(0x0150, 'CAPABILITIES_BLE', 'I', Mode.RW)
|
||||||
|
AC_OUT_NOM_VOLTAGE = Register(0x2202, 'AC_OUT_NOM_VOLTAGE', 'B', Mode.R, 1,
|
||||||
|
'V')
|
||||||
|
BAT_VOLTAGE = Register(0xEDEF, 'BAT_VOLTAGE', 'B', Mode.R, 1, 'V')
|
||||||
|
|
||||||
|
|
||||||
|
# # Generic device status registers
|
||||||
|
class DeviceState(enum.IntEnum):
|
||||||
|
# Off,Not inverting. When due to a protection the inverter will
|
||||||
|
# automatically start again when the cause is solved.
|
||||||
|
OFF = 0
|
||||||
|
# Low Power,Eco load search active
|
||||||
|
LOW_POWER = 1
|
||||||
|
# Fault,Not inverting due to a fatal active protection. A turn OFF-ON cycle
|
||||||
|
# is required to enable the device again.
|
||||||
|
FAULT = 2
|
||||||
|
# Inverting,Normal operating
|
||||||
|
INVERTING = 9
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceOffReason(enum.IntFlag):
|
||||||
|
NONE = 0
|
||||||
|
# 0,No input power (will also cause a battery alarm),
|
||||||
|
NO_INPUT_POWER = 1 << 0
|
||||||
|
# 2,Soft power button or SW controlled (VE.Direct or Bluetooth),
|
||||||
|
SOFT_POWER_BUTTON_OR_SW_CONTROLLED = 1 << 2
|
||||||
|
# 3,HW remote input connector,
|
||||||
|
HW_REMOTE_INPUT_CONNECTOR = 1 << 3
|
||||||
|
# 4,Internal reason (see alarm reason for more info),
|
||||||
|
INTERNAL_REASON = 1 << 4
|
||||||
|
# 5,"PayGo, out of credit, need token",
|
||||||
|
PAYGO_OUT_OF_CREDIT_NEED_TOKEN = 1 << 5
|
||||||
|
|
||||||
|
|
||||||
|
class WarningReason(enum.IntFlag):
|
||||||
|
NONE = 0
|
||||||
|
# 0,Low battery voltage,
|
||||||
|
LOW_BATTERY_VOLTAGE = 1 << 0
|
||||||
|
# 1,High battery voltage,
|
||||||
|
HIGH_BATTERY_VOLTAGE = 1 << 1
|
||||||
|
# 5,Low temperature,
|
||||||
|
LOW_TEMPERATURE = 1 << 5
|
||||||
|
# 6,High temperature,
|
||||||
|
HIGH_TEMPERATURE = 1 << 6
|
||||||
|
# 8,Overload,
|
||||||
|
OVERLOAD = 1 << 8
|
||||||
|
# 9,Poor DC connection,
|
||||||
|
POOR_DC_CONNECTION = 1 << 9
|
||||||
|
# 10,Low AC-output voltage,
|
||||||
|
LOW_AC_OUTPUT_VOLTAGE = 1 << 10
|
||||||
|
# 11,High AC-output voltage,
|
||||||
|
HIGH_AC_OUTPUT_VOLTAGE = 1 << 11
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_STATE = Register(0x0201, 'DEVICE_STATE', 'B', Mode.R, None, DeviceState)
|
||||||
|
DEVICE_OFF_REASON = Register(0x0207, 'DEVICE_OFF_REASON', 'I', Mode.R, None,
|
||||||
|
DeviceOffReason)
|
||||||
|
WARNING_REASON = Register(0x031C, 'WARNING_REASON', 'H', Mode.R, None,
|
||||||
|
WarningReason)
|
||||||
|
ALARM_REASON = Register(0x031E, 'ALARM_REASON', 'H', Mode.R, None,
|
||||||
|
WarningReason)
|
||||||
|
|
||||||
|
|
||||||
|
# Generic device control registers
|
||||||
|
class DeviceMode(enum.IntEnum):
|
||||||
|
# 2,Inverter On,,,,,
|
||||||
|
INVERTER_ON = 2
|
||||||
|
# 3,Device On (multi compliant),1),,,,
|
||||||
|
DEVICE_ON = 3
|
||||||
|
# 4,Device Off,VE.Direct is still enabled,,,,
|
||||||
|
DEVICE_OFF = 4
|
||||||
|
# 5,Eco mode,,,,,
|
||||||
|
ECO_MODE = 5
|
||||||
|
# 0xFD,Hibernate,VE.Direct is affected 2),,,,
|
||||||
|
HIBERNATE = 0xFD
|
||||||
|
|
||||||
|
|
||||||
|
BLE_MODE = Register(0x0090, 'BLE_MODE', 'B', Mode.RW)
|
||||||
|
DEVICE_MODE = Register(0x0200, 'DEVICE_MODE', 'B', Mode.RW, None, DeviceMode)
|
||||||
|
SETTINGS_CHANGED = Register(0xEC41, 'SETTINGS_CHANGED', 'I', Mode.RW)
|
||||||
|
|
||||||
|
# Inverter operation registers
|
||||||
|
HISTORY_TIME = Register(0x1040, 'HISTORY_TIME', 'I', Mode.R, 1, _ureg.second)
|
||||||
|
HISTORY_ENERGY = Register(0x1041, 'HISTORY_ENERGY', 'I', Mode.R, 0.01 * 1000,
|
||||||
|
_ureg.watthour)
|
||||||
|
AC_OUT_CURRENT = Register(0x2201, 'AC_OUT_CURRENT', 'h', Mode.R, 0.1,
|
||||||
|
_ureg.amp)
|
||||||
|
AC_OUT_VOLTAGE = Register(0x2200, 'AC_OUT_VOLTAGE', 'h', Mode.R, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
AC_OUT_APPARENT_POWER = Register(0x2205, 'AC_OUT_APPARENT_POWER', 'i', Mode.R,
|
||||||
|
1, _ureg.watt)
|
||||||
|
INV_LOOP_GET_IINV = Register(0xEB4E, 'INV_LOOP_GET_IINV', 'h', Mode.R, 0.001,
|
||||||
|
_ureg.amp)
|
||||||
|
DC_CHANNEL1_VOLTAGE = Register(0xED8D, 'DC_CHANNEL1_VOLTAGE', 'h', Mode.R,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
|
||||||
|
# User AC-out control registers
|
||||||
|
AC_OUT_VOLTAGE_SETPOINT = Register(0x0230, 'AC_OUT_VOLTAGE_SETPOINT', 'H',
|
||||||
|
Mode.W, 0.01, _ureg.volt)
|
||||||
|
AC_OUT_VOLTAGE_SETPOINT_MIN = Register(0x0231, 'AC_OUT_VOLTAGE_SETPOINT_MIN',
|
||||||
|
'H', Mode.R, 0.01, _ureg.volt)
|
||||||
|
AC_OUT_VOLTAGE_SETPOINT_MAX = Register(0x0232, 'AC_OUT_VOLTAGE_SETPOINT_MAX',
|
||||||
|
'H', Mode.R, 0.01, _ureg.volt)
|
||||||
|
AC_LOAD_SENSE_POWER_THRESHOLD = Register(0x2206,
|
||||||
|
'AC_LOAD_SENSE_POWER_THRESHOLD', 'H',
|
||||||
|
Mode.W, None, _ureg.watt)
|
||||||
|
AC_LOAD_SENSE_POWER_CLEAR = Register(0x2207, 'AC_LOAD_SENSE_POWER_CLEAR', 'H',
|
||||||
|
Mode.W, None, _ureg.watt)
|
||||||
|
INV_WAVE_SET50HZ_NOT60HZ = Register(0xEB03, 'INV_WAVE_SET50HZ_NOT60HZ', 'B',
|
||||||
|
Mode.W)
|
||||||
|
INV_OPER_ECO_MODE_INV_MIN = Register(0xEB04, 'INV_OPER_ECO_MODE_INV_MIN', 'h',
|
||||||
|
Mode.W, 0.001, _ureg.A)
|
||||||
|
INV_OPER_ECO_MODE_RETRY_TIME = Register(0xEB06, 'INV_OPER_ECO_MODE_RETRY_TIME',
|
||||||
|
'B', Mode.W, 0.25, _ureg.second)
|
||||||
|
INV_OPER_ECO_LOAD_DETECT_PERIODS = Register(
|
||||||
|
0xEB10, 'INV_OPER_ECO_LOAD_DETECT_PERIODS', 'B', Mode.W, 0.02, _ureg.hertz)
|
||||||
|
|
||||||
|
# User battery control registers
|
||||||
|
SHUTDOWN_LOW_VOLTAGE_SET = Register(0x2210, 'SHUTDOWN_LOW_VOLTAGE_SET', 'H',
|
||||||
|
Mode.W, 0.01, _ureg.volt)
|
||||||
|
ALARM_LOW_VOLTAGE_SET = Register(0x0320, 'ALARM_LOW_VOLTAGE_SET', 'H', Mode.W,
|
||||||
|
0.01, _ureg.volt)
|
||||||
|
ALARM_LOW_VOLTAGE_CLEAR = Register(0x0321, 'ALARM_LOW_VOLTAGE_CLEAR', 'H',
|
||||||
|
Mode.W, 0.01, _ureg.volt)
|
||||||
|
VOLTAGE_RANGE_MIN = Register(0x2211, 'VOLTAGE_RANGE_MIN', 'H', Mode.R, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
VOLTAGE_RANGE_MAX = Register(0x2212, 'VOLTAGE_RANGE_MAX', 'H', Mode.R, 0.01,
|
||||||
|
_ureg.volt)
|
||||||
|
# Datasheet says H.
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_ENABLE = Register(0xEBBA,
|
||||||
|
'INV_PROT_UBAT_DYN_CUTOFF_ENABLE',
|
||||||
|
'B', Mode.W)
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR = Register(0xEBB7,
|
||||||
|
'INV_PROT_UBAT_DYN_CUTOFF_FACTOR',
|
||||||
|
'H', Mode.W)
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR2000 = Register(
|
||||||
|
0xEBB5, 'INV_PROT_UBAT_DYN_CUTOFF_FACTOR2000', 'H', Mode.W)
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR250 = Register(
|
||||||
|
0xEBB3, 'INV_PROT_UBAT_DYN_CUTOFF_FACTOR250', 'H', Mode.W)
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR5 = Register(
|
||||||
|
0xEBB2, 'INV_PROT_UBAT_DYN_CUTOFF_FACTOR5', 'H', Mode.W)
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_VOLTAGE = Register(
|
||||||
|
0xEBB1, 'INV_PROT_UBAT_DYN_CUTOFF_VOLTAGE', 'H', Mode.R, 0.001, _ureg.volt)
|
||||||
|
|
||||||
|
|
||||||
|
# Relay control registers
|
||||||
|
class RelayMode(enum.IntEnum):
|
||||||
|
# Normal operation. On during normal operation (warnings are ignored).
|
||||||
|
NORMAL_OPERATION = 4
|
||||||
|
# Warnings and alarms. Off when a warning or alarm is active (inverter on).
|
||||||
|
WARNINGS_AND_ALARMS = 0
|
||||||
|
# Battery low. Off when a low battery warning or alarm is active.
|
||||||
|
BATTERY_LOW = 5
|
||||||
|
# External fan. On when the internal fan is on.
|
||||||
|
EXTERNAL_FAN = 6
|
||||||
|
# Disabled relay. Always Off.
|
||||||
|
DISABLED_RELAY = 3
|
||||||
|
# Remote. Controlled by writing to RELAY_CONTROL (0x034E).
|
||||||
|
REMOTE = 2
|
||||||
|
|
||||||
|
|
||||||
|
RELAY_CONTROL = Register(0x034E, 'RELAY_CONTROL', 'B', Mode.RW)
|
||||||
|
RELAY_MODE = Register(0x034F, 'RELAY_MODE', 'B', Mode.W, None, RelayMode)
|
||||||
|
|
||||||
|
|
||||||
|
class NVMCommand(enum.IntEnum):
|
||||||
|
NONE = 0
|
||||||
|
SAVE = 1
|
||||||
|
REVERT = 2
|
||||||
|
LOAD_BACKUP = 3
|
||||||
|
LOAD_DEFAULTS = 4
|
||||||
|
|
||||||
|
|
||||||
|
INV_NVM_COMMAND = Register(0xEB99, 'INV_NVM_COMMAND', 'B', Mode.W, None,
|
||||||
|
NVMCommand)
|
||||||
|
|
||||||
|
ID = int
|
||||||
|
|
||||||
|
REGISTERS = {
|
||||||
|
ID(r.id): r
|
||||||
|
for r in globals().values() if isinstance(r, Register)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NVM:
|
||||||
|
inv_nvm_command: int = Field(INV_NVM_COMMAND)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Information:
|
||||||
|
product_id: int = Field(PRODUCT_ID)
|
||||||
|
product_revision: int = Field(PRODUCT_REVISION)
|
||||||
|
app_ver: int = Field(APP_VER)
|
||||||
|
serial_number: str = Field(SERIAL_NUMBER)
|
||||||
|
model_name: str = Field(MODEL_NAME)
|
||||||
|
ac_out_rated_power: float = Field(AC_OUT_RATED_POWER)
|
||||||
|
capabilities: Capabilities = Field(CAPABILITIES)
|
||||||
|
# capabilities_ble: float = Field(CAPABILITIES_BLE)
|
||||||
|
ac_out_nom_voltage: float = Field(AC_OUT_NOM_VOLTAGE)
|
||||||
|
bat_voltage: float = Field(BAT_VOLTAGE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Status:
|
||||||
|
device_state: DeviceState = Field(DEVICE_STATE)
|
||||||
|
device_off_reason: DeviceOffReason = Field(DEVICE_OFF_REASON)
|
||||||
|
warning_reason: WarningReason = Field(WARNING_REASON)
|
||||||
|
alarm_reason: int = Field(ALARM_REASON)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceControl:
|
||||||
|
# ble_mode: float = Field(BLE_MODE)
|
||||||
|
device_mode: DeviceMode = Field(DEVICE_MODE)
|
||||||
|
settings_changed: int = Field(SETTINGS_CHANGED)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Inverter:
|
||||||
|
history_time: float = Field(HISTORY_TIME)
|
||||||
|
history_energy: float = Field(HISTORY_ENERGY)
|
||||||
|
ac_out_current: float = Field(AC_OUT_CURRENT)
|
||||||
|
ac_out_voltage: float = Field(AC_OUT_VOLTAGE)
|
||||||
|
# ac_out_apparent_power: float = Field(AC_OUT_APPARENT_POWER)
|
||||||
|
inv_loop_get_iinv: float = Field(INV_LOOP_GET_IINV)
|
||||||
|
dc_channel1_voltage: float = Field(DC_CHANNEL1_VOLTAGE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ACControl:
|
||||||
|
ac_out_voltage_setpoint: float = Field(AC_OUT_VOLTAGE_SETPOINT)
|
||||||
|
ac_out_voltage_setpoint_min: float = Field(AC_OUT_VOLTAGE_SETPOINT_MIN)
|
||||||
|
ac_out_voltage_setpoint_max: float = Field(AC_OUT_VOLTAGE_SETPOINT_MAX)
|
||||||
|
# ac_load_sense_power_threshold: float = Field(
|
||||||
|
# AC_LOAD_SENSE_POWER_THRESHOLD)
|
||||||
|
# ac_load_sense_power_clear: float = Field(
|
||||||
|
# AC_LOAD_SENSE_POWER_CLEAR)
|
||||||
|
inv_wave_set50hz_not60hz: int = Field(INV_WAVE_SET50HZ_NOT60HZ)
|
||||||
|
inv_oper_eco_mode_inv_min: float = Field(INV_OPER_ECO_MODE_INV_MIN)
|
||||||
|
inv_oper_eco_mode_retry_time: float = Field(INV_OPER_ECO_MODE_RETRY_TIME)
|
||||||
|
inv_oper_eco_load_detect_periods: float = Field(
|
||||||
|
INV_OPER_ECO_LOAD_DETECT_PERIODS)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatteryControl:
|
||||||
|
shutdown_low_voltage_set: float = Field(SHUTDOWN_LOW_VOLTAGE_SET)
|
||||||
|
alarm_low_voltage_set: float = Field(ALARM_LOW_VOLTAGE_SET)
|
||||||
|
alarm_low_voltage_clear: float = Field(ALARM_LOW_VOLTAGE_CLEAR)
|
||||||
|
voltage_range_min: float = Field(VOLTAGE_RANGE_MIN)
|
||||||
|
voltage_range_max: float = Field(VOLTAGE_RANGE_MAX)
|
||||||
|
inv_prot_ubat_dyn_cutoff_enable: int = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_ENABLE)
|
||||||
|
inv_prot_ubat_dyn_cutoff_factor: int = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR)
|
||||||
|
inv_prot_ubat_dyn_cutoff_factor2000: int = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR2000)
|
||||||
|
inv_prot_ubat_dyn_cutoff_factor250: int = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR250)
|
||||||
|
inv_prot_ubat_dyn_cutoff_factor5: int = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_FACTOR5)
|
||||||
|
inv_prot_ubat_dyn_cutoff_voltage: float = Field(
|
||||||
|
INV_PROT_UBAT_DYN_CUTOFF_VOLTAGE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RelayControl:
|
||||||
|
relay_control: float = Field(RELAY_CONTROL)
|
||||||
|
relay_mode: float = Field(RELAY_MODE)
|
||||||
|
|
||||||
|
|
||||||
|
GROUPS = (
|
||||||
|
Information,
|
||||||
|
Status,
|
||||||
|
DeviceControl,
|
||||||
|
Inverter,
|
||||||
|
ACControl,
|
||||||
|
BatteryControl,
|
||||||
|
NVM,
|
||||||
|
)
|
||||||
|
|
||||||
|
for group in GROUPS:
|
||||||
|
for field in dataclasses.fields(group):
|
||||||
|
r = field.metadata.get('vedirect.Register', None)
|
||||||
|
assert r is not None
|
||||||
|
if field.type == int:
|
||||||
|
assert r.scale is None, r
|
||||||
|
elif field.type == float:
|
||||||
|
assert r.scale is not None, r
|
|
@ -1,12 +1,25 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Exports fields as Prometheus gauges and enums."""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import serial
|
|
||||||
import prometheus_client
|
|
||||||
import pint
|
import pint
|
||||||
|
import prometheus_client
|
||||||
|
|
||||||
from . import defs
|
from . import defs
|
||||||
from . import text
|
|
||||||
|
|
||||||
_UNITS = {
|
_UNITS = {
|
||||||
'%': 'percent',
|
'%': 'percent',
|
||||||
|
@ -15,14 +28,32 @@ _UNITS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_enum(v):
|
class Filter:
|
||||||
|
def __init__(self):
|
||||||
|
self._acc = None
|
||||||
|
self._tau = 0.05
|
||||||
|
|
||||||
|
def step(self, v: float) -> float:
|
||||||
|
if self._acc is None:
|
||||||
|
self._acc = v
|
||||||
|
|
||||||
|
self._acc = self._acc * (1 - self._tau) + v * self._tau
|
||||||
|
return self._acc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_enum(v: object) -> bool:
|
||||||
try:
|
try:
|
||||||
return issubclass(v, enum.Enum)
|
return issubclass(v, enum.Enum)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _metrics(fields):
|
class Exporter:
|
||||||
|
def __init__(self):
|
||||||
|
self._metrics = None
|
||||||
|
self._filters = {}
|
||||||
|
|
||||||
|
def _config(self, fields):
|
||||||
metrics = {}
|
metrics = {}
|
||||||
labels = ['serial_number', 'product_id']
|
labels = ['serial_number', 'product_id']
|
||||||
|
|
||||||
|
@ -71,38 +102,29 @@ def _metrics(fields):
|
||||||
|
|
||||||
return metrics, updated, blocks
|
return metrics, updated, blocks
|
||||||
|
|
||||||
|
def export(self, fields):
|
||||||
|
if self._metrics is None:
|
||||||
|
self._metrics, self._updated, self._blocks = self._config(fields)
|
||||||
|
|
||||||
def _update(fields, metrics, updated, blocks):
|
|
||||||
ser = fields[defs.SER.label]
|
ser = fields[defs.SER.label]
|
||||||
pid = fields[defs.PID.label]
|
pid = fields[defs.PID.label]
|
||||||
updated.labels(ser, pid).set(time.time())
|
self._updated.labels(ser, pid).set(time.time())
|
||||||
blocks.labels(ser, pid).inc()
|
self._blocks.labels(ser, pid).inc()
|
||||||
|
|
||||||
for label, value in fields.items():
|
for label, value in fields.items():
|
||||||
gauge = metrics[label]
|
gauge = self._metrics[label]
|
||||||
if isinstance(value, pint.Quantity):
|
if isinstance(value, pint.Quantity):
|
||||||
gauge.labels(ser, pid).set(value.m)
|
f = self._filters.setdefault(label, Filter())
|
||||||
|
m = f.step(value.m)
|
||||||
|
gauge.labels(ser, pid).set(round(m, 3))
|
||||||
elif isinstance(gauge, prometheus_client.Info):
|
elif isinstance(gauge, prometheus_client.Info):
|
||||||
gauge.labels(ser,
|
gauge.labels(ser,
|
||||||
pid).info({label.lower().replace('#', ''): value})
|
pid).info({label.lower().replace('#', ''): value})
|
||||||
elif isinstance(gauge, prometheus_client.Enum):
|
elif isinstance(gauge, prometheus_client.Enum):
|
||||||
gauge.labels(ser, pid).state(value.name.lower())
|
gauge.labels(ser, pid).state(value.name.lower())
|
||||||
metrics[label + '_value'].labels(ser, pid).set(value.value)
|
self._metrics[label + '_value'].labels(ser,
|
||||||
|
pid).set(value.value)
|
||||||
elif isinstance(value, int):
|
elif isinstance(value, int):
|
||||||
gauge.labels(ser, pid).set(value)
|
gauge.labels(ser, pid).set(value)
|
||||||
else:
|
else:
|
||||||
print(repr(value))
|
print(repr(value))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
s = serial.Serial('/dev/ttyAMA4', 19200, timeout=0.7)
|
|
||||||
|
|
||||||
metrics, updated, blocks = _metrics(defs.FIELDS)
|
|
||||||
prometheus_client.start_http_server(7099)
|
|
||||||
|
|
||||||
for fields in text.parse(s):
|
|
||||||
_update(fields, metrics, updated, blocks)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
324
vedirect/schema.py
Normal file
324
vedirect/schema.py
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import enum
|
||||||
|
from typing import Callable, Union
|
||||||
|
|
||||||
|
import pint
|
||||||
|
|
||||||
|
_ureg = pint.UnitRegistry()
|
||||||
|
|
||||||
|
W = _ureg.watt
|
||||||
|
V = _ureg.volt
|
||||||
|
A = _ureg.amp
|
||||||
|
C = float # _ureg.degree_Celsius
|
||||||
|
K = _ureg.kelvin
|
||||||
|
Hours = _ureg.hour
|
||||||
|
mVK = float # _ureg.mvolt * 1e-3 / _ureg.kelvin
|
||||||
|
Percent = float
|
||||||
|
kWh = float
|
||||||
|
Minute = _ureg.minute
|
||||||
|
Unknown = int
|
||||||
|
|
||||||
|
|
||||||
|
class State(enum.IntEnum):
|
||||||
|
"""State is the state of operation. Sent in the `CS` field."""
|
||||||
|
# Off
|
||||||
|
OFF = 0
|
||||||
|
# Low power
|
||||||
|
LOW_POWER = 1
|
||||||
|
# Fault
|
||||||
|
FAULT = 2
|
||||||
|
# Bulk
|
||||||
|
BULK = 3
|
||||||
|
# Absorption
|
||||||
|
ABSORPTION = 4
|
||||||
|
# Float
|
||||||
|
FLOAT = 5
|
||||||
|
# Storage
|
||||||
|
STORAGE = 6
|
||||||
|
# Equalize (manual)
|
||||||
|
EQUALIZE_MANUAL = 7
|
||||||
|
# Inverting
|
||||||
|
INVERTING = 9
|
||||||
|
# Power supply
|
||||||
|
POWER_SUPPLY = 11
|
||||||
|
# Starting-up
|
||||||
|
STARTING_UP = 245
|
||||||
|
# Repeated absorption
|
||||||
|
REPEATED_ABSORPTION = 246
|
||||||
|
# Auto equalize / Recondition
|
||||||
|
AUTO_EQUALIZE_RECONDITION = 247
|
||||||
|
# BatterySafe
|
||||||
|
BATTERYSAFE = 248
|
||||||
|
# External Control
|
||||||
|
EXTERNAL_CONTROL = 252
|
||||||
|
|
||||||
|
|
||||||
|
class Capabilities(enum.IntFlag):
|
||||||
|
LOAD_OUTPUT_PRESENT = 1 << 0
|
||||||
|
ROTARY_ENCODER_PRESENT = 1 << 1
|
||||||
|
HISTORY_SUPPORT = 1 << 2
|
||||||
|
BATTERYSAFE_MODE = 1 << 3
|
||||||
|
ADAPTIVE_MODE = 1 << 4
|
||||||
|
MANUAL_EQUALISE = 1 << 5
|
||||||
|
AUTOMATIC_EQUALISE = 1 << 6
|
||||||
|
STORAGE_MODE = 1 << 7
|
||||||
|
REMOTE_ON_OFF_VIA_RX_PIN = 1 << 8
|
||||||
|
SOLAR_TIMER_STREETLIGHTING = 1 << 9
|
||||||
|
ALTERNATIVE_VEDIRECT_TX_PIN_FUNCTION = 1 << 10
|
||||||
|
USER_DEFINED_LOAD_SWITCH = 1 << 11
|
||||||
|
LOAD_CURRENT_IN_TEXT_PROTOCOL = 1 << 12
|
||||||
|
PANEL_CURRENT = 1 << 13
|
||||||
|
BMS_SUPPORT = 1 << 14
|
||||||
|
EXTERNAL_CONTROL_SUPPORT = 1 << 15
|
||||||
|
PARALLEL_CHARGING_SUPPORT = 1 << 16
|
||||||
|
ALARM_RELAY = 1 << 17
|
||||||
|
ALTERNATIVE_VEDIRECT_RX_PIN_FUNCTION = 1 << 18
|
||||||
|
VIRTUAL_LOAD_OUTPUT = 1 << 19
|
||||||
|
VIRTUAL_RELAY = 1 << 20
|
||||||
|
PLUGIN_DISPLAY_SUPPORT = 1 << 21
|
||||||
|
UNDEFINED_24 = 1 << 24
|
||||||
|
LOAD_AUTOMATIC_ENERGY_SELECTOR = 1 << 25
|
||||||
|
BATTERY_TEST = 1 << 26
|
||||||
|
PAYGO_SUPPORT = 1 << 27
|
||||||
|
|
||||||
|
|
||||||
|
class Err(enum.IntEnum):
|
||||||
|
"""Err is the error code of the device. Sent in the `ERR` field."""
|
||||||
|
# No error
|
||||||
|
NO_ERROR = 0
|
||||||
|
# Battery voltage too high
|
||||||
|
BATTERY_VOLTAGE_TOO_HIGH = 2
|
||||||
|
# Charger temperature too high
|
||||||
|
CHARGER_TEMPERATURE_TOO_HIGH = 17
|
||||||
|
# Charger over current
|
||||||
|
CHARGER_OVER_CURRENT = 18
|
||||||
|
# Charger current reversed
|
||||||
|
CHARGER_CURRENT_REVERSED = 19
|
||||||
|
# Bulk time limit exceeded
|
||||||
|
BULK_TIME_LIMIT_EXCEEDED = 20
|
||||||
|
# Current sensor issue (sensor bias/sensor broken)
|
||||||
|
CURRENT_SENSOR_ISSUE = 21
|
||||||
|
# Terminals overheated
|
||||||
|
TERMINALS_OVERHEATED = 26
|
||||||
|
# Input voltage too high (solar panel)
|
||||||
|
INPUT_VOLTAGE_TOO_HIGH_SOLAR_PANEL = 33
|
||||||
|
# Input current too high (solar panel)
|
||||||
|
INPUT_CURRENT_TOO_HIGH_SOLAR_PANEL = 34
|
||||||
|
# Input shutdown (due to excessive battery voltage)
|
||||||
|
INPUT_SHUTDOWN = 38
|
||||||
|
# Factory calibration data lost
|
||||||
|
FACTORY_CALIBRATION_DATA_LOST = 116
|
||||||
|
# Invalid/incompatible firmware
|
||||||
|
INVALID_OR_INCOMPATIBLE_FIRMWARE = 117
|
||||||
|
# User settings invalid
|
||||||
|
USER_SETTINGS_INVALID = 119
|
||||||
|
|
||||||
|
|
||||||
|
class OffReason(enum.IntFlag):
|
||||||
|
NO_INPUT_POWER = 1 << 0
|
||||||
|
RESERVED = 1 << 1
|
||||||
|
SOFT_POWER_SWITCH = 1 << 2
|
||||||
|
REMOTE_INPUT = 1 << 3
|
||||||
|
INTERNAL_REASOn = 1 << 4
|
||||||
|
PAY_AS_YOU_GO_OUT_OF_CREDIT = 1 << 5
|
||||||
|
BMS_SHUTDOWN = 1 << 6
|
||||||
|
RESERVED_2 = 1 << 7
|
||||||
|
|
||||||
|
|
||||||
|
class AdditionalChargerState(enum.IntFlag):
|
||||||
|
SAFE_MODE_ACTIVE = 1 << 0
|
||||||
|
AUTOMATIC_EQUALISATION_ACTIVE = 1 << 1
|
||||||
|
TEMPERATURE_DIMMING_ACTIVeE = 1 << 4
|
||||||
|
INPUT_CURRENT_DIMMING_ACTIVE = 1 << 6
|
||||||
|
|
||||||
|
|
||||||
|
class LoadOutputOffReason(enum.IntFlag):
|
||||||
|
BATTERY_LOW = 1 << 0
|
||||||
|
SHORT_CIRCUIT = 1 << 1
|
||||||
|
TIMER_PROGRAM = 1 << 2
|
||||||
|
REMOTE_INPUT = 1 << 3
|
||||||
|
PAY_AS_YOU_GO_OUT_OF_CREDIT = 1 << 4
|
||||||
|
RESERVED = 1 << 5
|
||||||
|
RESERVED_2 = 1 << 6
|
||||||
|
DEVICE_STARTING_UP = 1 << 7
|
||||||
|
|
||||||
|
|
||||||
|
class LoadOutputControl(enum.IntEnum):
|
||||||
|
LOAD_OUTPUT_OFF = 0
|
||||||
|
AUTOMATIC_CONTROL_BATTERYLIFE = 1
|
||||||
|
ALTERNATIVE_CONTROL_1 = 2
|
||||||
|
ALTERNATIVE_CONTROL_2 = 3
|
||||||
|
LOAD_OUTPUT_ON = 4
|
||||||
|
USER_DEFINED_SETTINGS_1 = 5
|
||||||
|
USER_DEFINED_SETTINGS_2 = 6
|
||||||
|
AUTOMATIC_ENERGY_SELECTOR = 7
|
||||||
|
|
||||||
|
|
||||||
|
class RelayMode(enum.IntEnum):
|
||||||
|
RELAY_ALWAYS_OFF = 0
|
||||||
|
PANEL_VOLTAGE_HIGH = 1
|
||||||
|
INTERNAL_TEMPERATURE_HIGH = 2
|
||||||
|
BATTERY_VOLTAGE_TOO_LOW = 3
|
||||||
|
EQUALISATION_ACTIVE = 4
|
||||||
|
ERROR_CONDITION_PRESENT = 5
|
||||||
|
INTERNAL_TEMPERATURE_LOW = 6
|
||||||
|
BATTERY_VOLTAGE_TOO_HIGH = 7
|
||||||
|
CHARGER_IN_FLOAT_OR_STORAGE = 8
|
||||||
|
DAY_DETECTION = 9
|
||||||
|
LOAD_CONTROL = 10
|
||||||
|
|
||||||
|
|
||||||
|
class TXPortMode(enum.IntEnum):
|
||||||
|
NORMAL_VEDIRECT_COMMUNICATION = 0
|
||||||
|
PULSE_ON_HARVEST = 1
|
||||||
|
LIGHTING_CONTROL_PWM_NORMAL = 2
|
||||||
|
LIGHTING_CONTROL_PWM_INVERTED = 3
|
||||||
|
VIRTUAL_LOAD_OUTPUT = 4
|
||||||
|
|
||||||
|
|
||||||
|
class RXPortMode(enum.IntEnum):
|
||||||
|
REMOTE_ON_OFF = 0
|
||||||
|
LOAD_OUTPUT_CONFIGURATION = 1
|
||||||
|
LOAD_OUTPUT_ON_OFF_REMOTE_CONTROL_INVERTED = 2
|
||||||
|
LOAD_OUTPUT_ON_OFF_REMOTE_CONTROL = 3
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerMode(enum.IntEnum):
|
||||||
|
OFF = 0
|
||||||
|
LIMITED = 1
|
||||||
|
MPPT = 2
|
||||||
|
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
@dataclass
|
||||||
|
class Register:
|
||||||
|
"""Defines a single register on the controller."""
|
||||||
|
command: int
|
||||||
|
scale: Union[float, Callable, None]
|
||||||
|
size: Union[str, Callable]
|
||||||
|
|
||||||
|
|
||||||
|
class Group:
|
||||||
|
"""Tags a group of registers."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Product(Group):
|
||||||
|
id: int = Register(0x0100, None, 'I')
|
||||||
|
group_id: int = Register(0x0104, None, 'B')
|
||||||
|
serial_number: str = Register(0x010A, None, str)
|
||||||
|
model_name: str = Register(0x010B, None, str)
|
||||||
|
capabilities: Capabilities = Register(0x0140, Capabilities, 'I')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device(Group):
|
||||||
|
mode: int = Register(0x200, None, 'B')
|
||||||
|
state: State = Register(0x201, State, 'B')
|
||||||
|
remote_control_used: Unknown = Register(0x202, None, 'I')
|
||||||
|
off_reason: OffReason = Register(0x0207, OffReason, 'I')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Load(Group):
|
||||||
|
current: A = Register(0xEDAD, 0.1, 'H')
|
||||||
|
offset_voltage: V = Register(0xEDAC, 0.01, 'B') # Spec says H.
|
||||||
|
output_control: LoadOutputControl = Register(0xEDAB, LoadOutputControl,
|
||||||
|
'B')
|
||||||
|
output_voltage: V = Register(0xEDA9, 0.01, 'H')
|
||||||
|
output_state: Unknown = Register(0xEDA8, None, 'B')
|
||||||
|
switch_high_level: V = Register(0xED9D, 0.01, 'H')
|
||||||
|
switch_low_level: V = Register(0xED9C, 0.01, 'H')
|
||||||
|
output_off_reason: LoadOutputOffReason = Register(0xED91,
|
||||||
|
LoadOutputOffReason, 'B')
|
||||||
|
aes_timer: Minute = Register(0xED90, 1, 'H')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Relay(Group):
|
||||||
|
relay_operation_mode: RelayMode = Register(0xEDD9, RelayMode, 'B')
|
||||||
|
battery_low_voltage_set: V = Register(0x0350, 0.01, 'H')
|
||||||
|
battery_low_voltage_clear: V = Register(0x0351, 0.01, 'H')
|
||||||
|
battery_high_voltage_set: V = Register(0x0352, 0.01, 'H')
|
||||||
|
battery_high_voltage_clear: V = Register(0x0353, 0.01, 'H')
|
||||||
|
panel_high_voltage_set: V = Register(0xEDBA, 0.01, 'H')
|
||||||
|
panel_high_voltage_clear: V = Register(0xEDB9, 0.01, 'H')
|
||||||
|
minimum_enabled_time: Minute = Register(0x100A, 1, 'H')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Charger(Group):
|
||||||
|
battery_temperature: K = Register(0xEDEC, 0.01, 'H')
|
||||||
|
maximum_current: A = Register(0xEDDF, 0.01, 'H')
|
||||||
|
system_yield: kWh = Register(0xEDDD, 0.01, 'I')
|
||||||
|
user_yield: kWh = Register(0xEDDC, 0.01, 'I')
|
||||||
|
internal_temperature: C = Register(0xEDDB, 0.01, 'h')
|
||||||
|
error_code: Err = Register(0xEDDA, Err, 'B')
|
||||||
|
current: A = Register(0xEDD7, 0.1, 'H')
|
||||||
|
voltage: V = Register(0xEDD5, 0.01, 'H')
|
||||||
|
additional_state_info: AdditionalChargerState = Register(
|
||||||
|
0xEDD4, AdditionalChargerState, 'B')
|
||||||
|
yield_today: kWh = Register(0xEDD3, 0.01, 'H')
|
||||||
|
maximum_power_today: W = Register(0xEDD2, 1, 'H')
|
||||||
|
yield_yesterday: kWh = Register(0xEDD1, 0.01, 'H')
|
||||||
|
maximum_power_yesterday: W = Register(0xEDD0, 1, 'H')
|
||||||
|
voltage_settings: Unknown = Register(0xEDCE, None, 'H')
|
||||||
|
history_version: Unknown = Register(0xEDCD, None, 'B')
|
||||||
|
streetlight_version: Unknown = Register(0xEDCC, None, 'B')
|
||||||
|
adjustable_voltage_minimum: V = Register(0x2211, 0.01, 'H')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Panel(Group):
|
||||||
|
power: W = Register(0xEDBC, 0.01, 'I')
|
||||||
|
voltage: V = Register(0xEDBB, 0.01, 'H')
|
||||||
|
current: A = Register(0xEDBD, 0.1, 'H')
|
||||||
|
maximum_voltage: V = Register(0xEDB8, 0.01, 'H')
|
||||||
|
tracker_mode: Unknown = Register(0xEDB3, None, 'B')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Battery(Group):
|
||||||
|
batterysafe_mode: bool = Register(0xEDFF, None, 'B')
|
||||||
|
adaptive_mode: bool = Register(0xEDFE, None, 'B')
|
||||||
|
automatic_equalisation_mode: int = Register(0xEDFD, None, 'B')
|
||||||
|
bulk_time_limit: Hours = Register(0xEDFC, 0.01, 'H')
|
||||||
|
absorption_time_limit: Hours = Register(0xEDFB, 0.01, 'H')
|
||||||
|
absorption_voltage: V = Register(0xEDF7, 0.01, 'H')
|
||||||
|
float_voltage: V = Register(0xEDF6, 0.01, 'H')
|
||||||
|
equalisation_voltage: V = Register(0xEDF4, 0.01, 'H')
|
||||||
|
temp_compensation: mVK = Register(0xEDF2, 0.01, 'h')
|
||||||
|
type: int = Register(0xedf1, 1, 'b')
|
||||||
|
maximum_current: A = Register(0xEDF0, 0.1, 'H')
|
||||||
|
voltage: V = Register(0xEDEF, 1, 'B')
|
||||||
|
temperature: K = Register(0xEDEC, 0.01, 'H')
|
||||||
|
voltage_setting: V = Register(0xEDEA, 1, 'B')
|
||||||
|
bms_present: bool = Register(0xEDE8, None, 'B')
|
||||||
|
tail_current: A = Register(0xEDE7, 0.1, 'H')
|
||||||
|
low_temperature_charge_current: A = Register(0xEDE6, 0.1, 'H')
|
||||||
|
auto_equalise_stop_on_voltage: bool = Register(0xEDE5, None, 'B')
|
||||||
|
equalisation_current_level: Percent = Register(0xEDE4, 1, 'B')
|
||||||
|
equalisation_duration: Hours = Register(0xEDE3, 0.01, 'H')
|
||||||
|
re_bulk_voltage_offset: V = Register(0xED2E, 0.01, 'H')
|
||||||
|
low_temperature_level: C = Register(0xEDE0, 0.01, 'h')
|
||||||
|
voltage_compensation: V = Register(0xEDCA, 0.01, 'H')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VEDirectPort(Group):
|
||||||
|
tx_port_mode: TXPortMode = Register(0xED9E, TXPortMode, 'B')
|
||||||
|
rx_port_mode: RXPortMode = Register(0xED98, RXPortMode, 'B')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Registers:
|
||||||
|
"""The registers on the device."""
|
||||||
|
product: Product = Product()
|
||||||
|
device: Device = Device()
|
||||||
|
charger: Charger = Charger()
|
||||||
|
load: Load = Load()
|
||||||
|
panel: Panel = Panel()
|
||||||
|
battery: Battery = Battery()
|
||||||
|
relay: Relay = Relay()
|
||||||
|
vedirect_port: VEDirectPort = VEDirectPort()
|
75
vedirect/test_text.py
Normal file
75
vedirect/test_text.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pint
|
||||||
|
|
||||||
|
from . import defs
|
||||||
|
from . import text
|
||||||
|
|
||||||
|
_ureg = pint.UnitRegistry()
|
||||||
|
|
||||||
|
_SYNC = b"""
|
||||||
|
Checksum &
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCK = b"""PID 0xA042
|
||||||
|
FW 153
|
||||||
|
SER# HQ1949I8BGA
|
||||||
|
V 12110
|
||||||
|
I 0
|
||||||
|
VPV 13590
|
||||||
|
PPV 0
|
||||||
|
CS 3
|
||||||
|
MPPT 2
|
||||||
|
ERR 0
|
||||||
|
LOAD ON
|
||||||
|
IL 0
|
||||||
|
H19 43
|
||||||
|
H20 2
|
||||||
|
H21 34
|
||||||
|
H22 1
|
||||||
|
H23 4
|
||||||
|
HSDS 7
|
||||||
|
Checksum &
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse():
|
||||||
|
block = (_SYNC + _BLOCK).replace(b'\n', b'\r\n')
|
||||||
|
src = io.BytesIO(block)
|
||||||
|
parser = text.parse(src)
|
||||||
|
want = {
|
||||||
|
'PID': '0xA042',
|
||||||
|
'FW': '1.53',
|
||||||
|
'SER#': 'HQ1949I8BGA',
|
||||||
|
'V': 12.110 * _ureg.volt,
|
||||||
|
'I': 0 * _ureg.amp,
|
||||||
|
'VPV': 13.590 * _ureg.volt,
|
||||||
|
'PPV': 0 * _ureg.watt,
|
||||||
|
'CS': defs.State.BULK,
|
||||||
|
'MPPT': defs.MPPTMode.ACTIVE,
|
||||||
|
'ERR': defs.Err.NO_ERROR,
|
||||||
|
'LOAD': defs.Load.ON,
|
||||||
|
'IL': 0 * _ureg.amp,
|
||||||
|
'H19': 430 * _ureg.watt * _ureg.hour,
|
||||||
|
'H20': 20 * _ureg.watt * _ureg.hour,
|
||||||
|
'H21': 34 * _ureg.watt,
|
||||||
|
'H22': 10 * _ureg.watt * _ureg.hour,
|
||||||
|
'H23': 4 * _ureg.watt,
|
||||||
|
'HSDS': 7 * _ureg.day,
|
||||||
|
}
|
||||||
|
got = next(parser)
|
||||||
|
assert got == want
|
|
@ -1,6 +1,20 @@
|
||||||
"""Implements the ve.direct text protocol."""
|
# Copyright 2020 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Implements a VE.Direct text protocol decoder."""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
from typing import Iterator, Tuple
|
||||||
|
|
||||||
import pint
|
import pint
|
||||||
|
|
||||||
|
@ -11,14 +25,19 @@ _CR = 0x0D
|
||||||
_TAB = 0x09
|
_TAB = 0x09
|
||||||
_CHECKSUM = 'Checksum'
|
_CHECKSUM = 'Checksum'
|
||||||
|
|
||||||
# Parses for certain unique field values.
|
# Parsers for certain unique field values.
|
||||||
_PARSERS = {
|
_PARSERS = {
|
||||||
defs.FW.label: lambda x: '%d.%d' % (int(x) // 100, int(x) % 100),
|
defs.FW.label: lambda x: '%d.%d' % (int(x) // 100, int(x) % 100),
|
||||||
defs.LOAD.label: lambda x: 1 if x == 'ON' else 0,
|
defs.LOAD.label: lambda x: 1 if x == 'ON' else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class _Source:
|
class _Source:
|
||||||
|
"""A simple buffered reader."""
|
||||||
def __init__(self, f):
|
def __init__(self, f):
|
||||||
self._f = f
|
self._f = f
|
||||||
self._ready = b''
|
self._ready = b''
|
||||||
|
@ -31,12 +50,12 @@ class _Source:
|
||||||
return ch
|
return ch
|
||||||
|
|
||||||
|
|
||||||
def _get_value(label: str, value: bytearray) -> object:
|
def _get_value(label: str, value_bytes: bytearray) -> object:
|
||||||
"""Parses the value in a label specific way."""
|
"""Parses the value in a label specific way."""
|
||||||
if label == _CHECKSUM:
|
if label == _CHECKSUM:
|
||||||
return value[0]
|
return value_bytes[0]
|
||||||
|
|
||||||
value = value.decode()
|
value = value_bytes.decode()
|
||||||
try:
|
try:
|
||||||
if label not in defs.FIELD_MAP:
|
if label not in defs.FIELD_MAP:
|
||||||
return int(value)
|
return int(value)
|
||||||
|
@ -53,11 +72,11 @@ def _get_value(label: str, value: bytearray) -> object:
|
||||||
return value
|
return value
|
||||||
assert False, 'Unhandled kind %s' % kind
|
assert False, 'Unhandled kind %s' % kind
|
||||||
return int(value)
|
return int(value)
|
||||||
except ValueError as ex:
|
except ValueError:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _get_line(src):
|
def _get_line(src: _Source) -> Tuple[str, object]:
|
||||||
label = bytearray()
|
label = bytearray()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
@ -82,7 +101,7 @@ def _get_line(src):
|
||||||
return label, _get_value(label, value)
|
return label, _get_value(label, value)
|
||||||
|
|
||||||
|
|
||||||
def parse(src):
|
def parse(src) -> Iterator[dict]:
|
||||||
src = _Source(src)
|
src = _Source(src)
|
||||||
|
|
||||||
while src.next() != _LF:
|
while src.next() != _LF:
|
||||||
|
@ -103,5 +122,4 @@ def parse(src):
|
||||||
break
|
break
|
||||||
fields[label] = value
|
fields[label] = value
|
||||||
|
|
||||||
print(fields)
|
|
||||||
yield fields
|
yield fields
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
import fileinput
|
|
||||||
import collections
|
|
||||||
import enum
|
|
||||||
import time
|
|
||||||
|
|
||||||
import serial
|
|
||||||
import prometheus_client
|
|
||||||
import pint
|
|
Loading…
Reference in a new issue