WIP on MQTT support
This commit is contained in:
parent
2813e9a884
commit
071f91ea83
|
@ -6,3 +6,4 @@ solcast_data.json
|
|||
vendor/*
|
||||
Gemfile.lock
|
||||
logs/*
|
||||
octolux.log
|
||||
|
|
4
Gemfile
4
Gemfile
|
@ -17,3 +17,7 @@ group :webserver do
|
|||
gem 'slim'
|
||||
gem 'tilt'
|
||||
end
|
||||
|
||||
group :mqtt do
|
||||
gem 'mqtt-sub_handler'
|
||||
end
|
||||
|
|
11
README.md
11
README.md
|
@ -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
|
||||
|
|
2
boot.rb
2
boot.rb
|
@ -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
|
||||
|
|
|
@ -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).
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
11
octolux.rb
11
octolux.rb
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue