Compare commits

...

44 commits
v0 ... master

Author SHA1 Message Date
Michael Hope 23c0a49a26 vedirect: use the systemd watchdog
Some checks failed
continuous-integration/drone/push Build is failing
2021-05-09 14:20:10 +02:00
Michael Hope 66cb355392 vedirect: add the Phenix NVM group
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-18 14:34:22 +02:00
Michael Hope 0d26752550 vedirect: add a work-around for an empty serial number 2021-04-18 14:33:58 +02:00
Michael Hope 9a36e59d38 vedirect: fixed up the MPPT scale factors
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-28 17:45:23 +02:00
Michael Hope e14e5afa34 vedirect: looks stable, so poll less often.
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-21 17:02:01 +01:00
Michael Hope c682c172d8 vedirect: added frame metrics
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-21 16:30:39 +01:00
Michael Hope dda687d25b vedirect: add support for setting enums
Some checks failed
continuous-integration/drone/push Build is failing
2021-03-21 14:14:54 +01:00
Michael Hope 659546a35b vedirect: move the MPPT code out of cli.py and add detection.
Some checks failed
continuous-integration/drone/push Build is failing
2021-03-21 13:41:24 +01:00
Michael Hope 235f6b26df vedirect: move to the same register definition across products 2021-03-21 13:40:58 +01:00
Michael Hope 8c7bb58c6b vedirect: add registers for the Phoenix inverters. 2021-03-21 13:40:34 +01:00
Michael Hope 950548a9c3 vedirect: add flake8
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-10 21:28:14 +01:00
Michael Hope 32fde540f8 vedirect: fix the missing payload
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-07 20:14:55 +01:00
Michael Hope f0e797f2d6 vedirect: moved unpacking to hex.py
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-07 20:12:34 +01:00
Michael Hope 59ddf1a2c1 vedirect: move to drone for CI
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-07 20:04:38 +01:00
Michael Hope b521c25dd9 vedirect: put locks around the serial port usage 2021-02-28 11:30:18 +01:00
Michael Hope be80f12825 vedirect: on set, awaken the poller to get the new value 2021-02-28 11:19:14 +01:00
Michael Hope 83db956dfd vedirect: poll once a minute 2021-02-26 19:24:11 +01:00
Michael Hope 2469ebaed0 vedirect: use the serial number as the ID 2021-02-26 19:09:28 +01:00
Michael Hope 8bc6764988 vedirect: add the baud rate, dont die on a protocol error 2021-02-26 17:54:38 +00:00
Michael Hope 414a761918 vedirect: fix the apt-get deps command 2021-02-26 17:54:13 +01:00
Michael Hope f960699d2d vedirect: drop the power support, run flake8 2021-02-26 17:53:33 +01:00
Michael Hope 0f299386d9 vedirect: move to requirements.txt to pick up janet 2021-02-26 17:46:01 +01:00
Michael Hope 945a1c5a5f vedirect: tidy up the new HEX protocol. 2021-02-25 16:55:26 +01:00
Michael Hope c179cbc7c8 vedirect: move to the HEX protocol and janet libraries 2021-02-25 11:45:08 +01:00
Michael Hope 29f60a58f1 vedirect: ran YAPF and tidied up power.py 2021-01-02 17:37:49 +01:00
Michael Hope d74b2ec66b Merge branch 'master' of ssh://juju.nz:3022/michaelh/vedirect 2021-01-02 13:36:56 +00:00
Michael Hope b8057275a8 vedirect: add support for exporting directory entries 2021-01-02 13:33:31 +00:00
Michael Hope 808ca9b9e5 vedirect: be clear about this being for Victron devices 2020-11-21 16:32:04 +01:00
Michael Hope 2bf9b909cb vedirect: added some type annotations 2020-11-17 21:33:33 +01:00
Michael Hope c1c60fd7ed vedirect: expanded the README 2020-11-17 21:06:20 +01:00
Michael Hope a5701f88b6 vedirect: added the Apache license and CONTRIBUTING 2020-11-17 20:54:29 +01:00
Michael Hope 65818066e0 vedirect: fix the readme after setting the entry point 2020-11-17 20:52:07 +01:00
Michael Hope 19a4a9d029 vedirect: added comments, tidied up the files. 2020-11-17 20:50:45 +01:00
Michael Hope 28322e5897 vedirect: add a console script 2020-11-17 20:43:01 +01:00
Michael Hope dfd9e39637 vedirect: add a first order filter to smooth the low resolution values 2020-11-14 08:40:11 +00:00
Michael Hope 1d6c27a9e8 vedirect: retain the discovery records
This fixes a startup ordering issue between HASS and the exporter.
2020-11-01 16:33:15 +01:00
Michael Hope 886b8a9114 vedirect: add a parser test 2020-11-01 16:18:00 +01:00
Michael Hope 68a6f152de vedirect: added a README 2020-11-01 15:53:33 +01:00
Michael Hope deb494a57e vedirect: ran flake8 2020-11-01 15:24:02 +01:00
Michael Hope 77fb254781 vedirect: round the magnitude to fix float false precision errors 2020-11-01 14:17:57 +00:00
Michael Hope 13e987cf02 vedirect: add click to setup.py 2020-10-31 20:22:42 +00:00
Michael Hope fa06afacab vedirect: move the MQTT and Prometheus exporters to classes and add a CLI. 2020-10-31 20:22:23 +00:00
Michael Hope e3130abc13 vedirect: added MQTT output 2020-10-30 21:09:02 +00:00
Michael Hope d4556042a5 vedirect: add a setup.py 2020-10-30 19:39:25 +00:00
18 changed files with 2664 additions and 98 deletions

27
.drone.yml Normal file
View 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
View file

@ -0,0 +1,8 @@
.eggs
.mypy*
.vscode
build
dis
*.pyc
*.egg-info
*.tar.*

28
CONTRIBUTING.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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()

View file

@ -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 enum
@ -83,7 +98,9 @@ class Err(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 = 0
# 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')
VPV = Field('VPV', 'mV', 'Panel voltage')
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')
I3 = Field('I3', 'mA', 'Channel 3 battery current')
IL = Field('IL', 'mA', 'Load current')

259
vedirect/hex.py Normal file
View 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
View 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
View 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
View 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

View file

@ -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 time
import serial
import prometheus_client
import pint
import prometheus_client
from . import defs
from . import text
_UNITS = {
'%': 'percent',
@ -15,94 +28,103 @@ _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:
return issubclass(v, enum.Enum)
except TypeError:
return False
def _metrics(fields):
metrics = {}
labels = ['serial_number', 'product_id']
class Exporter:
def __init__(self):
self._metrics = None
self._filters = {}
for f in defs.FIELDS:
label = f.label.replace('#', '')
name = 'victron_%s' % label.lower()
kind = f.kind()
if isinstance(kind, pint.Quantity):
unit = str(kind.units)
else:
unit = _UNITS.get(f.unit, f.unit)
def _config(self, fields):
metrics = {}
labels = ['serial_number', 'product_id']
if unit == 'hour * watt':
unit = 'wh'
for f in defs.FIELDS:
label = f.label.replace('#', '')
name = 'victron_%s' % label.lower()
kind = f.kind()
if isinstance(kind, pint.Quantity):
unit = str(kind.units)
else:
unit = _UNITS.get(f.unit, f.unit)
if kind == str:
metrics[f.label] = prometheus_client.Info(name,
f.description,
labelnames=labels)
elif _is_enum(kind):
states = [x.name.lower() for x in kind]
metrics[f.label] = prometheus_client.Enum(
name,
f.description,
labelnames=['serial_number', 'product_id'],
states=states)
metrics[f.label + '_value'] = prometheus_client.Gauge(
name + '_value',
f.description,
labelnames=['serial_number', 'product_id'])
else:
metrics[f.label] = prometheus_client.Gauge(
name,
f.description,
labelnames=['serial_number', 'product_id'],
unit=unit)
if unit == 'hour * watt':
unit = 'wh'
updated = prometheus_client.Gauge(
'victron_updated',
'Last time a block was received from the device',
labelnames=labels)
blocks = prometheus_client.Counter(
'victron_blocks',
'Number of blocks received from the device',
labelnames=labels)
if kind == str:
metrics[f.label] = prometheus_client.Info(name,
f.description,
labelnames=labels)
elif _is_enum(kind):
states = [x.name.lower() for x in kind]
metrics[f.label] = prometheus_client.Enum(
name,
f.description,
labelnames=['serial_number', 'product_id'],
states=states)
metrics[f.label + '_value'] = prometheus_client.Gauge(
name + '_value',
f.description,
labelnames=['serial_number', 'product_id'])
else:
metrics[f.label] = prometheus_client.Gauge(
name,
f.description,
labelnames=['serial_number', 'product_id'],
unit=unit)
return metrics, updated, blocks
updated = prometheus_client.Gauge(
'victron_updated',
'Last time a block was received from the device',
labelnames=labels)
blocks = prometheus_client.Counter(
'victron_blocks',
'Number of blocks received from the device',
labelnames=labels)
return metrics, updated, blocks
def _update(fields, metrics, updated, blocks):
ser = fields[defs.SER.label]
pid = fields[defs.PID.label]
updated.labels(ser, pid).set(time.time())
blocks.labels(ser, pid).inc()
def export(self, fields):
if self._metrics is None:
self._metrics, self._updated, self._blocks = self._config(fields)
for label, value in fields.items():
gauge = metrics[label]
if isinstance(value, pint.Quantity):
gauge.labels(ser, pid).set(value.m)
elif isinstance(gauge, prometheus_client.Info):
gauge.labels(ser,
pid).info({label.lower().replace('#', ''): value})
elif isinstance(gauge, prometheus_client.Enum):
gauge.labels(ser, pid).state(value.name.lower())
metrics[label + '_value'].labels(ser, pid).set(value.value)
elif isinstance(value, int):
gauge.labels(ser, pid).set(value)
else:
print(repr(value))
ser = fields[defs.SER.label]
pid = fields[defs.PID.label]
self._updated.labels(ser, pid).set(time.time())
self._blocks.labels(ser, pid).inc()
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()
for label, value in fields.items():
gauge = self._metrics[label]
if isinstance(value, pint.Quantity):
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):
gauge.labels(ser,
pid).info({label.lower().replace('#', ''): value})
elif isinstance(gauge, prometheus_client.Enum):
gauge.labels(ser, pid).state(value.name.lower())
self._metrics[label + '_value'].labels(ser,
pid).set(value.value)
elif isinstance(value, int):
gauge.labels(ser, pid).set(value)
else:
print(repr(value))

324
vedirect/schema.py Normal file
View 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
View 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

View file

@ -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
from typing import Iterator, Tuple
import pint
@ -11,14 +25,19 @@ _CR = 0x0D
_TAB = 0x09
_CHECKSUM = 'Checksum'
# Parses for certain unique field values.
# Parsers for certain unique field values.
_PARSERS = {
defs.FW.label: lambda x: '%d.%d' % (int(x) // 100, int(x) % 100),
defs.LOAD.label: lambda x: 1 if x == 'ON' else 0,
}
class ProtocolError(RuntimeError):
pass
class _Source:
"""A simple buffered reader."""
def __init__(self, f):
self._f = f
self._ready = b''
@ -31,12 +50,12 @@ class _Source:
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."""
if label == _CHECKSUM:
return value[0]
return value_bytes[0]
value = value.decode()
value = value_bytes.decode()
try:
if label not in defs.FIELD_MAP:
return int(value)
@ -53,11 +72,11 @@ def _get_value(label: str, value: bytearray) -> object:
return value
assert False, 'Unhandled kind %s' % kind
return int(value)
except ValueError as ex:
except ValueError:
return value
def _get_line(src):
def _get_line(src: _Source) -> Tuple[str, object]:
label = bytearray()
while True:
@ -82,7 +101,7 @@ def _get_line(src):
return label, _get_value(label, value)
def parse(src):
def parse(src) -> Iterator[dict]:
src = _Source(src)
while src.next() != _LF:
@ -103,5 +122,4 @@ def parse(src):
break
fields[label] = value
print(fields)
yield fields

View file

@ -1,8 +0,0 @@
import fileinput
import collections
import enum
import time
import serial
import prometheus_client
import pint