├── solis_service ├── __init__.py ├── persistence │ ├── __init__.py │ └── influxdb_persistence_client.py ├── messaging.py └── server.py ├── setup.py ├── requirements.txt ├── data ├── 2021-03-12.xlsx └── 2021-03-13.xlsx ├── pyproject.toml ├── .gitignore ├── setup.cfg ├── config ├── supervisord.conf └── solis-service.conf ├── scripts ├── intercept.py └── find_correlations.py ├── README.md ├── .github └── workflows │ └── build.yml ├── test └── test_messaging.py └── LICENSE.md /solis_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | setuptools.setup() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | influxdb-client[ciso] 2 | numpy 3 | openpyxl 4 | pandas 5 | pint 6 | pytest -------------------------------------------------------------------------------- /data/2021-03-12.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planetmarshall/solis-service/HEAD/data/2021-03-12.xlsx -------------------------------------------------------------------------------- /data/2021-03-13.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/planetmarshall/solis-service/HEAD/data/2021-03-13.xlsx -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE folders 2 | .idea 3 | 4 | # python folders 5 | .venv 6 | *.egg-info 7 | __pycache__ 8 | 9 | # git files 10 | *.orig -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = solis-service 3 | version = 1.2.0 4 | license = Apache-2.0 5 | 6 | [options] 7 | packages = find: 8 | install_requires = pint 9 | influxdb-client[ciso] 10 | python_requires = >=3.7 11 | 12 | [options.entry_points] 13 | console_scripts = 14 | solis_service = solis_service.server:run 15 | 16 | -------------------------------------------------------------------------------- /solis_service/persistence/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from .influxdb_persistence_client import InfluxDbPersistenceClient 4 | 5 | 6 | @contextmanager 7 | def persistence_client(config): 8 | client = None 9 | try: 10 | persistence_type = config["service"]["persistence"] 11 | if persistence_type == "influxdb": 12 | client = InfluxDbPersistenceClient(**config["influxdb"]) 13 | yield client 14 | else: 15 | raise ValueError(f"persistence type: {persistence_type}") 16 | finally: 17 | if client is not None: 18 | client.close() 19 | -------------------------------------------------------------------------------- /config/supervisord.conf: -------------------------------------------------------------------------------- 1 | [unix_http_server] 2 | file=/tmp/supervisor.sock ; the path to the socket file 3 | chmod=0766 ; needed so non-root users can run supervisorctl 4 | 5 | [supervisord] 6 | nodaemon=false 7 | 8 | [rpcinterface:supervisor] 9 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 10 | 11 | [supervisorctl] 12 | serverurl=unix:///tmp/supervisor.sock 13 | 14 | [program:solis_service] 15 | command=/usr/local/bin/solis_service 16 | user=solis 17 | stdout_logfile=/var/log/solis/solis.log 18 | stderr_logfile=/var/log/solis/solis_error.log 19 | 20 | [rpcinterface:supervisor] 21 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 22 | 23 | [supervisorctl] 24 | serverurl=unix:///tmp/supervisor.sock 25 | -------------------------------------------------------------------------------- /config/solis-service.conf: -------------------------------------------------------------------------------- 1 | [service] 2 | hostname = localhost 3 | port = 19042 4 | persistence = influxdb 5 | 6 | [influxdb] 7 | url = localhost 8 | token = user:password 9 | bucket = database/autogen 10 | org = - 11 | 12 | [loggers] 13 | keys=solis_service,root 14 | 15 | [handlers] 16 | keys=consoleHandler 17 | 18 | [formatters] 19 | keys=simpleFormatter 20 | 21 | [logger_root] 22 | level=DEBUG 23 | handlers=consoleHandler 24 | 25 | [logger_solis_service] 26 | level=DEBUG 27 | handlers=consoleHandler 28 | qualname=solis_service 29 | propagate=0 30 | 31 | [handler_consoleHandler] 32 | class=StreamHandler 33 | level=DEBUG 34 | formatter=simpleFormatter 35 | args=(sys.stdout,) 36 | 37 | [formatter_simpleFormatter] 38 | format=%(asctime)s - %(name)s - %(levelname)s - %(message)s 39 | datefmt= -------------------------------------------------------------------------------- /solis_service/persistence/influxdb_persistence_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | from functools import partial 4 | 5 | 6 | from influxdb_client import InfluxDBClient 7 | from influxdb_client.client.write_api import SYNCHRONOUS 8 | 9 | 10 | def to_influx_measurement(timestamp, data): 11 | return { 12 | "measurement": f"solis_inverter_{data['inverter_serial_number']}", 13 | "time": timestamp, 14 | "fields": {key: value.magnitude for key, value in data.items() if key != "inverter_serial_number"} 15 | } 16 | 17 | 18 | class InfluxDbPersistenceClient: 19 | description = "InfluxDb Persistence Client" 20 | 21 | def __init__(self, url, token, org, bucket): 22 | self.client = InfluxDBClient(url=url, token=token, org=org) 23 | self.bucket = bucket 24 | self.writer = self.client.write_api(write_options=SYNCHRONOUS) 25 | 26 | async def write_measurement(self, measurement): 27 | timestamp = datetime.utcnow().isoformat() 28 | record = to_influx_measurement(timestamp, measurement) 29 | return asyncio.get_running_loop().run_in_executor( 30 | None, partial(self.writer.write, self.bucket, record=record)) 31 | 32 | def close(self): 33 | self.writer.close() 34 | self.client.close() 35 | -------------------------------------------------------------------------------- /scripts/intercept.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from base64 import b64encode 4 | from datetime import datetime 5 | 6 | 7 | async def _read_and_log_response(reader, writer): 8 | buffer_size = 1024 9 | data = await reader.read(buffer_size) 10 | print(json.dumps({ 11 | 'timestamp': datetime.now().isoformat(), 12 | 'target': writer.transport.get_extra_info("peername"), 13 | 'data': b64encode(data).decode('ascii'), 14 | 'length': len(data)}) 15 | ) 16 | 17 | return data 18 | 19 | 20 | async def log_and_forward_response(reader, writer): 21 | data = await _read_and_log_response(reader, writer) 22 | writer.write(data) 23 | await writer.drain() 24 | 25 | 26 | async def handle_inverter_message(inverter_reader, inverter_writer): 27 | server_reader, server_writer = await asyncio.open_connection('47.88.8.200', 10000) 28 | await log_and_forward_response(inverter_reader, server_writer) 29 | await log_and_forward_response(server_reader, inverter_writer) 30 | server_writer.close() 31 | inverter_writer.close() 32 | 33 | 34 | async def main(): 35 | server = await asyncio.start_server(handle_inverter_message, 36 | '192.168.10.9', 19042) 37 | 38 | print(f"serving on {server.sockets[0].getsockname()}") 39 | 40 | async with server: 41 | await server.serve_forever() 42 | 43 | 44 | asyncio.run(main()) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solis / Ginlong Inverter Service 2 | 3 | ![Build and Run](https://github.com/planetmarshall/solis-service/actions/workflows/build.yml/badge.svg) 4 | 5 | This Python package implements a service that interprets messages from a Solis PV Inverter 6 | monitoring device and can persist them to various destinations 7 | 8 | Here's it being used to display a Grafana dashboard on my Raspberry Pi 9 | 10 | ![Grafana Dashboard](https://www.algodynamic.co.uk/images/grafana.png) 11 | 12 | ## Configuration 13 | 14 | See the example file in `conf`. 15 | 16 | ### Persistence Clients 17 | 18 | Currently [InfluxDb](https://www.influxdata.com/) is supported. Persistence clients are encapsulated such that 19 | adding new ones should be straightforward. See the configuration file for details. 20 | 21 | ## Installing as a service 22 | 23 | To install as a service, I use [supervisor](http://supervisord.org/). 24 | 25 | sudo pip install . supervisor 26 | adduser solis 27 | sudo mkdir -p /var/log/solis 28 | sudo chown -R solis:solis /var/log/solis 29 | 30 | Create a `supervisord.conf`, if you have not already done so and edit. See the example in 31 | the `conf` folder. 32 | 33 | Run supervisor 34 | 35 | sudo supervisord -c conf/supervisord.conf 36 | supervisorctl -c conf/supervisord.conf status 37 | supervisorctl -c conf/supervisord.conf tail solis_service 38 | 39 | ## Reverse engineering the data protocol 40 | 41 | For some details on reverse engineering the protocol, See my 42 | [blog](https://www.algodynamic.co.uk/reverse-engineering-the-solisginlong-inverter-protocol.html) -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | name: Build package and test 16 | runs-on: ubuntu-20.04 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: 3.7 26 | 27 | - name: Static Analysis 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install flake8 31 | flake8 --count --max-line-length=120 --statistics solis_service 32 | 33 | - name: Install Prerequisites 34 | run: | 35 | sudo adduser --disabled-password --gecos "" solis 36 | sudo mkdir -p /var/log/solis 37 | sudo chown -R solis:solis /var/log/solis 38 | sudo pip install . pytest supervisor 39 | sudo cp config/solis-service.conf /etc/solis-service.conf 40 | sudo chown solis:solis /etc/solis-service.conf 41 | sudo chmod 600 /etc/solis-service.conf 42 | 43 | - name: Run tests 44 | run: | 45 | pytest -v test 46 | 47 | - name: Configure and run supervisor 48 | run: | 49 | CONFIG=config/supervisord.conf 50 | sudo supervisord -c ${CONFIG} 51 | sleep 10s 52 | supervisorctl -c ${CONFIG} status 53 | supervisorctl -c ${CONFIG} tail solis_service 54 | sudo pkill supervisord 55 | 56 | - name: Show error logs 57 | run: | 58 | cat supervisord.log 59 | cat /var/log/solis/solis.log 60 | cat /var/log/solis/solis_error.log 61 | if: always() 62 | -------------------------------------------------------------------------------- /solis_service/messaging.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from io import BytesIO 3 | from functools import reduce 4 | from struct import unpack_from, pack 5 | 6 | import pint 7 | 8 | 9 | ureg = pint.UnitRegistry() 10 | 11 | 12 | def parse_inverter_message(message): 13 | return { 14 | "inverter_serial_number": message[32:48].decode("ascii").rstrip(), 15 | "inverter_temperature": 0.1 * unpack_from("