WIP on MQTT support

This commit is contained in:
Chris Elsworth 2020-04-01 10:47:42 +01:00
parent 2813e9a884
commit 071f91ea83
10 changed files with 202 additions and 14 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ solcast_data.json
vendor/*
Gemfile.lock
logs/*
octolux.log

View File

@ -17,3 +17,7 @@ group :webserver do
gem 'slim'
gem 'tilt'
end
group :mqtt do
gem 'mqtt-sub_handler'
end

View File

@ -45,6 +45,7 @@ This script needs to know:
* the serial numbers of your inverter and datalogger (the plug-in WiFi unit), which are normally printed on the sides.
* how many batteries you have, which determines the maximum charge rate (used in agile_cheap_slots rules)
* which Octopus tariff you're on, AGILE-18-02-21 is my current one for Octopus Agile.
* if you're using MQTT, where to find your MQTT server.
* optionally on the Pi, a list of GPIOs you'll be controlling.
Copy `rules.rb` from the example as a starting point:
@ -53,8 +54,6 @@ Copy `rules.rb` from the example as a starting point:
cp doc/rules.example.5p.rb rules.rb
```
This default one simply enables AC charging when the tariff price is 5p or lower, and disables it otherwise. Perhaps more exotic examples to follow.
The idea behind keeping the rules separate is you can edit it and be unaffected by any changes to the main script in the git repository (hopefully).
### Inverter Setup
@ -68,11 +67,13 @@ There are two components.
### server.rb
`server.rb` starts a HTTP server and is a long-running process that monitors the inverter for status packets (these include things like battery state-of-charge). We can then use this SOC in `octolux.rb`.
`server.rb` is a long-running process that we use for background work. In particular, it monitors the inverter for status packets (these include things like battery state-of-charge).
It's split like this because there's no way to ask the inverter for the current battery SOC. You just have to wait (up to two minutes) for it to tell you. The server will return the latest SOC on-demand via HTTP. If you're not interested in battery SOC you can ignore server.rb for now.
It starts a HTTP server which `octolux.rb` can then query to get realtime inverter data. It can also connect to MQTT and publish inverter information there. See [MQTT.md](doc/MQTT.md) for more information about this.
If you do want to run it, the simplest thing to do is just start it in screen:
It's split like this because there's no way to ask the inverter for the current battery SOC. You just have to wait (up to two minutes) for it to tell you. The server will return the latest SOC on-demand via HTTP.
The simplest thing to do is just start it in screen:
```
screen

View File

@ -14,7 +14,7 @@ require 'zeitwerk'
LOGGER = Logger.new(STDOUT)
LOADER = Zeitwerk::Loader.new
LOADER.inflector.inflect('gpio' => 'GPIO')
LOADER.inflector.inflect('gpio' => 'GPIO', 'mq' => 'MQ')
LOADER.logger = LOGGER if ENV['ZEITWERK_LOGGING']
LOADER.push_dir('lib')
LOADER.enable_reloading

85
doc/MQ.md Normal file
View File

@ -0,0 +1,85 @@
# MQTT Support
`server.rb` can be configured to connect to an MQTT server. For this, add a section to your `config.ini` like so;
```ini
[mqtt]
# see https://github.com/mqtt/mqtt.github.io/wiki/URI-Scheme for URI help
uri = mqtt://server:1883
```
You can use mqtts for secure connections and add username/password; see the URL for details.
## Getting Inverter Status
As mentioned in the README, the inverter sends power data when it feels like it. This is every 2 minutes. There's no way to change this or ask for it on demand.
It's sent by the inverter as 3 packets in sequence, around a second apart. When we receive each one, it is published under the keys `octolux/inputs/1`, `octolux/inputs/2`, and `octolux/inputs/3`. Each one always has the same set of data, and it should look something like this:
```
$ mosquitto_sub -t octolux/inputs/+ -v
octolux/inputs/1 {"status":32,"v_bat":49.4,"soc":53,"p_pv":550,"p_charge":114,"p_discharge":0,"v_acr":247.3,"f_ac":49.96,"p_inv":0,"p_rec":116,"v_eps":247.3,"f_eps":49.96,"p_to_grid":0,"p_to_user":0,"e_pv_day":0.7,"e_inv_day":2.0,"e_rec_day":1.7,"e_chg_day":1.9,"e_dischg_day":2.4,"e_eps_day":0.0,"e_to_grid_day":0.0,"e_to_user_day":3.9,"v_bus_1":379.9,"v_bus_2":300.5}
octolux/inputs/2 {"e_pv_all":1675.6,"e_inv_all":943.9,"e_rec_all":1099.3,"e_chg_all":1251.2,"e_dischg_all":1151.6,"e_eps_all":0.0,"e_to_grid_all":124.0,"e_to_user_all":1115.8,"t_inner":43,"t_rad_1":30,"t_rad_2":30}
octolux/inputs/3 {"max_chg_curr":105.0,"max_dischg_curr":150.0,"charge_volt_ref":53.2,"dischg_cut_volt":40.0,"bat_status_0":0,"bat_status_1":0,"bat_status_2":0,"bat_status_3":0,"bat_status_4":0,"bat_status_5":192,"bat_status_6":0,"bat_status_7":0,"bat_status_8":0,"bat_status_9":0,"bat_status_inv":3}
```
Documenting all these is beyond the scope of this document, but broadly speaking:
* `status` is 0 when idle, 16 when discharging (`p_dischg > 0`), 32 when charging (`p_charge > 0`)
* `v_bat is battery voltage, `soc` is state-of-charge in %. `v_bus` are internal bus voltages
* those prefixed with `p_` are intantaneous power in watts
* `e_` are energy accumulators in `kWh` (and they're present for both today and all-time)
* `f_` are mains Hz
* `t_` are temperatures in celsius; internally, and both radiators on the back of the inverter
* not really worked out what all the `bat_status_` are yet as they're usually mostly 0
## Controlling the Inverter
`server.rb` will subscribe to a few topics that can be used for inverter control.
In the following, "boolean" can be any of the following to mean true: `1`, `t`, `true`, `y`, `yes`, `on`. Anything else is interpreted as false.
* `octolux/cmd/ac_charge` - send this a boolean to enable or disable AC charging. This is taking energy from the grid to charge; this does not need to be on to charge from solar.
* `octolux/cmd/forced_discharge` - send this a boolean to enable or disable forced discharging. This is only useful if you get paid for export and the export rate is high. Normally, this should be off; this is *not* related to normal discharging operation.
* `octolux/cmd/discharge_pct` - send this an integer (0-100) to set the discharge rate. Normally this is 100% to enable normal discharge. If you have another cheap electricity source and want the inverter to stop supplying electricity, setting this to 0 will do that.
* `octolux/cmd/charge_pct` - send this an integer (0-100) to set the charge rate. This probably isn't terribly useful, but if you want to limit AC charging to less than the full 3600W, use this to do it.
So for example, you could do;
```
$ mosquitto_pub -t octolux/cmd/ac_charge -m on
```
After you send the inverter a command, you'll get two responses.
The first one will be in a topic like `octolux/result/ac_charge` (where the final topic level matches the `cmd` you sent). This will be the string `OK` if the inverter replied with success, or `FAIL` if it didn't.
The second response is a little more low-level and is described fully in the next section.
## Inverter Holdings
This is quite low-level and may not be terribly useful yet; this is subject to improvement in future.
The inverter has a bunch of registers that determine current operation. There's a full list in my [lxp-packet](https://github.com/celsworth/lxp-packet/blob/master/doc/LXP_REGISTERS.txt) gem.
So for example, setting discharge percentage is register 65 (DISCHG_POWER_PERCENT_CMD from the register list).
So if I send:
```
$ mosquitto_pub -t octolux/cmd/discharge_pct -m 50
```
I should then see a message:
```
$ mosquitto_sub -t octolux/hold/+ -v
octolux/hold/65 50
```
This says the inverter has told us that register 65 now contains the value 50. If you don't see that, then the register most likely has not updated.
So, clearly for now this requires your client code to know that register 65 is what will change in response to `discharge_pct`. For this reason, the `result` topics are probably more useful for now.
However, you could use these topics to record or graph every time a register changes, regardless of *how* it was changed, since these will be published even if MQ wasn't used to action the change (eg, via LuxPower's portal or app).

View File

@ -14,11 +14,19 @@ batteries = 4
product_code = AGILE-18-02-21
tariff_code = E-1R-AGILE-18-02-21-E
# where the optional server.rb is running, if available
[server]
host = localhost # use 0.0.0.0 to make the server available externally
# used by server.rb to open a listening port.
# use 0.0.0.0 here to make the server available externally
listen_host = localhost
# used by octolux.rb to connect to server.rb
connect_host = localhost
port = 4346
[mqtt]
# see https://github.com/mqtt/mqtt.github.io/wiki/URI-Scheme for URI help
# you can leave this commented out if you don't want to use MQTT at all.
# uri = mqtt://nas:1883
# an optional list of GPIOs you'd like to control on a Raspberry Pi.
# the names are used in your rules.rb and can be anything you like.
# the numbers are corresponding GPIO numbers.

View File

@ -43,11 +43,30 @@ class LuxListener
next unless (pkt = socket.read_packet)
@last_packet = Time.now
inputs.merge!(pkt.to_h) if pkt.is_a?(LXP::Packet::ReadInput)
registers[pkt.register] = pkt.value if pkt.is_a?(LXP::Packet::ReadHold)
process_input(pkt) if pkt.is_a?(LXP::Packet::ReadInput)
process_hold(pkt) if pkt.is_a?(LXP::Packet::ReadHold)
process_hold(pkt) if pkt.is_a?(LXP::Packet::WriteSingle)
end
ensure
socket.close
end
def process_input(pkt)
inputs.merge!(pkt.to_h)
n = case pkt
when LXP::Packet::ReadInput1 then 1
when LXP::Packet::ReadInput2 then 2
when LXP::Packet::ReadInput3 then 3
end
MQ.publish("octolux/inputs/#{n}", pkt.to_h)
end
def process_hold(pkt)
registers[pkt.register] = pkt.value
MQ.publish("octolux/hold/#{pkt.register}", pkt.value)
end
end
end

62
lib/mq.rb Normal file
View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'mqtt/sub_handler'
class MQ
class << self
def run
sub.subscribe_to 'octolux/cmd/ac_charge' do |data|
LOGGER.info "MQ cmd/ac_charge => #{data}"
r = lux_controller.charge(bool(data))
sub.publish_to('octolux/result/ac_charge', r ? 'OK' : 'FAIL')
end
sub.subscribe_to 'octolux/cmd/forced_discharge' do |data|
LOGGER.info "MQ cmd/forced_discharge => #{data}"
r = lux_controller.discharge(bool(data))
sub.publish_to('octolux/result/forced_discharge', r ? 'OK' : 'FAIL')
end
sub.subscribe_to 'octolux/cmd/charge_pct' do |data|
LOGGER.info "MQ cmd/charge_pct => #{data}"
r = (lux_controller.charge_pct = data.to_i)
sub.publish_to('octolux/result/charge_pct',
r == data.to_i ? 'OK' : 'FAIL')
end
sub.subscribe_to 'octolux/cmd/discharge_pct' do |data|
LOGGER.info "MQ cmd/discharge_pct => #{data}"
r = (lux_controller.discharge_pct = data.to_i)
sub.publish_to('octolux/result/discharge_pct',
r == data.to_i ? 'OK' : 'FAIL')
end
Thread.stop # sleep forever
end
def publish(topic, message)
sub.publish_to(topic, message)
end
private
def sub
@sub ||= MQTT::SubHandler.new(CONFIG['mqtt']['uri'])
end
def lux_controller
# FIXME: duplicated in octolux.rb, could move to boot.rb?
@lux_controller ||= LuxController.new(host: CONFIG['lxp']['host'],
port: CONFIG['lxp']['port'],
serial: CONFIG['lxp']['serial'],
datalog: CONFIG['lxp']['datalog'])
end
def bool(input)
case input
when true, 1, /\A(?:1|t(?:rue)?|y(?:es)?|on)\z/i then true
else false
end
end
end
end

View File

@ -12,16 +12,21 @@ unless octopus.price
exit 255
end
lc = LuxController.new(host: CONFIG['lxp']['host'], # rubocop:disable Lint/UselessAssignment
# rubocop:disable Lint/UselessAssignment
# FIXME: duplicated in mq.rb, could move to boot.rb?
lc = LuxController.new(host: CONFIG['lxp']['host'],
port: CONFIG['lxp']['port'],
serial: CONFIG['lxp']['serial'],
datalog: CONFIG['lxp']['datalog'])
ls = LuxStatus.new(host: CONFIG['server']['host'], # rubocop:disable Lint/UselessAssignment
ls = LuxStatus.new(host: CONFIG['server']['connect_host'] || CONFIG['server']['host'],
port: CONFIG['server']['port'])
# abstraction of RPi::GPIO
gpio = GPIO.new(gpios: CONFIG['gpios']) # rubocop:disable Lint/UselessAssignment
gpio = GPIO.new(gpios: CONFIG['gpios'])
# rubocop:enable Lint/UselessAssignment
raise('rules.rb not found!') unless File.readable?('rules.rb')

View File

@ -8,9 +8,12 @@ Dir.chdir(__dir__)
require 'rack'
# connect to MQTT if configured
Thread.new { MQ.run } if CONFIG['mqtt']['uri']
# start a background thread which will listen for inverter packets
Thread.new { LuxListener.run }
Rack::Server.start(Host: CONFIG['server']['host'],
Rack::Server.start(Host: CONFIG['server']['listen_host'] || CONFIG['server']['host'],
Port: CONFIG['server']['port'],
app: App.freeze.app)