Add mqtt interface and document REST api (#243)

This commit is contained in:
andig 2020-07-26 13:55:41 +02:00 • committed by GitHub
parent e948e8b31d
commit c12d52ca82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 359 additions and 271 deletions

168
README.md
View file

@ -24,11 +24,11 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
- [Getting started](#getting-started)
- [Installation](#installation)
- [Configuration](#configuration)
- [Site](#site)
- [Loadpoint](#loadpoint)
- [Charger](#charger)
- [Meter](#meter)
- [Vehicle](#vehicle)
- [Loadpoint](#loadpoint)
- [Considerations](#considerations)
- [Plugins](#plugins)
- [Modbus](#modbus-read-only)
- [MQTT](#mqtt-readwrite)
@ -36,7 +36,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
- [HTTP](#http-readwrite)
- [Websocket](#websocket-read-only)
- [Combined status](#combined-status-read-only)
- [Developer information](#developer-information)
- [API](#api)
- [Background](#background)
## Getting started
@ -79,6 +79,12 @@ EVCC can also be run using Docker. Here's and example with given config file and
docker run -v $(pwd)/evcc.dist.yaml:/etc/evcc.yaml -p 7070:7070 andig/evcc -h
```
If using Docker with a meter or charger that requires UDP like KEBA or SMA Energy Meter, make sure that the Docker container can receive UDP messages on the relevant ports (`:7090` for KEBA and `:9522` for SMA):
```sh
docker run -p 7070:7070 -p 7090:7090/udp -p 7090:7090/udp -p 9522:9522/udp andig/evcc ...
```
To build EVCC from source, [Go](2) 1.13 is required:
make
@ -89,7 +95,45 @@ All components **must** be installed by a certified professional.
## Configuration
The EVCC consists of four basic elements: *Charger*, *Meter* and *Vehicle* individually configured and attached to *Loadpoints*.
The EVCC consists of five basic elements: *Site* and *Loadpoints* describe the infrastructure and combine *Charger*s, *Meter*s and *Vehicle*s.
### Site
A site describes the grid connection and is responsible for managing the available power. A minimal site configuration requires a grid meter for managing EVU demand and optionally a PV or battery meter.
```yaml
site:
- title: Zuhause # display name for UI
meters:
grid: sdm630 # grid meter reference
pv: sma # pv meter reference
```
### Loadpoint
Loadpoints combine meters, charger and vehicle together and add optional configuration. A minimal loadpoint configuration requires a charger and optionally a separate charge meter. If charger has an integrated meter it will automatically be used:
```yaml
loadpoints:
- title: Garage # display name for UI
charger: wallbe # charger reference
vehicle: audi # vehicle reference
meters:
charge: sdm630 # grid meter reference
```
More options are documented in the `evcc.dist.yaml` sample configuration.
#### Charge modes
The default *charge mode* upon start of EVCC is configured on the loadpoint. Multiple charge modes are supported:
- **Off**: disable the charger, even if car gets connected.
- **Now** (**Sofortladen**): charge immediately with maximum allowed current.
- **Min + PV**: charge immediately with minimum configured current. Additionally use PV if available.
- **PV**: use PV as available. May not charge the car if PV remains dark.
In general, due to the minimum value of 5% for signalling the EV duty cycle, the charger cannot limit the current to below 6A. If the available power calculation demands a limit less than 6A, handling depends on the charge mode. In **PV** mode, the charger will be disabled until available PV power supports charging with at least 6A. In **Min + PV** mode, charging will continue at minimum current of 6A and charge current will be raised as PV power becomes available again.
### Charger
@ -131,12 +175,6 @@ Compare the value to what you see as *Actual Charge Current Setting* in the Wall
KEBA chargers require UDP function to be enabled with DIP switch 1.3 = `ON`, see KEBA installation manual.
If using Docker, make sure that the Docker container can receive UDP messages on port 7090 used by KEBA by using [host networking](https://docs.docker.com/network/host/) in Docker:
```sh
docker run --network=host -p 7070:7070 andig/evcc ...
```
### Meter
Meters provide data about power and energy consumption. Available meter implementations are:
@ -201,71 +239,6 @@ Available vehicle implementations are:
- `porsche`: Porsche (Taycan)
- `default`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle
### Loadpoint
A loadpoint combines meters, charger and vehicle together and adds optional configuration. A minimal loadpoint configuration needs either pv or grid meter and a charger. More meters can be added as needed:
```yaml
loadpoints:
- name: main # name for logging
charger: wallbe # charger reference
vehicle: audi # vehicle reference
meters:
grid: sdm630 # grid meter reference
pv: sma # pv meter reference
```
More options are documented in the `evcc.dist.yaml` sample configuration.
#### Charge modes
The default *charge mode* upon start of EVCC is configured on the loadpoint. Multiple charge modes are supported:
- **Off**: disable the charger, even if car gets connected.
- **Now** (**Sofortladen**): charge immediately with maximum allowed current.
- **Min + PV**: charge immediately with minimum configured current. Additionally use PV if available.
- **PV**: use PV as available. May not charge the car if PV remains dark.
In general, due to the minimum value of 5% for signalling the EV duty cycle, the charger cannot limit the current to below 6A. If the available power calculation demands a limit less than 6A, handling depends on the charge mode. In **PV** mode, the charger will be disabled until available PV power supports charging with at least 6A. In **Min + PV** mode, charging will continue at minimum current of 6A and charge current will be raised as PV power becomes available again.
### Considerations
For intelligent control of PV power usage, EVCC needs to assess how much residual PV power is available at the grid connection point and how much power the charger actually uses. Various methods are implemented to obtain this information, with different degrees of accuracy.
- **PV meter**: Configuring a *PV meter* is the simplest option. *PV meter* measures the PV generation. The charger is allowed to consume:
Charge Power = PV Meter Power - Residual Power
The *pv meter* is expected to deliver negative values for export and should not return positive values.
*Residual Power* is a configurable assumption how much power remaining facilities beside the charger use.
- **Grid meter**: Configuring a *grid meter* is the preferred option. The *grid meter* is expected to be a two-way meter (import+export) and return the current amount of grid export as negative value measured in Watt (W). The charger is then allowed to consume:
Charge Power = Current Charge Power - Grid Meter Power - Residual Power
In this setup, *residual power* is used as margin to account for fluctuations in PV production that may be faster than EVCC's control loop.
- **Battery meter**: *battery meter* is used if a home battery is installed and you want charging the EV take priority over charging the home battery. As the home battery would otherwise "grab" all available PV power, this meter measures the home battery charging power.
With *grid meter* the charger is then allowed to consume:
Charge Power = Current Charge Power - Grid Meter Power + Battery Meter Power - Residual Power
or without *grid meter*
Charge Power = PV Meter Power + Battery Meter Power - Residual Power
The *battery meter* is expected to deliver negative values when charging and positive values when discharging.
When using a *grid meter* for accurate control of PV utilization, EVCC needs to be able to determine the current charge power. There are two configurations for determining the *current charge power*:
- **Charge meter**: A *charge meter* is often integrated into the charger but can also be installed separately. EVCC expects the *charge meter* to supply *charge power* in Watt (W) and preferably *total energy* in kWh.
If *total energy* is supplied, it can be used to calculate the *charged energy* for the current charging cycle.
- **No charge meter**: If no charge meter is installed, *charge power* is deducted from *charge current* as controlled by the charger. This method is less accurate than using a *charge meter* since the EV may chose to use less power than EVCC has allowed for consumption.
If the charger supplies *total energy* for the charging cycle this value is preferred over the *charge meter*'s value (if present).
## Plugins
Plugins are used to integrate physical devices and external data sources with EVCC. Plugins support both *read* and *write* access. When using plugins for *write* access, the actual data is provided as variable in form of `${var[:format]}`. If `format` is omitted, data is formatted according to the default Go `%v` [format](https://golang.org/pkg/fmt/). The variable is replaced with the actual data before the plugin is executed.
@ -467,37 +440,32 @@ charging:
topic: openWB/lp/1/boolChargeStat
```
## Developer information
## API
EVCC has the following internal API. The full documentation is available in GoDoc format in https://pkg.go.dev/github.com/andig/evcc/api.
EVCC provides a REST and MQTT APIs.
### Charger API
### REST API
- `Status()`: get charge controller status (`A...F`)
- `Enabled()`: get charger availability
- `Enable(bool)`: set charger availability
- `MaxCurrent(int)`: set maximum allowed charge current in A
- `/api/config`: EVCC static configuration
- `/api/state`: EVCC dynamic state
- `/api/mode`: global charge mode, use `/api/mode/<mode>` to modify
- `/api/targetsoc`: global target SoC, use `/api/targetsoc/<soc>` to modify
- `/api/loadpoints/<id>/mode`: loadpoint charge mode, use `/api/loadpoints/<id>/mode/<mode>` to modify
- `/api/loadpoints/<id>/targetsoc`: loadpoint target SoC, use `/api/loadpoints/<id>/targetsoc/<soc>` to modify
Optionally, charger can also provide:
### MQTT API
- `CurrentPower()`: power in W (used if charge meter is not present)
The MQTT API follows the REST API's structure:
### Meter API
- `CurrentPower()`: power in W
- `TotalEnergy()`: energy in kWh (optional)
### Vehicle API
- `Title()`: vehicle name for display in the configuration UI
- `Capacity()`: battery capacity in kWh
- `ChargeState()`: state of charge in %
Optionally, vehicles can also provide:
- `CurrentPower()`: charge power in W (used if charge meter not present)
- `ChargedEnergy()`: charged energy in kWh
- `ChargeDuration()`: charge duration
- `evcc`: root topic
- `evcc/updated`: timestamp of last update
- `evcc/site`: site dynamic state
- `evcc/site/mode`: global charge mode, write `<mode>` to `/evcc/site/mode/set` to modify
- `evcc/site/targetsoc`: global target SoC, write `<soc>` to `/evcc/site/targetsoc/set` to modify
- `evcc/loadpoints`: number of available loadpoints
- `evcc/loadpoints/<id>`: loadpoint dynamic state
- `evcc/loadpoints/<id>/mode`: loadpoint charge mode, write `<mode>` to `/evcc/loadpoints/<id>/mode/set` to modify
- `evcc/loadpoints/<id>/targetsoc`: loadpoint target SoC, write `<soc>` to `/evcc/loadpoints/<id>/targetsoc/set` to modify
## Background

View file

@ -136,7 +136,7 @@
<script type="text/x-template" id="main-template">
<div class="container">
<site v-bind:state="state" v-if="configured"></site>
<site v-bind:state="state" v-if="<<.Configured>>"></site>
<div v-else>
<div class="row py-5">
<div class="col12">

View file

@ -263,7 +263,7 @@ Vue.component("loadpoint", {
},
methods: {
api: function (func) {
return "lp/" + this.id + "/" + func;
return "loadpoint/" + this.id + "/" + func;
},
targetMode: function (mode) {
axios.post(this.api("mode") + "/" + mode).then(function (response) {

View file

@ -4,7 +4,6 @@ import (
"fmt"
"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
"github.com/spf13/cobra"
@ -30,8 +29,8 @@ func runCharger(cmd *cobra.Command, args []string) {
conf := loadConfigFile(cfgFile)
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
if conf.Mqtt.Broker != "" {
configureMQTT(conf.Mqtt, nil, nil)
}
cp := &ConfigProvider{}

View file

@ -6,6 +6,7 @@ import (
"github.com/andig/evcc/api"
"github.com/andig/evcc/charger"
"github.com/andig/evcc/meter"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/push"
"github.com/andig/evcc/server"
"github.com/andig/evcc/vehicle"
@ -16,7 +17,7 @@ type config struct {
Log string
Levels map[string]string
Interval time.Duration
Mqtt mqttConfig
Mqtt provider.MqttConfig
Influx server.InfluxConfig
Menu []server.MenuConfig
Messaging messagingConfig
@ -42,12 +43,6 @@ type messagingConfig struct {
Services []typedConfig
}
type mqttConfig struct {
Broker string
User string
Password string
}
// ConfigProvider provides configuration items
type ConfigProvider struct {
meters map[string]api.Meter

View file

@ -5,7 +5,6 @@ import (
"os"
"testing"
"github.com/andig/evcc/provider"
"github.com/spf13/viper"
)
@ -37,8 +36,8 @@ func TestDistConfig(t *testing.T) {
}
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
if conf.Mqtt.Broker != "" {
configureMQTT(conf.Mqtt, nil, nil)
}
// check config is valid

View file

@ -4,7 +4,6 @@ import (
"fmt"
"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
"github.com/spf13/cobra"
@ -30,8 +29,8 @@ func runMeter(cmd *cobra.Command, args []string) {
conf := loadConfigFile(cfgFile)
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
if conf.Mqtt.Broker != "" {
configureMQTT(conf.Mqtt, nil, nil)
}
cp := &ConfigProvider{}

View file

@ -5,7 +5,6 @@ import (
"os"
"time"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/server"
"github.com/andig/evcc/server/updater"
"github.com/andig/evcc/util"
@ -125,14 +124,6 @@ func run(cmd *cobra.Command, args []string) {
uri := viper.GetString("uri")
log.INFO.Println("listening at", uri)
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
}
// setup loadpoints
site := loadConfig(conf)
// start broadcasting values
tee := &Tee{}
@ -140,6 +131,14 @@ func run(cmd *cobra.Command, args []string) {
cache := util.NewCache()
go cache.Run(tee.Attach())
// setup loadpoints
site := loadConfig(conf)
// setup mqtt
if conf.Mqtt.Broker != "" {
configureMQTT(conf.Mqtt, site, tee.Attach())
}
// setup database
if conf.Influx.URL != "" {
configureDatabase(conf.Influx, site.LoadPoints(), tee.Attach())

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/andig/evcc/core"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/push"
"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
@ -39,6 +40,21 @@ func configureDatabase(conf server.InfluxConfig, loadPoints []*core.LoadPoint, i
go influx.Run(loadPoints, in)
}
func mqttClientID() string {
pid := rand.Int31()
return fmt.Sprintf("evcc-%d", pid)
}
// setup mqtt
func configureMQTT(conf provider.MqttConfig, site *core.Site, in <-chan util.Param) {
provider.MQTT = provider.NewMqttClient(conf.Broker, conf.User, conf.Password, mqttClientID(), 1)
if site != nil && conf.Topic != "" {
mqtt := &server.MQTT{Handler: provider.MQTT}
go mqtt.Run(conf.Topic, site, in)
}
}
func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Event {
notificationChan := make(chan push.Event, 1)
notificationHub := push.NewHub(conf.Events, cache)
@ -53,11 +69,6 @@ func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Even
return notificationChan
}
func clientID() string {
pid := rand.Int31()
return fmt.Sprintf("evcc-%d", pid)
}
func loadConfig(conf config) *core.Site {
cp := &ConfigProvider{}
cp.configure(conf)

View file

@ -4,7 +4,6 @@ import (
"fmt"
"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/server"
"github.com/andig/evcc/util"
"github.com/spf13/cobra"
@ -30,8 +29,8 @@ func runVehicle(cmd *cobra.Command, args []string) {
conf := loadConfigFile(cfgFile)
// setup mqtt
if viper.Get("mqtt") != nil {
provider.MQTT = provider.NewMqttClient(conf.Mqtt.Broker, conf.Mqtt.User, conf.Mqtt.Password, clientID(), 1)
if conf.Mqtt.Broker != "" {
configureMQTT(conf.Mqtt, nil, nil)
}
cp := &ConfigProvider{}

View file

@ -117,6 +117,32 @@ type LoadpointConfiguration struct {
SoCLevels []int `json:"socLevels"`
}
// GetMode Gets loadpoint charge mode
func (site *Site) GetMode() api.ChargeMode {
return site.loadpoints[0].GetMode()
}
// GetTargetSoC gets loadpoint charge targetSoC
func (site *Site) GetTargetSoC() int {
return site.loadpoints[0].GetTargetSoC()
}
// SetMode sets loadpoint charge mode
func (site *Site) SetMode(mode api.ChargeMode) {
site.log.INFO.Printf("set global charge mode: %s", string(mode))
for _, lp := range site.loadpoints {
lp.SetMode(mode)
}
}
// SetTargetSoC sets loadpoint charge targetSoC
func (site *Site) SetTargetSoC(targetSoC int) {
site.log.INFO.Println("set global target soc:", targetSoC)
for _, lp := range site.loadpoints {
lp.SetTargetSoC(targetSoC)
}
}
func (lp *LoadPoint) hasChargeMeter() bool {
_, isWrapped := lp.chargeMeter.(*wrapper.ChargeMeter)
return lp.chargeMeter != nil && !isWrapped

View file

@ -10,6 +10,7 @@ levels:
# mqtt message broker
mqtt:
broker: localhost:1883
topic: evcc # root topic for publishing, set empty to disable
# user:
# password:
@ -222,14 +223,14 @@ vehicles:
# cache: 5m
site:
title: Home
title: Home # display name for UI
meters:
grid: grid # grid meter
pv: pv # pv meter
battery: battery # battery meter
loadpoints:
- title: Ladepunkt # ui display name
- title: Garage # display name for UI
charger: wallbe # charger
meters:
charge: charge # charge meter

View file

@ -13,13 +13,21 @@ import (
const publishTimeout = 2 * time.Second
// MqttConfig is the public configuration
type MqttConfig struct {
Broker string
User string
Password string
Topic string
}
// MqttClient is a paho publisher
type MqttClient struct {
log *util.Logger
mux sync.Mutex
Client mqtt.Client
broker string
qos byte
Qos byte
listener map[string]func(string)
}
@ -37,7 +45,7 @@ func NewMqttClient(
mc := &MqttClient{
log: log,
broker: broker,
qos: qos,
Qos: qos,
listener: make(map[string]func(string)),
}
@ -62,12 +70,12 @@ func NewMqttClient(
// ConnectionLostHandler logs cause of connection loss as warning
func (m *MqttClient) ConnectionLostHandler(client mqtt.Client, reason error) {
m.log.WARN.Printf("%s connection lost: %v", m.broker, reason.Error())
m.log.ERROR.Printf("%s connection lost: %v", m.broker, reason.Error())
}
// ConnectionHandler restores listeners
func (m *MqttClient) ConnectionHandler(client mqtt.Client) {
m.log.TRACE.Printf("%s connected", m.broker)
m.log.DEBUG.Printf("%s connected", m.broker)
m.mux.Lock()
defer m.mux.Unlock()
@ -92,7 +100,7 @@ func (m *MqttClient) Listen(topic string, callback func(string)) {
// listen attaches listener to topic
func (m *MqttClient) listen(topic string, callback func(string)) {
token := m.Client.Subscribe(topic, m.qos, func(c mqtt.Client, msg mqtt.Message) {
token := m.Client.Subscribe(topic, m.Qos, func(c mqtt.Client, msg mqtt.Message) {
s := string(msg.Payload())
if len(s) > 0 {
callback(s)
@ -171,7 +179,7 @@ func (m *MqttClient) IntSetter(param, topic, message string) func(int64) error {
}
m.log.TRACE.Printf("send %s: '%s'", topic, payload)
token := m.Client.Publish(topic, m.qos, false, payload)
token := m.Client.Publish(topic, m.Qos, false, payload)
if token.WaitTimeout(publishTimeout) {
return token.Error()
}
@ -189,7 +197,7 @@ func (m *MqttClient) BoolSetter(param, topic, message string) func(bool) error {
}
m.log.TRACE.Printf("send %s: '%s'", topic, payload)
token := m.Client.Publish(topic, m.qos, false, payload)
token := m.Client.Publish(topic, m.Qos, false, payload)
if token.WaitTimeout(publishTimeout) {
return token.Error()
}

View file

@ -3561,97 +3561,97 @@ oJE6K0E084u3ea7ne0X73qsc2P2KOB+ngKecxITsMAgTVJfqMwAA//+21HeD5gEAAA==
"/index.html": {
name: "index.html",
local: "../assets/index.html",
size: 19887,
size: 19892,
modtime: 1566640112,
compressed: `
H4sIAAAAAAAC/9x83ZLbNpbwfT/FMT1xuyem1N22E49MaSqx/c3km9jrmnacqt3aC5A8kpAGAS4ASt12
VLXvsHu5l3mMucubzJNsAQT4J1JSt9uZ3fWFWwIPzj/ODwAqupeKRF/nCEudsdlRZP4AI3wxDZAHZgBJ
OjsCiDLUBJIlkQr1NCj0PHwW1A84yXAarCiucyF1AIngGrmeBmua6uU0xRVNMLRfHgHlVFPCQpUQhtOz
R6CWkvLLUItwTvWUiy3EKapE0lxTwRu4X72HF0siFwgvBNdSMIZyayop9FLIxizCU7rYBstzhmEmYsow
XGMckjwPE5KTmGFj8jWqw6YqTXShwpjIUOnrFo6YkeQymB0ZNIzyS5DIPBYtimQZ0sTIqegHVNPg7Nnp
1dmz0wCWEufTgCZi3IUd5dxJVOMrcRjbTgOakQWODZBH+vj86vF5E+WcrMyM0D64Ob6zr67OvurDZx/0
4csIp3NUujlJUY2jNcbVs6056tIppzGJzImkYU45xzTUJB6p1cKomwk5De4/jWOSPu1iUkshdVJo6GJz
fI9osu2FmTKKpwkxfhi+owxfGCIN094/j59+/Yev98xMBJ/TRWOaIRxLsVYoy2ejq4xtIdFLzDBMuiTn
9p/zJ001wxmukiQal5/t8L0whBcXFxCGtR5KmROlxrEQWmlJ8lFG+ShRKnBKMo6rlohNQ9TT5oJrskYl
Mtw10dP/1lPxXJRLGpRMpsFPavzTvxUor8PHoyejs5FiNLNIf1LBLBqXoH2zcpHnKA8CrcWMC54y7Jnk
ef3migo1wCcxz3bMfV/g/78YmLsq8CBWVwWGUhS6VzILbzRsZgKMfw8SVygVAhMkpXwBhNPMuhr8fmxh
RrkUC4mqDEjlY0zho30INXyYUomJ+TTxSJ9bmE2JJtZ8lBAbh6vJIicJ1dcTONsCnVOpdAUYC5miDLXI
Q4ZzHUqS0kJNYHQuMXveBoqF1iIbhCtpJCLFCrvxxtBEowk8+3r09AuP0K6XCdzHZ4/xWeJH10Km4VqS
fAKxRHIZmoEadzR2Co7GZQKMYpFez46OopSugKY2XJeLwijJqINypQljmE6DKBq9L0dns8BCOyCjfWNJ
97U0JScrSBhRahpwsoqJhPJPiFc54WnIFn6A0cVSQ7woPwSl+SPSnh3GkvDUh7Sl1rmajMcLqpdFPEpE
Nrb5b2xCRDCLqJ+s8UqHuaQZkdcwJwrmJGRI5pDJ8NzwTF1UIY5sXGgteIe2FouFScIuT5QwAaREE/ds
GiSCMZIr9MMmgZs4VqJ4Q1bfMP2ayMsiD7x7SkpM0DQpvqLVAixBSoUZC8wJMwTsKCOxiUnvLHmjSrqw
vu70ZxZTTgYEKZONWXs54U7wcSmV+2b8wU31gnlz1YIaF+jhumKggcTN5WRVPQaIylgQ2gBcA4ZUY2ao
lQ/yODwLQItpMA5m35MUeTRuTGygM2EqiiThC4Tf0fTqEfzOYphMYfQ95ZdqNnMB7Jb0zXc1jiKDfDYL
ZlFkCYze2ZQ0280X8vRT6bsMO/uL/VtIa/AhqmQfygOW0pgqVZja8KLITQVcrRPjMClded9xH6MxJ6ty
+WeoFFmYao4orWbRuDNwVItvyutad/bbkUN55DNIufDMah5fhRqznBGN7SBUjVrcDd+TYg15+NhEmDWR
3GSS2oetDmz86nP88Oy88tc3WCC4AAgrlPNf/7aIibznHn9XBkqKUk/g48cqbm42IwfxvppjAEwljSOy
IpSZYtwhrqGjeBYRWIUx5emkNNXxbltJZEgUqrEmi/Hxl/0EgspBmhHSpAHIbUh8KdbcJNzRaGSsHY3j
yuIuNraDoFcWEwphzgTRobRxHGyuMdWtXFBu8uMEwvP8qh2/Xph5B4bSVrqpHd3GOYtzSdMU+TTQssBg
9kDTDNXzZpTbinO159oPjWrkAM/DLDal+ZDfmdBOKEcJ2VVICi3A6jpBrrHyt+WZB0+pyhm5Dp9ArsPH
AaxCOp8GttoNZh8/2g+bTTRenrmpuZ/JkKS9NBwOVcQVGv/ZYMqrVNtyMxMi/FT7udIdzRYe1JZ0NFt4
QPOxhptLkmEb1A5V0OU3bwSffK0lVyEyhS2aTk6aLcI5K2gafBYuvJv0+IIvj8rgVVvaeh3RIqOJ8zrv
9blQtCw4SawEKzQ+B7sEnphqD+wSmcDp6Kn9mlEeLrEcOz89za/KIbuxMIHHZsSzaZLJW4ccqK4SSjvi
wiqcmz7Rxn1qADFTldrMt/JZABMj17GfTNPw+EvzYGS0PLnE6xLOfPV6ahFqKezgmF2hGFo7Frdb/yky
cj0Nzk5PT08rEewT4/BLmqJf8Vsh3KIJTa2LslEYaSl45VWZtHgqpzDS2r2je1M4NgnjeKugTE2JIX09
iVcJI67R0JISvjBLzZSW8EpKIU3VbQjelP50kL5PY/sZ+LGE3GYhI4w1KZYbO8Hsz+/evTXpqx4zkcKC
H5QHMhaeQxaHJqzVkT2lKqMNu25ngDuO531uYJKcCYFWNueBRrhPSQEZofyADOBdU1FdBySTn6eB/eON
n7jCDlNbnVONDWE6gbFb4VyHT5tabBcyjTqmnTuWZybdPDUl4Xm7Sw1mP1LGLkWWIYcYKZSNUt6Px+ag
XNumKnY9VTwDqjSkFLmGD4WEOcMrGjPkYOp4pbFAWfAFrASHVwwvtRRzspQfsFggHw3SKsnAKwWK4lID
KdQjIEzB+tdfJMJLpByaTHCRLIHTZKnh0inYlGkj+CFrwX0oGuSgCYscFOWpEQXmgi2Qp8jhIllKqjUC
F3qNphCbtBBEgrVZbj21O0+zV05aQFsptCp7lRKNFDKq4Q0xVogSkZbbYKNrkrFobL+P4CXFDmqAC014
SmR62cTYxJBSpZto4JJwDimZ//o3abX5XkhGFmjthxweRmSLCBzURMRMxOOMKI1y3CZdl5umCDgZ9Sio
pRFIUcKPhLFYXFkeS3kSu10ulVfIAWgUvHr/A/zzr78sGUoFBU8BV5qNYI1Uo0Tpn0FhSilHKEN9UzJv
UH8gXCVLViiFqoXOLPDKiN95H7gwQWJNZWqlbc03vrBlhBSzll8jb8oGDxusO2Inpn+JC+PE+4UwTCm7
YPOCX+qOCMZ2uaBcq64gWT3HSdPjpS2+K8s+bJm1YtoshLSFd6cYF5pIbWNLc5GXSCRwLACl0siY0VhL
5kljnUCYQHfFtVf5WLBWnOqGrdlrymlGGMK3SFVOkbXXpF1wDRYn2xiOSn6iXGKVaK/Cx8HsqJB0Akwk
hC2F0pOvT78+hfvwTSpRmX7MoP7huyNqLLYibAJnpwruw19xgcwPsqPSrSdHod2SnwCuivBD6T5HJvlN
YDQawX14iZrQDvfdpVQhYSTFmyD5voZX8FDY3VjCTo784q7ZW5ducgDWKlgcmaVmorNtfibwZ5EZZ/SC
AywkTVuCw/2mWEe1nxs+HJbaEe9DQcF1cJbLIwDHeMUv3PdDLcoesKUxuD+oj2icS5xte+K217ynyOAi
J2XcaLrYPeOK8FaKmKFJLCb0Kw0vCyi4AlLMTVN4k/2hP1H95yI2QRxQSqTJsp3A+3eLblFwGTs2C67I
I2uXQz29QBpywRHSMEtDU4hAub9j/2Rp+AQyXf4ljC643TFTIfK07hsaRZNtpe0Gi3WFn38OjEcFza66
vxRtE33mifoCMCuYpo1OxUibWtdWvXWjqxI9TL+Ge/bDtpTRKf0GZ3pG7zU5HVJzzERyWSm4wrpHJ59D
/Gr5+sb4YTXyiKYnpkUuzVmv83KnzHfM6TSwHbEjX4EFMMlXjp2RCSKv0e68TKx+mgaNxtWkqle+he87
UbtroOv7Xf1+5dX72Lhctb3U5XvbOFkcnm9DvxVrlDCD08pY3+KHYgG7T16IlGIdpmLNy/Z010LxdJs9
zyvKVY5UmZ7hEEpFPkBnee5Rfvw4z/TDjmAnm41vkZs0skKbxsxO+YHTvmk/tnrlaFwSqj3yJlbJVztt
4lqHti5K8a0KVMGr3RL39ONxU1fHE0/Hch+dPjquxew8nE1PN6Uu96vRTbmZEutJN1BhFWsoZ5RjO970
qTQmWqO83q3Xby0QxR0uVu+idxXc8nKvREfWqxmOGx7aBzPzyj7QcZtzb6b2zsydur9VzKpi3iFJuycJ
DueJJ43sZSztUunT4fzdPBBdnjk4n/G6+byq7WxSbx6U+qSyO7Fv58EmkzVTtYL2JLotwIP4sfQNJ8HQ
xpFlK4/LA7LmEfwARruqWpVmJtKqLVmHZ41dWvPErz3z2WXLfGUgBJ8UeUo0ptOgPON5bUBm0ThrlbYN
/9/FknOeJVHvLLIL8aLNphJJP5dKJJ5JXU2FCcMVMuWfKJF8bwf6WbfUorESyQDjOyrgQ+qzzG7SXZem
etI85anNtq8082pqRyznsV8dT5qqe+THz86PJ/eaTza1J1nD7zf1xN2t8Uckh5l+R7Fo2N1vdGvwW5r4
EJa7Jj/IrG41Gu7PO6tun/n61t/nXHu7q/XD1P+brbf9yj9Uv430sr0Gf+Mss8uXWh3bdrrZ1cPtl70U
NS7/y9KwrvOjFS5pwnAoTbnHN0mWTwaS5UBTvFWwvRZpodrBtrksYs3DhRRFHqrst1odN5EMHjyA/hW0
JandE6LI2sI2FlqvrJ9xvQ2F5576p3f9/UMKoDuieFeF8R119EPtzPdIlS74Yn8bUe5D3qKLaE/8xB54
SI4/ISsv/x0mRvqKo1xc30aQeuqPy12yfIIwL0mBcocoF0sh9Uu3kd1izg/WgvXe6GoK2EK2JW0D4ScK
22q1lUgGpf8rKv0Bqb6VAl4pbe9535kCGgjvvP01+WJoYVexGuqoXV6Bs1cVl1QjqCVJxbqOX5G9NNHA
YOeKQtv9D79DkVJFYoYp+Evt9hb52l4wCmPB0uq+QfncGcrm0XKn0lA5mN5WT0ESTVdY5tjpsZjPjx/Z
O/OTe46g7yEiyvPCK1CSlIoAVoQVOA3EfO4yUcJoctlMxA8typNgdqFFfvf8crE+PoBBXpaU/QwaHIZB
MRdS3z2LGeX56njjzZiv9rNrpwwzXGI8CZo38apdtnJ7DdJQZc4VX1MOX8Lb962atT3JpX+VuelBeQ5a
ZJ2Jd6yam+pll1JuopEb6uJNIfu1cLuTAZH8r4kyvoa9pend6Y0pVf3JTVnI/pN8iXNSMF05iB2fBvaP
u+hI03Lrtus8pkSelkjbkerBA5pO7eb3sCOV1Jw/OWrbLnUhXpRsn5isZD9tNl98suldv7Vve7VMvL79
VCJ55zrQ/+cuXAXNq3CNif4Vq+pma3VP9Ul+9bzxftLZ6ekXz6Fx2/vsq/rWag9Ce40bpLC3Zd2YHXJt
Ql1GdBd8le+PW+9/KS1pjunxBBrZnfLFowF4/77Yrgnxoj4t8VCCc0w0pm0wu4dzPIF7Dztg8PPPHfwn
jZm2VlGYCJ6WRG4yf1O3eKVtProbw8fGGCaJeYP7qfBHCGwZHcAEtij9EYLqRo0BCBaoJXKug5PNZlfX
e4BNkSlsl4fuJecZ/OF/jIXvdbT14MGQ0QdV3xHvy+OWHarxzeaLT9Pl/yGVPezoJjo9gT/CKQwp8076
7/Kq7Q3u7tq3uIZe1+h/xaPzntYN3tyYvXQ7EylK8CE6fOSvM4X2wqK7m9S69GQv3diXAo4GDnuedO96
HA2dqzTPgW5096XvtKOXneXTQfJbN0Z29J/n9mSkwe4b1B/c3alJ52J5z4nu4GWIv//Xf7Rvm+9BYu8q
PMhSopZb19RvJcfb997INxajuj3wDxfCn6ff1iDtQ/vPIs6Wjx5+S6gqNv1Q+3KQfbum3us7/tK+SDN8
FDu0Ovesz+0VWtEcOGvId59TDnC43PGOQd+SvYmfQPPK46R1crvHT2pZyx2dYVe5tbPsPYbeJdfbJVHI
DxOpZbrcTFSbTX6HzLymfAQXWorsFgxllL8opESuN5tv7pIpcvUJTJGrA5n63M5tTz6WnYW5/aLOY1ie
D67zA9a6+eeLgs5d9BZ7fQO7ZO632pax3F4hMjbpXrs/zGCN9vPv//6fnaPPQdYP5u0vJCcfqP71F317
/l6Q8vc/NptLewiwn72ua+y+/NF//3kuhEbZeGnoSfliVu4OnfO4eTzrBusfINlfxPYeBPcotvU+3tDp
SfMHQSCK6Bw4gh+C0QuRZVTPZg+jqPp84n4HoftK4e7KwhKV3atJg3w9SER+/RzOT89Ph+lsNxGl8uvu
wTycF9z+bgyUv05gf0Xi4Yn7dRaJupAc/sUhH4/3/fqEP+CoZHDvEQSdH5II6mbLv6/dALpwQy04mi0a
IN9li/ZT+6ZzE8AOtGDMeAPCsDyb+YuOm0dNIa0J7cC/Pj8C2PS1XtXvCuV594d+onH5szPRuPx5tv8O
AAD///oQUayvTQAA
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
f6PtvwMAAP//970Y07RNAAA=
`,
},
"/js/app.js": {
name: "app.js",
local: "../assets/js/app.js",
size: 8627,
size: 8634,
modtime: 1566640112,
compressed: `
H4sIAAAAAAAC/7wZ247bxvVdX3EydiMqS1PaBEkKrWmjdVKkhVMDcZI+LBbIiByJE5EcYmYo7cIW0I/o
@ -3687,17 +3687,17 @@ k27HjHPErX7oNULaWc0OQvjH2zf/DCi3ECfozWWuFGANaCeuxbhvAtAUBN6g2/YmG+FDQ3/ceW/6wRZp
wXtFvImuqumSTbx6ohtnGKNUioXlMjVsmGnsSfe6yTjWzThX09i5jJtk2qTRBMfm7iI7H5iM8W9xwL+U
w+xS1vcL+rl6bmW0F/oHnsep6O6QbT0fVo+Em59pTXmrXk2X8XEVMSp6LQ4iNVhGkQnW4Kn7tsisunPB
ub4SJShJ/RrAJmtOlT1RKrhu5nfi3dW9Cd7lElSePlSGwZbEI1vyNH0A4ibz3bBbdWSR+W6gWl9EeBHC
arTydgVpn+4v7BsTDWvA5yqE604+dkaJYb0YmHlQzXkhu07GbyNHs7RYtpNSTFUVfyNwb0506+2PKu7P
iirulAe3ShXKVIMyL6THEKQq1/RIhRj9tRZwQSpUbsSZBwXEgRBqKLdF4mFjiMcsiQNVBilgVPQBRRya
UVFHG8T6SGUa7iONmptPUYv+xMJYrR4ma+xH5NLpch28UIFHMH9cGT6IREapmGBa3VxmNUmTYnNMEI/P
UKswqkoe8YKK191E9j0mYdzkKTJpPeaaMbYJgum2zslWqKKpDqiic2Yg+EZiH1hKVZtNjpnu7o3+zv37
bbI4NzXUAdevtPD+PXSO664QDopnVX9u726wbMfS4MAe+6A0Dvbw1cqHr1c+/HlFNe/MC3PL5ubiXDuZ
8Z/uCvdy85MqLXaU9tkm4zKHEIY7MJdTGx4e99eCx3X98RZHjW+Xqg1P3e0HpgD3uFjqyTLxyEUi7G4S
zirOBo74yAr1i+Y4ZN1FP2o/2RKXDNERUmQbEY9lpOMJEen8TF5ZaakoMVNumu8y29HHVvOMDlKZ7zHT
OjJoVf2D53YG8A4KbpM1dhUfGonWLqJO/uwOp+OIW4+w9GuZ7423CDJetOMFieSDjO+dvWprNaRRCENt
S8b3PTakoF8rRR/k1UXNti9j48sOicrvKOtAR92+Y/5Ev+k106nvo8dMglvdVmn4zZ2uq9vfZgAo9Pf3
PLJ/iaw8iFcpN2YNjNMvhuh/Vcoaq3kBz3N+eNG1MS+KwSOqkwgdETylRyWPPeFFgUv+zAWSzCXtIP8L
AAD//4j75SKzIQAA
arTydgVpn+4v7BsTDWvA5yqE604+dkaJYb0YmHlQzXkhu07GbyNHt9G9bAemmIor/kac3rjottwfVdwf
GVXcqRJuoyqUqeZlXkiPIUhVtemtCjH62y3gnlSo3Igz7wqIAyHUUG6ZxMPGHo/ZFQeqDDLBqOgDijg0
o6KONoj1kco03EcaNTefohb9iYWxWj1MltqPSKnT5XJ4oRCPYP64anwQiYxSMcG0urnMapImxeaYIB6f
oVZhVAU94gXVsLuJJHxMwrgBVGTSesz1ZOwWBNPtoJMdUUVTjVBF58xA8I3EPrCUijebnDbd3Rv9nfsv
3GSNbkqpA64fa+H9e+gc180hHNTQqgzd3t1g9Y6lwbk99kFpnO/hq5UPX698+POKSt+Zh+aWzc3F8XYy
4z/dFe4B5ydVWmws7etNxmUOIQxXYS6nFj087m8Hj2v+42WO+t8uVRueutsPDAPujbHUk2XikftE2F0o
nFWcDRzxkRXqh81xyLqLftR+siUuGaIjpMg2Ih7LSMcTItL5mbyy0lJRYqbcNN9ltqOPreYZHaQy32Om
dWTQqvo/z+0M4B0U3CZr7Co+NBKtXUSd/NkdDskRtx5h6dcy3xtvEWS8aKcMEskHGd87e9XWakijEIba
lozve2xIQb9Wij7Iq4uabV/GxpcdEpXfUdaBjrp9zvyJftOjplPfR4+ZBJe7rdLwmztdV7e/zQBQ6O/v
eWT/Ell5EK9SbswaGKdfDNH/qpQ1VvMCnuf88KJrY14Ug7dUJxE6InhKb0see8KLAnf9mQskmUtaRf4X
AAD//5n5oMK6IQAA
`,
},
@ -29395,12 +29395,6 @@ qyfj+jzDqd+3UHB3GYzx8OkQYWBg/k8AAAD//3J5YZY0KQEA
isDir: true,
},
"/img": {
name: "img",
local: `../assets/img`,
isDir: true,
},
"/js": {
name: "js",
local: `../assets/js`,
@ -29419,7 +29413,6 @@ var _escDirs = map[string][]os.FileInfo{
"../assets": {
_escData["/css"],
_escData["/ico"],
_escData["/img"],
_escData["/index.html"],
_escData["/js"],
_escData["/webfonts"],
@ -29446,8 +29439,6 @@ var _escDirs = map[string][]os.FileInfo{
_escData["/ico/site.webmanifest"],
},
"../assets/img": {},
"../assets/js": {
_escData["/js/app.js"],
_escData["/js/axios.min.js"],

View file

@ -48,9 +48,10 @@ type route struct {
type site interface {
Configuration() core.SiteConfiguration
LoadPoints() []*core.LoadPoint
loadpoint
}
// mode is the minimal interface for accessing loadpoint methods
// loadpoint is the minimal interface for accessing loadpoint methods
type loadpoint interface {
GetMode() api.ChargeMode
SetMode(api.ChargeMode)
@ -72,7 +73,7 @@ func routeLogger(inner http.Handler) http.HandlerFunc {
}
}
func indexHandler(links []MenuConfig, liveAssets bool) http.HandlerFunc {
func indexHandler(links []MenuConfig, site site, liveAssets bool) http.HandlerFunc {
indexTemplate, err := FSString(liveAssets, "/index.html")
if err != nil {
log.FATAL.Fatal("httpd: failed to load embedded template: " + err.Error())
@ -90,10 +91,11 @@ func indexHandler(links []MenuConfig, liveAssets bool) http.HandlerFunc {
// w.WriteHeader(http.StatusOK)
if err := t.Execute(w, map[string]interface{}{
"Version": Version,
"Commit": Commit,
"Debug": debug,
"Links": links,
"Version": Version,
"Commit": Commit,
"Debug": debug,
"Links": links,
"Configured": len(site.LoadPoints()) > 0,
}); err != nil {
log.ERROR.Println("httpd: failed to render main page: ", err.Error())
}
@ -200,16 +202,21 @@ func SocketHandler(hub *SocketHub) http.HandlerFunc {
}
}
// applyRouteHandler applies route with given handler
func applyRouteHandler(router *mux.Router, r route, handler http.HandlerFunc) {
router.Methods(r.Methods...).Path(r.Pattern).Handler(handler)
}
// NewHTTPd creates HTTP server with configured routes for loadpoint
func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *util.Cache) *http.Server {
var routes = map[string]route{
"health": {[]string{"GET"}, "/health", HealthHandler()},
"config": {[]string{"GET"}, "/config", ConfigHandler(site)},
"state": {[]string{"GET"}, "/state", StateHandler(cache)},
"getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(site.LoadPoints()[0])},
"setmode": {[]string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site.LoadPoints()[0])},
"gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(site.LoadPoints()[0])},
"settargetsoc": {[]string{"PUT", "POST", "OPTIONS"}, "/targetsoc/{soc:[0-9]+}", TargetSoCHandler(site.LoadPoints()[0])},
"getmode": {[]string{"GET"}, "/mode", CurrentChargeModeHandler(site)},
"setmode": {[]string{"PUT", "POST", "OPTIONS"}, "/mode/{mode:[a-z]+}", ChargeModeHandler(site)},
"gettargetsoc": {[]string{"GET"}, "/targetsoc", CurrentTargetSoCHandler(site)},
"settargetsoc": {[]string{"PUT", "POST", "OPTIONS"}, "/targetsoc/{soc:[0-9]+}", TargetSoCHandler(site)},
}
router := mux.NewRouter().StrictSlash(true)
@ -221,7 +228,7 @@ func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *
static := router.PathPrefix("/").Subrouter()
static.Use(handlers.CompressHandler)
static.HandleFunc("/", indexHandler(links, liveAssets))
static.HandleFunc("/", indexHandler(links, site, liveAssets))
for _, folder := range []string{"js", "css", "webfonts", "ico"} {
prefix := fmt.Sprintf("/%s/", folder)
static.PathPrefix(prefix).Handler(http.StripPrefix(prefix, http.FileServer(Dir(liveAssets, prefix))))
@ -244,19 +251,11 @@ func NewHTTPd(url string, links []MenuConfig, site site, hub *SocketHub, cache *
// loadpoint api
for id, lp := range site.LoadPoints() {
subAPI := api.PathPrefix(fmt.Sprintf("/lp/%d", id)).Subrouter()
r := routes["getmode"]
subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(CurrentChargeModeHandler(lp))
r = routes["setmode"]
subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(ChargeModeHandler(lp))
r = routes["gettargetsoc"]
subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(CurrentTargetSoCHandler(lp))
r = routes["settargetsoc"]
subAPI.Methods(r.Methods...).Path(r.Pattern).Handler(TargetSoCHandler(lp))
subAPI := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id)).Subrouter()
applyRouteHandler(subAPI, routes["getmode"], CurrentChargeModeHandler(lp))
applyRouteHandler(subAPI, routes["setmode"], ChargeModeHandler(lp))
applyRouteHandler(subAPI, routes["gettargetsoc"], CurrentTargetSoCHandler(lp))
applyRouteHandler(subAPI, routes["settargetsoc"], TargetSoCHandler(lp))
}
srv := &http.Server{

94
server/mqtt.go Normal file
View file

@ -0,0 +1,94 @@
package server
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/core"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
)
// MQTT is the MQTT server. It uses the MQTT client for publishing.
type MQTT struct {
Handler *provider.MqttClient
}
func (m *MQTT) encode(v interface{}) string {
var s string
switch val := v.(type) {
case time.Time:
s = strconv.FormatInt(val.Unix(), 10)
case time.Duration:
// must be before stringer to convert to seconds instead of string
s = fmt.Sprintf("%d", int64(val.Seconds()))
case fmt.Stringer, string:
s = fmt.Sprintf("%s", val)
case float64:
s = fmt.Sprintf("%.3f", val)
default:
s = fmt.Sprintf("%v", val)
}
return s
}
func (m *MQTT) publish(topic string, retained bool, payload interface{}) {
token := m.Handler.Client.Publish(topic, m.Handler.Qos, retained, m.encode(payload))
go m.Handler.WaitForToken(token)
}
type apiHandler interface {
SetMode(api.ChargeMode)
SetTargetSoC(int)
}
func (m *MQTT) listenSetters(topic string, apiHandler apiHandler) {
m.Handler.Listen(topic+"/mode/set", func(payload string) {
apiHandler.SetMode(api.ChargeMode(payload))
})
m.Handler.Listen(topic+"/targetsoc/set", func(payload string) {
soc, err := strconv.Atoi(payload)
if err == nil {
apiHandler.SetTargetSoC(soc)
}
})
}
// Run starts the MQTT publisher for the MQTT API
func (m *MQTT) Run(root string, site *core.Site, in <-chan util.Param) {
topic := fmt.Sprintf("%s/site", root)
m.listenSetters(topic, site)
// number of loadpoints
topic = fmt.Sprintf("%s/loadpoints", root)
m.publish(topic, true, len(site.LoadPoints()))
for id, lp := range site.LoadPoints() {
topic := fmt.Sprintf("%s/loadpoints/%d", root, id)
m.listenSetters(topic, lp)
}
// alive indicator
updated := time.Now().Unix()
m.publish(fmt.Sprintf("%s/updated", root), true, updated)
for p := range in {
topic = fmt.Sprintf("%s/site", root)
if p.LoadPoint != nil {
topic = fmt.Sprintf("%s/loadpoints/%d", root, *p.LoadPoint)
}
// alive indicator
if now := time.Now().Unix(); now != updated {
updated = now
m.publish(fmt.Sprintf("%s/updated", root), true, updated)
}
// value
topic += "/" + strings.ToLower(p.Key)
m.publish(topic, false, p.Val)
}
}