From 03f45a849d1dfab48e935a54e664743efae1b60e Mon Sep 17 00:00:00 2001 From: jagheterfredrik <jagheterfredrik@gmail.com> Date: Sat, 23 Dec 2023 21:34:37 +0100 Subject: [PATCH] move to go version --- .github/workflows/go.yml | 25 ++ .gitignore | 166 +------------ .pre-commit-config.yaml | 42 ---- README.md | 21 +- app/bridge.go | 127 ++++++++++ app/config.go | 37 +++ app/ratelimit/rate_limiter.go | 35 +++ app/sensors.go | 179 ++++++++++++++ app/tui.go | 92 ++++++++ app/wallbox/mq_darwin.go | 5 + app/wallbox/mq_linux.go | 39 ++++ app/wallbox/mq_linux_amd64.go | 6 + app/wallbox/mq_linux_arm.go | 6 + app/wallbox/mq_linux_arm64.go | 6 + app/wallbox/wallbox.go | 199 ++++++++++++++++ app/wallbox/wallbox_const.go | 91 ++++++++ go.mod | 19 ++ go.sum | 23 ++ main.go | 19 ++ make.sh | 4 + mqtt-bridge/bridge.ini | 10 - mqtt-bridge/bridge.py | 399 -------------------------------- mqtt-bridge/install.sh | 40 ---- mqtt-bridge/mqtt-bridge.service | 15 -- mqtt-bridge/requirements.txt | 3 - pyproject.toml | 2 - 26 files changed, 923 insertions(+), 687 deletions(-) create mode 100644 .github/workflows/go.yml delete mode 100644 .pre-commit-config.yaml create mode 100644 app/bridge.go create mode 100644 app/config.go create mode 100644 app/ratelimit/rate_limiter.go create mode 100644 app/sensors.go create mode 100644 app/tui.go create mode 100644 app/wallbox/mq_darwin.go create mode 100644 app/wallbox/mq_linux.go create mode 100644 app/wallbox/mq_linux_amd64.go create mode 100644 app/wallbox/mq_linux_arm.go create mode 100644 app/wallbox/mq_linux_arm64.go create mode 100644 app/wallbox/wallbox.go create mode 100644 app/wallbox/wallbox_const.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100755 make.sh delete mode 100644 mqtt-bridge/bridge.ini delete mode 100644 mqtt-bridge/bridge.py delete mode 100755 mqtt-bridge/install.sh delete mode 100644 mqtt-bridge/mqtt-bridge.service delete mode 100644 mqtt-bridge/requirements.txt delete mode 100644 pyproject.toml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..a004b8f --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,25 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: ./make.sh diff --git a/.gitignore b/.gitignore index 68bc17f..8d36e43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,160 +1,12 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Test binary, built with `go test -c` +*.test -# C extensions -*.so +# Output of the go coverage tool, specifically when used with LiteIDE +*.out -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# make.sh output +bridge-armhf +bridge-arm64 -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Go workspace file +go.work diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7e48b17..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,42 +0,0 @@ ---- -# Some expensive checks are disabled by default. To run all: -# pre-commit run --all-files --hook-stage manual -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: check-added-large-files - - id: check-case-conflict - - id: check-executables-have-shebangs - - id: check-merge-conflict - - id: check-shebang-scripts-are-executable - - id: check-vcs-permalinks - - id: debug-statements - - id: detect-private-key - - id: end-of-file-fixer - - id: fix-byte-order-marker - - id: mixed-line-ending - - id: trailing-whitespace - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 - hooks: - - id: ruff - args: [--fix] - - id: ruff-format - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.7.1 - hooks: - - id: mypy - additional_dependencies: [paho-mqtt, pymysql, redis, types-PyMySQL, types-paho-mqtt] - - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 - hooks: - - id: codespell - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.9.0 - hooks: - - id: shellcheck - stages: [manual] - -ci: - autoupdate_schedule: monthly diff --git a/README.md b/README.md index 85356fd..62f6b92 100644 --- a/README.md +++ b/README.md @@ -21,27 +21,10 @@ This open-source project connects your Wallbox fully locally to Home Assistant, 1. [Root your Wallbox](https://github.com/jagheterfredrik/wallbox-pwn) 2. Setup an MQTT Broker, if you don't already have one. Here's an example [installing it as a Home Assistant add-on](https://www.youtube.com/watch?v=dqTn-Gk4Qeo) -3. Edit bridge.ini - - Set `host` the IP address of your MQTT broker - - Set `username` and `password` to match your broker setup -4. Copy the files in mqtt-bridge to your Wallbox. - - On Windows you can use WinSCP. - - On OS X/Linux this can be done using `scp -r /path/to/wallbox-mqtt-bridge/mqtt-bridge root@<wallbox-ip>:~` - - You should end up with the following files in your Wallbox: - - `/home/root/mqtt-bridge/bridge.ini` - - `/home/root/mqtt-bridge/bridge.py` - - `/home/root/mqtt-bridge/install.sh` - - `/home/root/mqtt-bridge/mqtt-bridge.service` - - `/home/root/mqtt-bridge/requirements.txt` -5. `ssh` to your Wallbox and run the installer +3. `ssh` to your Wallbox and run ```sh -cd mqtt-bridge -chmod +x install.sh -./install.sh +curl -sSfL https://github.com/jagheterfredrik/wallbox-mqtt-bridge/releases/download/bridge/install.sh > install.sh && bash install.sh ``` ## Acknowledgments diff --git a/app/bridge.go b/app/bridge.go new file mode 100644 index 0000000..60742e1 --- /dev/null +++ b/app/bridge.go @@ -0,0 +1,127 @@ +package bridge + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/eclipse/paho.mqtt.golang" + "github.com/jagheterfredrik/wallbox-mqtt-bridge/app/ratelimit" + "github.com/jagheterfredrik/wallbox-mqtt-bridge/app/wallbox" +) + +var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) { + panic("Connection to MQTT lost") +} + +func LaunchBridge(configPath string) { + c := LoadConfig(configPath) + w := wallbox.New() + w.RefreshData() + + serialNumber := w.SerialNumber() + entityConfig := getEntities(w) + if c.Settings.DebugSensors { + for k, v := range getDebugEntities(w) { + entityConfig[k] = v + } + } + + topicPrefix := "wallbox_" + serialNumber + availabilityTopic := topicPrefix + "/availability" + + opts := mqtt.NewClientOptions() + opts.AddBroker(fmt.Sprintf("tcp://%s:%d", c.MQTT.Host, c.MQTT.Port)) + opts.SetUsername(c.MQTT.Username) + opts.SetPassword(c.MQTT.Password) + opts.SetWill(availabilityTopic, "offline", 1, true) + opts.OnConnectionLost = connectLostHandler + + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + for key, val := range entityConfig { + component := val.Component + uid := serialNumber + "_" + key + config := map[string]interface{}{ + "~": topicPrefix + "/" + key, + "availability_topic": availabilityTopic, + "state_topic": "~/state", + "unique_id": uid, + "device": map[string]string{ + "identifiers": serialNumber, + "name": c.Settings.DeviceName, + }, + } + if val.Setter != nil { + config["command_topic"] = "~/set" + } + for k, v := range val.Config { + config[k] = v + } + jsonPayload, _ := json.Marshal(config) + token := client.Publish("homeassistant/"+component+"/"+uid+"/config", 1, true, jsonPayload) + token.Wait() + } + + token := client.Publish(availabilityTopic, 1, true, "online") + token.Wait() + + messageHandler := func(client mqtt.Client, msg mqtt.Message) { + field := strings.Split(msg.Topic(), "/")[1] + payload := string(msg.Payload()) + setter := entityConfig[field].Setter + fmt.Println("Setting", field, payload) + setter(payload) + } + + topic := topicPrefix + "/+/set" + client.Subscribe(topic, 1, messageHandler) + + ticker := time.NewTicker(time.Duration(c.Settings.PollingIntervalSeconds) * time.Second) + defer ticker.Stop() + + published := make(map[string]interface{}) + rateLimiter := map[string]*ratelimit.DeltaRateLimit{ + "charging_power": ratelimit.NewDeltaRateLimit(10, 100), + "added_energy": ratelimit.NewDeltaRateLimit(10, 50), + } + + for { + select { + case <-ticker.C: + w.RefreshData() + for key, val := range entityConfig { + payload := val.Getter() + bytePayload := []byte(fmt.Sprint(payload)) + if published[key] != payload { + if rate, ok := rateLimiter[key]; ok && !rate.Allow(strToFloat(payload)) { + continue + } + fmt.Println("Publishing: ", key, payload) + token := client.Publish(topicPrefix+"/"+key+"/state", 1, true, bytePayload) + token.Wait() + published[key] = payload + } + } + case <-interrupt(): + fmt.Println("Interrupted. Exiting...") + token := client.Publish(availabilityTopic, 1, true, "offline") + token.Wait() + client.Disconnect(250) + os.Exit(0) + } + } +} + +func interrupt() <-chan os.Signal { + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) + return interrupt +} diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..2b2f1f0 --- /dev/null +++ b/app/config.go @@ -0,0 +1,37 @@ +package bridge + +import ( + "gopkg.in/ini.v1" +) + +type WallboxConfig struct { + MQTT struct { + Host string `ini:"host"` + Port int `ini:"port"` + Username string `ini:"username"` + Password string `ini:"password"` + } `ini:"mqtt"` + + Settings struct { + PollingIntervalSeconds int `ini:"polling_interval_seconds"` + DeviceName string `ini:"device_name"` + DebugSensors bool `ini:"debug_sensors"` + } `ini:"settings"` +} + +func (w *WallboxConfig) SaveTo(path string) { + cfg := ini.Empty() + cfg.ReflectFrom(w) + cfg.SaveTo(path) +} + +func LoadConfig(path string) *WallboxConfig { + cfg, _ := ini.Load(path) + + var config WallboxConfig + if err := cfg.MapTo(&config); err != nil { + return nil + } + + return &config +} diff --git a/app/ratelimit/rate_limiter.go b/app/ratelimit/rate_limiter.go new file mode 100644 index 0000000..61a938f --- /dev/null +++ b/app/ratelimit/rate_limiter.go @@ -0,0 +1,35 @@ +package ratelimit + +import ( + "math" + "time" +) + +type DeltaRateLimit struct { + lastTime time.Time + lastValue float64 + interval time.Duration + valueChange float64 +} + +func NewDeltaRateLimit(interval time.Duration, valueChange float64) *DeltaRateLimit { + return &DeltaRateLimit{ + interval: interval * time.Second, + valueChange: valueChange, + } +} + +func (c *DeltaRateLimit) Allow(value float64) bool { + now := time.Now() + + if math.Abs(value-c.lastValue) < c.valueChange { + if now.Sub(c.lastTime) < c.interval { + return false + } + } + + c.lastTime = now + c.lastValue = value + + return true +} diff --git a/app/sensors.go b/app/sensors.go new file mode 100644 index 0000000..1241e9b --- /dev/null +++ b/app/sensors.go @@ -0,0 +1,179 @@ +package bridge + +import ( + "fmt" + "strconv" + + "github.com/jagheterfredrik/wallbox-mqtt-bridge/app/wallbox" +) + +type Entity struct { + Component string + Getter func() string + Setter func(string) + Config map[string]string +} + +func strToInt(val string) int { + i, _ := strconv.Atoi(val) + return i +} + +func strToFloat(val string) float64 { + f, _ := strconv.ParseFloat(val, 64) + return f +} + +func getEntities(w *wallbox.Wallbox) map[string]Entity { + return map[string]Entity{ + "added_energy": { + Component: "sensor", + Getter: func() string { return fmt.Sprint(w.Data.RedisState.ScheduleEnergy) }, + Config: map[string]string{ + "name": "Added energy", + "device_class": "energy", + "unit_of_measurement": "Wh", + "state_class": "total", + "suggested_display_precision": "1", + }, + }, + "added_range": { + Component: "sensor", + Getter: func() string { return fmt.Sprint(w.Data.SQL.AddedRange) }, + Config: map[string]string{ + "name": "Added range", + "device_class": "distance", + "unit_of_measurement": "km", + "state_class": "total", + "suggested_display_precision": "1", + "icon": "mdi:map-marker-distance", + }, + }, + "cable_connected": { + Component: "binary_sensor", + Getter: func() string { return strconv.Itoa(w.CableConnected()) }, + Config: map[string]string{ + "name": "Cable connected", + "payload_on": "1", + "payload_off": "0", + "icon": "mdi:ev-plug-type1", + "device_class": "plug", + }, + }, + "charging_enable": { + Component: "switch", + Setter: func(val string) { w.SetChargingEnable(strToInt(val)) }, + Getter: func() string { return strconv.Itoa(w.Data.SQL.ChargingEnable) }, + Config: map[string]string{ + "name": "Charging enable", + "payload_on": "1", + "payload_off": "0", + "icon": "mdi:ev-station", + }, + }, + "charging_power": { + Component: "sensor", + Getter: func() string { + return fmt.Sprint(w.Data.RedisM2W.Line1Power + w.Data.RedisM2W.Line2Power + w.Data.RedisM2W.Line3Power) + }, + Config: map[string]string{ + "name": "Charging power", + "device_class": "power", + "unit_of_measurement": "W", + "state_class": "total", + "suggested_display_precision": "1", + }, + }, + "cumulative_added_energy": { + Component: "sensor", + Getter: func() string { return fmt.Sprint(w.Data.SQL.CumulativeAddedEnergy) }, + Config: map[string]string{ + "name": "Cumulative added energy", + "device_class": "energy", + "unit_of_measurement": "Wh", + "state_class": "total_increasing", + "suggested_display_precision": "1", + }, + }, + "halo_brightness": { + Component: "number", + Setter: func(val string) { w.SetHaloBrightness(strToInt(val)) }, + Getter: func() string { return strconv.Itoa(w.Data.SQL.HaloBrightness) }, + Config: map[string]string{ + "name": "Halo Brightness", + "command_topic": "~/set", + "min": "0", + "max": "100", + "icon": "mdi:brightness-percent", + "unit_of_measurement": "%", + "entity_category": "config", + }, + }, + "lock": { + Component: "lock", + Setter: func(val string) { w.SetLocked(strToInt(val)) }, + Getter: func() string { return strconv.Itoa(w.Data.SQL.Lock) }, + Config: map[string]string{ + "name": "Lock", + "payload_lock": "1", + "payload_unlock": "0", + "state_locked": "1", + "state_unlocked": "0", + "command_topic": "~/set", + }, + }, + "max_charging_current": { + Component: "number", + Setter: func(val string) { w.SetMaxChargingCurrent(strToInt(val)) }, + Getter: func() string { return strconv.Itoa(w.Data.SQL.MaxChargingCurrent) }, + Config: map[string]string{ + "name": "Max charging current", + "command_topic": "~/set", + "min": "6", + "max": strconv.Itoa(w.AvailableCurrent()), + "unit_of_measurement": "A", + "device_class": "current", + }, + }, + "status": { + Component: "sensor", + Getter: w.EffectiveStatus, + Config: map[string]string{ + "name": "Status", + }, + }, + } +} + +func getDebugEntities(w *wallbox.Wallbox) map[string]Entity { + return map[string]Entity{ + "control_pilot": { + Component: "sensor", + Getter: w.ControlPilotStatus, + Config: map[string]string{ + "name": "Control pilot", + }, + }, + "m2w_status": { + Component: "sensor", + Getter: func() string { return fmt.Sprint(w.Data.RedisM2W.ChargerStatus) }, + Config: map[string]string{ + "name": "M2W Status", + }, + }, + "state_machine_state": { + Component: "sensor", + Getter: w.StateMachineState, + Config: map[string]string{ + "name": "State machine", + }, + }, + "s2_open": { + Component: "sensor", + Getter: func() string { return strconv.Itoa(w.Data.RedisState.S2open) }, + Config: map[string]string{ + "name": "S2 open", + }, + }, + } +} diff --git a/app/tui.go b/app/tui.go new file mode 100644 index 0000000..4723d45 --- /dev/null +++ b/app/tui.go @@ -0,0 +1,92 @@ +package bridge + +import ( + "bufio" + "fmt" + "io/ioutil" + "os" + "os/exec" + "strconv" + "strings" +) + +var service = `[Unit] +Description=MQTT Bridge +After=network.target +Requires=mysqld.service +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=always +RestartSec=1 +User=root +ExecStart=/home/root/mqtt-bridge/bridge /home/root/mqtt-bridge/bridge.ini + +[Install] +WantedBy=multi-user.target +` + +func askConfirmOrNew(field *string, name string) { + fmt.Printf("%s (%s): ", name, *field) + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if len(input) > 0 { + *field = input + } +} + +func askConfirmOrNewInt(field *int, name string) { + fmt.Printf("%s (%d): ", name, *field) + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if len(input) > 0 { + *field, _ = strconv.Atoi(input) + } +} + +func askConfirmOrNewBool(field *bool, name string) { + fmt.Printf("%s (y/N): ", name) + reader := bufio.NewReader(os.Stdin) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if len(input) > 0 && input == "y" { + *field = true + } +} + +func installService() { + ioutil.WriteFile("/lib/systemd/system/mqtt-bridge.service", []byte(service), 0644) + var cmd *exec.Cmd + cmd = exec.Command("systemctl", "daemon-reload") + cmd.Run() + cmd = exec.Command("systemctl", "enable", "mqtt-bridge") + cmd.Run() + cmd = exec.Command("systemctl", "restart", "mqtt-bridge") + cmd.Run() +} + +func RunTuiSetup() { + config := WallboxConfig{} + config.MQTT.Host = "127.0.0.1" + config.MQTT.Port = 1883 + config.MQTT.Username = "" + config.MQTT.Password = "" + config.Settings.PollingIntervalSeconds = 1 + config.Settings.DeviceName = "Wallbox" + config.Settings.DebugSensors = false + + askConfirmOrNew(&config.MQTT.Host, "MQTT Host") + askConfirmOrNewInt(&config.MQTT.Port, "MQTT Port") + askConfirmOrNew(&config.MQTT.Username, "MQTT Username") + askConfirmOrNew(&config.MQTT.Password, "MQTT Password") + askConfirmOrNewInt(&config.Settings.PollingIntervalSeconds, "Polling interval") + askConfirmOrNew(&config.Settings.DeviceName, "Device name") + askConfirmOrNewBool(&config.Settings.DebugSensors, "Debug sensors") + + config.SaveTo("bridge.ini") + + installService() +} diff --git a/app/wallbox/mq_darwin.go b/app/wallbox/mq_darwin.go new file mode 100644 index 0000000..76867ce --- /dev/null +++ b/app/wallbox/mq_darwin.go @@ -0,0 +1,5 @@ +package wallbox + +func mqOpen(path []byte) uintptr { return 0 } +func mqTimedsend(fd uintptr, data []byte) uintptr { return 0 } +func mqClose(fd uintptr) {} diff --git a/app/wallbox/mq_linux.go b/app/wallbox/mq_linux.go new file mode 100644 index 0000000..cc87110 --- /dev/null +++ b/app/wallbox/mq_linux.go @@ -0,0 +1,39 @@ +package wallbox + +import ( + "syscall" + "unsafe" +) + +func mqOpen(path []byte) uintptr { + mq, _, _ := syscall.Syscall6( + uintptr(MqOpenSyscall), + uintptr(unsafe.Pointer(&path[0])), + uintptr(0x02), + uintptr(0x1c7), + uintptr(0), + uintptr(0), + uintptr(0), + ) + + return mq +} + +func mqTimedsend(fd uintptr, data []byte) uintptr { + mqLock, _, _ := syscall.Syscall6( + uintptr(MqTimedSendSyscall), + uintptr(fd), + uintptr(unsafe.Pointer(&data[0])), + uintptr(uintptr(len(data))), + uintptr(0), + uintptr(0), + uintptr(0), + ) + + return mqLock +} + +func mqClose(fd uintptr) { + fdi := int(fd) + syscall.Close(fdi) +} diff --git a/app/wallbox/mq_linux_amd64.go b/app/wallbox/mq_linux_amd64.go new file mode 100644 index 0000000..cf9717b --- /dev/null +++ b/app/wallbox/mq_linux_amd64.go @@ -0,0 +1,6 @@ +package wallbox + +const ( + MqOpenSyscall = 240 + MqTimedSendSyscall = 242 +) diff --git a/app/wallbox/mq_linux_arm.go b/app/wallbox/mq_linux_arm.go new file mode 100644 index 0000000..2893919 --- /dev/null +++ b/app/wallbox/mq_linux_arm.go @@ -0,0 +1,6 @@ +package wallbox + +const ( + MqOpenSyscall = 274 + MqTimedSendSyscall = 276 +) diff --git a/app/wallbox/mq_linux_arm64.go b/app/wallbox/mq_linux_arm64.go new file mode 100644 index 0000000..05d8fff --- /dev/null +++ b/app/wallbox/mq_linux_arm64.go @@ -0,0 +1,6 @@ +package wallbox + +const ( + MqOpenSyscall = 180 + MqTimedSendSyscall = 182 +) diff --git a/app/wallbox/wallbox.go b/app/wallbox/wallbox.go new file mode 100644 index 0000000..6cd04d3 --- /dev/null +++ b/app/wallbox/wallbox.go @@ -0,0 +1,199 @@ +package wallbox + +import ( + "bytes" + "context" + "fmt" + "reflect" + + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" +) + +type DataCache struct { + SQL struct { + Lock int `db:"lock"` + ChargingEnable int `db:"charging_enable"` + MaxChargingCurrent int `db:"max_charging_current"` + HaloBrightness int `db:"halo_brightness"` + CumulativeAddedEnergy float64 `db:"cumulative_added_energy"` + AddedRange float64 `db:"added_range"` + } + + RedisState struct { + SessionState int `redis:"session.state"` + ControlPilot int `redis:"ctrlPilot"` + S2open int `redis:"S2open"` + ScheduleEnergy float64 `redis:"scheduleEnergy"` + } + + RedisM2W struct { + ChargerStatus int `redis:"tms.charger_status"` + Line1Power float64 `redis:"tms.line1.power_watt.value"` + Line2Power float64 `redis:"tms.line2.power_watt.value"` + Line3Power float64 `redis:"tms.line3.power_watt.value"` + } +} + +type Wallbox struct { + redisClient *redis.Client + sqlClient *sqlx.DB + Data DataCache +} + +func New() *Wallbox { + var w Wallbox + + var err error + w.sqlClient, err = sqlx.Connect("mysql", "root:fJmExsJgmKV7cq8H@tcp(127.0.0.1:3306)/wallbox") + if err != nil { + panic(err) + } + + w.redisClient = redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", + DB: 0, + }) + + return &w +} + +func getRedisFields(obj interface{}) []string { + var result []string + val := reflect.ValueOf(obj) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + result = append(result, field.Tag.Get("redis")) + } + + return result +} + +func (w *Wallbox) RefreshData() { + ctx := context.Background() + + stateRes := w.redisClient.HMGet(ctx, "state", getRedisFields(w.Data.RedisState)...) + if stateRes.Err() != nil { + panic(stateRes.Err()) + } + + if err := stateRes.Scan(&w.Data.RedisState); err != nil { + panic(err) + } + + m2wRes := w.redisClient.HMGet(ctx, "m2w", getRedisFields(w.Data.RedisM2W)...) + if m2wRes.Err() != nil { + panic(m2wRes.Err()) + } + + if err := m2wRes.Scan(&w.Data.RedisM2W); err != nil { + panic(err) + } + + query := "SELECT " + + " `wallbox_config`.`charging_enable`," + + " `wallbox_config`.`lock`," + + " `wallbox_config`.`max_charging_current`," + + " `wallbox_config`.`halo_brightness`," + + " `power_outage_values`.`charged_energy` AS cumulative_added_energy," + + " IF(`active_session`.`unique_id` != 0," + + " `active_session`.`charged_range`," + + " `latest_session`.`charged_range`) AS added_range " + + "FROM `wallbox_config`," + + " `active_session`," + + " `power_outage_values`," + + " (SELECT * FROM `session` ORDER BY `id` DESC LIMIT 1) AS latest_session" + w.sqlClient.Get(&w.Data.SQL, query) +} + +func (w *Wallbox) SerialNumber() string { + var serialNumber string + w.sqlClient.Get(&serialNumber, "SELECT `serial_num` FROM charger_info") + return serialNumber +} + +func (w *Wallbox) UserId() string { + var userId string + w.sqlClient.QueryRow("SELECT `user_id` FROM `users` WHERE `user_id` != 1 ORDER BY `user_id` DESC LIMIT 1").Scan(&userId) + return userId +} + +func (w *Wallbox) AvailableCurrent() int { + var availableCurrent int + w.sqlClient.QueryRow("SELECT `max_avbl_current` FROM `state_values` ORDER BY `id` DESC LIMIT 1").Scan(&availableCurrent) + return availableCurrent +} + +func sendToPosixQueue(path, data string) { + pathBytes := append([]byte(path), 0) + mq := mqOpen(pathBytes) + + event := []byte(data) + eventPaddedBytes := append(event, bytes.Repeat([]byte{0x00}, 1024-len(event))...) + + mqTimedsend(mq, eventPaddedBytes) + mqClose(mq) +} + +func (w *Wallbox) SetLocked(lock int) { + w.RefreshData() + if lock == w.Data.SQL.Lock { + return + } + if lock == 1 { + sendToPosixQueue("WALLBOX_MYWALLBOX_WALLBOX_LOGIN", "EVENT_REQUEST_LOCK") + } else { + userId := w.UserId() + sendToPosixQueue("WALLBOX_MYWALLBOX_WALLBOX_LOGIN", "EVENT_REQUEST_LOGIN#"+userId+".000000") + } +} + +func (w *Wallbox) SetChargingEnable(enable int) { + w.RefreshData() + if enable == w.Data.SQL.ChargingEnable { + return + } + if enable == 1 { + sendToPosixQueue("WALLBOX_MYWALLBOX_WALLBOX_STATEMACHINE", "EVENT_REQUEST_USER_ACTION#1.000000") + } else { + sendToPosixQueue("WALLBOX_MYWALLBOX_WALLBOX_STATEMACHINE", "EVENT_REQUEST_USER_ACTION#2.000000") + } +} + +func (w *Wallbox) SetMaxChargingCurrent(current int) { + w.sqlClient.MustExec("UPDATE `wallbox_config` SET `max_charging_current`=?", current) +} + +func (w *Wallbox) SetHaloBrightness(brightness int) { + w.sqlClient.MustExec("UPDATE `wallbox_config` SET `halo_brightness`=?", brightness) +} + +func (w *Wallbox) CableConnected() int { + if w.Data.RedisM2W.ChargerStatus == 0 || w.Data.RedisM2W.ChargerStatus == 6 { + return 0 + } + return 1 +} + +func (w *Wallbox) EffectiveStatus() string { + tmsStatus := w.Data.RedisM2W.ChargerStatus + state := w.Data.RedisState.SessionState + + if override, ok := stateOverrides[state]; ok { + tmsStatus = override + } + + return wallboxStatusCodes[tmsStatus] +} + +func (w *Wallbox) ControlPilotStatus() string { + return fmt.Sprintf("%d: %s", w.Data.RedisState.ControlPilot, controlPilotStates[w.Data.RedisState.ControlPilot]) +} + +func (w *Wallbox) StateMachineState() string { + return fmt.Sprintf("%d: %s", w.Data.RedisState.SessionState, stateMachineStates[w.Data.RedisState.SessionState]) +} diff --git a/app/wallbox/wallbox_const.go b/app/wallbox/wallbox_const.go new file mode 100644 index 0000000..81e9524 --- /dev/null +++ b/app/wallbox/wallbox_const.go @@ -0,0 +1,91 @@ +package wallbox + +var wallboxStatusCodes = []string{ + "Ready", + "Charging", + "Connected waiting car", + "Connected waiting schedule", + "Paused", + "Schedule end", + "Locked", + "Error", + "Connected waiting current assignation", + "Unconfigured power sharing", + "Queue by power boost", + "Discharging", + "Connected waiting admin auth for mid", + "Connected mid safety margin exceeded", + "OCPP unavailable", + "OCPP charge finishing", + "OCPP reserved", + "Updating", + "Queue by eco smart", +} + +var stateOverrides = map[int]int{ + 0xA1: 0, + 0xA2: 9, + 0xA3: 14, + 0xA4: 15, + 0xA6: 17, + 0xB1: 3, + 0xB2: 4, + 0xB3: 3, + 0xB4: 2, + 0xB5: 2, + 0xB6: 4, + 0xB7: 8, + 0xB8: 8, + 0xB9: 10, + 0xBA: 10, + 0xBB: 12, + 0xBC: 13, + 0xBD: 18, + 0xC1: 1, + 0xC2: 1, + 0xC3: 11, + 0xC4: 11, + 0xD1: 6, + 0xD2: 6, +} + +var stateMachineStates = map[int]string{ + 0xE: "Error", + 0xF: "Unviable", + 0xA1: "Ready", + 0xA2: "PS Unconfig", + 0xA3: "Unavailable", + 0xA4: "Finish", + 0xA5: "Reserved", + 0xA6: "Updating", + 0xB1: "Connected 1", // Make new session? + 0xB2: "Connected 2", + 0xB3: "Connected 3", // Waiting schedule ? + 0xB4: "Connected 4", + 0xB5: "Connected 5", // Connected waiting car ? + 0xB6: "Connected 6", // Paused + 0xB7: "Waiting 1", + 0xB8: "Waiting 2", + 0xB9: "Waiting 3", + 0xBA: "Waiting 4", + 0xBB: "Mid 1", + 0xBC: "Mid 2", + 0xBD: "Waiting eco power", + 0xC1: "Charging 1", + 0xC2: "Charging 2", + 0xC3: "Discharging 1", + 0xC4: "Discharging 2", + 0xD1: "Lock", + 0xD2: "Wait Unlock", +} + +var controlPilotStates = map[int]string{ + 0xE: "Error", + 0xF: "Failure", + 0xA1: "Ready 1", // S1 at 12V, car not connected + 0xA2: "Ready 2", + 0xB1: "Connected 1", // S1 at 9V, car connected not allowed charge + 0xB2: "Connected 2", // S1 at Oscillator, car connected allowed charge + 0xC1: "Charging 1", + 0xC2: "Charging 2", // S2 closed +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..936f55e --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/jagheterfredrik/wallbox-mqtt-bridge + +go 1.19 + +require ( + github.com/eclipse/paho.mqtt.golang v1.4.3 + github.com/go-sql-driver/mysql v1.7.1 + github.com/jmoiron/sqlx v1.3.5 + github.com/redis/go-redis/v9 v9.3.1 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gorilla/websocket v1.5.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sync v0.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7dadc3f --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik= +github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds= +github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/main.go b/main.go new file mode 100644 index 0000000..533f9f1 --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/jagheterfredrik/wallbox-mqtt-bridge/app" + "os" +) + +func main() { + if len(os.Args) != 2 { + panic("Usage: ./bridge --config or ./bridge bridge.ini") + } + firstArgument := os.Args[1] + if firstArgument == "--config" { + bridge.RunTuiSetup() + os.Exit(0) + } else { + bridge.LaunchBridge(firstArgument) + } +} diff --git a/make.sh b/make.sh new file mode 100755 index 0000000..29a72a7 --- /dev/null +++ b/make.sh @@ -0,0 +1,4 @@ +set -x + +CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags="-s -w" -o bridge-armhf . +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bridge-arm64 . diff --git a/mqtt-bridge/bridge.ini b/mqtt-bridge/bridge.ini deleted file mode 100644 index 74ac06c..0000000 --- a/mqtt-bridge/bridge.ini +++ /dev/null @@ -1,10 +0,0 @@ -[mqtt] -host = 192.168.86.4 -port = 1883 -username = mqtt-user -password = mqtt - -[settings] -polling_interval_seconds = 1 -device_name = Wallbox -legacy_locking = no diff --git a/mqtt-bridge/bridge.py b/mqtt-bridge/bridge.py deleted file mode 100644 index ed3105a..0000000 --- a/mqtt-bridge/bridge.py +++ /dev/null @@ -1,399 +0,0 @@ -"""MQTT bridge. - -Polls the local database and publishes changes to an external MQTT broker for Home Assistant. -Accepts changes from Home Assistant and updates the local database. -Supports Home Assistant discovery. -""" -import configparser -import ctypes -import json -import logging -import os -import re -import time -from typing import Any, Dict # noqa: F401 - -import paho.mqtt.client as mqtt -import pymysql.cursors -import redis - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("mqtt-bridge") - -WALLBOX_STATUS_CODES = [ - "Ready", - "Charging", - "Connected waiting car", - "Connected waiting schedule", - "Paused", - "Schedule end", - "Locked", - "Error", - "Connected waiting current assignation", - "Unconfigured power sharing", - "Queue by power boost", - "Discharging", - "Connected waiting admin auth for mid", - "Connected mid safety margin exceeded", - "OCPP unavailable", - "OCPP charge finishing", - "OCPP reserved", - "Updating", - "Queue by eco smart", -] - -STATE_OVERRIDES = { - 0xA1: 0, - 0xA2: 9, - 0xA3: 14, - 0xA4: 15, - 0xA6: 17, - 0xB1: 3, - 0xB2: 4, - 0xB3: 3, - 0xB4: 2, - 0xB5: 2, - 0xB6: 4, - 0xB7: 8, - 0xB8: 8, - 0xB9: 10, - 0xBA: 10, - 0xBB: 12, - 0xBC: 13, - 0xBD: 18, - 0xC1: 1, - 0xC2: 1, - 0xC3: 11, - 0xC4: 11, - 0xD1: 6, - 0xD2: 6, -} - -connection = pymysql.connect( - host="localhost", - user="root", - password="fJmExsJgmKV7cq8H", - db="wallbox", - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, -) -# Because the transaction isolation is set to REPEATABLE-READ we need to commit after every write and read. -# If it was READ-COMMITTED this would only be needed after every write. -# Check the transaction isolation with: -# "SELECT @@GLOBAL.tx_isolation, @@tx_isolation;" -connection.autocommit(True) - -redis_connection = redis.Redis(host="localhost", port=6379, db=0) - - -def sql_execute(sql, *args): - with connection.cursor() as cursor: - cursor.execute(sql, args) - return cursor.fetchone() - - -def redis_hget(name, key): - result = redis_connection.hget(name, key) - assert result, name + " " + key + " not found in redis" - return result - - -def redis_hmget(name, keys): - result = redis_connection.hmget(name, keys) - assert result - return result - - -# Below, we're using arm64 syscalls to interact with Posix message queues -# sysall 274 is mq_open(name, oflag, mode, attr) -# sysall 276 is mq_timedsend(mqdes, msg_ptr, msg_len, msg_prio, abs_timeout) -libc = ctypes.CDLL(None) -syscall = libc.syscall - - -def send_to_posix_queue(name, msg): - mq = syscall(274, name, 0x2, 0x1C7, None) - if mq < 0: - return - syscall(276, mq, msg.ljust(1024, b"\x00"), 1024, 0, None) - os.close(mq) - - -def pause_resume(val): - proposed_state = int(val) - current_state = sql_execute("SELECT `charging_enable` FROM wallbox_config;")["charging_enable"] - if proposed_state == current_state: - return - - if proposed_state == 1: - send_to_posix_queue(b"WALLBOX_MYWALLBOX_WALLBOX_STATEMACHINE", b"EVENT_REQUEST_USER_ACTION#1.000000") - elif proposed_state == 0: - send_to_posix_queue(b"WALLBOX_MYWALLBOX_WALLBOX_STATEMACHINE", b"EVENT_REQUEST_USER_ACTION#2.000000") - - -# Needed for unlock -wallbox_uid = sql_execute("SELECT `user_id` FROM `users` WHERE `user_id` != 1 ORDER BY `user_id` DESC LIMIT 1;")[ - "user_id" -] - - -def lock_unlock(val): - proposed_state = int(val) - current_state = sql_execute("SELECT `lock` FROM wallbox_config;")["lock"] - if proposed_state == current_state: - return - - if proposed_state == 1: - send_to_posix_queue(b"WALLBOX_MYWALLBOX_WALLBOX_LOGIN", b"EVENT_REQUEST_LOCK") - elif proposed_state == 0: - send_to_posix_queue(b"WALLBOX_MYWALLBOX_WALLBOX_LOGIN", (b"EVENT_REQUEST_LOGIN#%d.000000" % wallbox_uid)) - - -# Applies some additional rules to the internal state and returns the status as a string -def effective_status_string(): - tms_status = int(redis_hget("m2w", "tms.charger_status")) - state = int(redis_hget("state", "session.state")) - if state in STATE_OVERRIDES: - tms_status = STATE_OVERRIDES[state] - return WALLBOX_STATUS_CODES[tms_status] - - -ENTITIES_CONFIG = { - "charging_enable": { - "component": "switch", - "setter": pause_resume, - "config": { - "name": "Charging enable", - "payload_on": 1, - "payload_off": 0, - "icon": "mdi:ev-station", - }, - }, - "lock": { - "component": "lock", - "setter": lock_unlock, - "config": { - "name": "Lock", - "payload_lock": 1, - "payload_unlock": 0, - "state_locked": 1, - "state_unlocked": 0, - }, - }, - "max_charging_current": { - "component": "number", - "setter": lambda val: sql_execute("UPDATE `wallbox_config` SET `max_charging_current`=%s;", val), - "config": { - "name": "Max charging current", - "min": 6, - "max": 40, - "unit_of_measurement": "A", - "device_class": "current", - }, - }, - "halo_brightness": { - "component": "number", - "setter": lambda val: sql_execute("UPDATE `wallbox_config` SET `halo_brightness`=%s;", val), - "config": { - "name": "Halo Brightness", - "min": 0, - "max": 100, - "icon": "mdi:brightness-percent", - "unit_of_measurement": "%", - "entity_category": "config", - }, - }, - "cable_connected": { - "component": "binary_sensor", - "getter": lambda: int(int(redis_hget("m2w", "tms.charger_status")) not in (0, 6)), - "config": { - "name": "Cable connected", - "payload_on": 1, - "payload_off": 0, - "icon": "mdi:ev-plug-type1", - "device_class": "plug", - }, - }, - "charging_power": { - "component": "sensor", - "getter": lambda: sum( - float(v) - for v in redis_hmget( - "m2w", ["tms.line1.power_watt.value", "tms.line2.power_watt.value", "tms.line3.power_watt.value"] - ) - ), - "config": { - "name": "Charging power", - "device_class": "power", - "unit_of_measurement": "W", - "state_class": "total", - "suggested_display_precision": 1, - }, - }, - "status": { - "component": "sensor", - "getter": effective_status_string, - "config": { - "name": "Status", - }, - }, - "added_energy": { - "component": "sensor", - "getter": lambda: float(redis_hget("state", "scheduleEnergy")), - "config": { - "name": "Added energy", - "device_class": "energy", - "unit_of_measurement": "Wh", - "state_class": "total", - "suggested_display_precision": 1, - }, - }, - "cumulative_added_energy": { - "component": "sensor", - "config": { - "name": "Cumulative added energy", - "device_class": "energy", - "unit_of_measurement": "Wh", - "state_class": "total_increasing", - "suggested_display_precision": 1, - }, - }, - "added_range": { - "component": "sensor", - "config": { - "name": "Added range", - "device_class": "distance", - "unit_of_measurement": "km", - "state_class": "total", - "suggested_display_precision": 1, - "icon": "mdi:map-marker-distance", - }, - }, -} # type: Dict[str, Dict[str, Any]] - -DB_QUERY = """ -SELECT - `wallbox_config`.`charging_enable`, - `wallbox_config`.`lock`, - `wallbox_config`.`max_charging_current`, - `wallbox_config`.`halo_brightness`, - `power_outage_values`.`charged_energy` AS cumulative_added_energy, - IF(`active_session`.`unique_id` != 0, - `active_session`.`charged_range`, - `latest_session`.`charged_range`) AS added_range -FROM `wallbox_config`, - `active_session`, - `power_outage_values`, - (SELECT * FROM `session` ORDER BY `id` DESC LIMIT 1) AS latest_session; -""" - -mqttc = mqtt.Client() - -try: - config = configparser.ConfigParser() - config.read(os.path.join(os.path.dirname(__file__), "bridge.ini")) - mqtt_host = config.get("mqtt", "host") - mqtt_port = config.getint("mqtt", "port") - mqtt_username = config.get("mqtt", "username") - mqtt_password = config.get("mqtt", "password") - polling_interval_seconds = config.getfloat("settings", "polling_interval_seconds") - device_name = config.get("settings", "device_name") - if config.getboolean("settings", "legacy_locking", fallback=False): - ENTITIES_CONFIG["lock"]["setter"] = lambda val: sql_execute("UPDATE `wallbox_config` SET `lock`=%s;", val) - - # Prepare the MQTT topic name to include the serial number of the Wallbox - result = sql_execute("SELECT `serial_num` FROM `charger_info`;") - assert result - serial_num = str(result["serial_num"]) - - # Set max available current - result = sql_execute("SELECT `max_avbl_current` FROM `state_values` ORDER BY `id` DESC LIMIT 1;") - assert result - ENTITIES_CONFIG["max_charging_current"]["config"]["max"] = result["max_avbl_current"] - - topic_prefix = "wallbox_" + serial_num - set_topic = topic_prefix + "/+/set" - set_topic_re = re.compile(topic_prefix + "/(.*)/set") - availability_topic = topic_prefix + "/availability" - - def _on_connect(client, userdata, flags, rc): - logger.info("Connected to MQTT with %d", rc) - if rc == mqtt.MQTT_ERR_SUCCESS: - mqttc.subscribe(set_topic) - mqttc.publish(availability_topic, "online", retain=True) - for k, v in ENTITIES_CONFIG.items(): - unique_id = serial_num + "_" + k - component = v["component"] - config = { - "~": topic_prefix + "/" + k, - "state_topic": "~/state", - "unique_id": unique_id, - "device": { - "identifiers": serial_num, - "name": device_name, - }, - "availability_topic": availability_topic, - } - if "setter" in v: - config["command_topic"] = "~/set" - config = {**v["config"], **config} - mqttc.publish( - "homeassistant/" + component + "/" + unique_id + "/config", - json.dumps(config), - retain=True, - ) - - def _on_message(client, userdata, message): - m = set_topic_re.match(message.topic) - if m: - field = m.group(1) - if field in ENTITIES_CONFIG and "setter" in ENTITIES_CONFIG[field]: - logger.info("Setting: %s %s", field, message.payload.decode()) - ENTITIES_CONFIG[field]["setter"](message.payload) - else: - logger.info("Setting unsupported for field %s", field) - - def on_disconnect(client, userdata, rc): - if rc != 0: - raise Exception("Disconnected") - - mqttc.on_disconnect = on_disconnect - mqttc.on_connect = _on_connect - mqttc.on_message = _on_message - mqttc.username_pw_set(mqtt_username, mqtt_password) - mqttc.will_set(availability_topic, "offline", retain=True) - logger.info("Connecting to MQTT %s %s", mqtt_host, mqtt_port) - mqttc.connect(mqtt_host, mqtt_port) - - published = {} # type: Dict[str, Any] - # If we change more than this, we publish even though we're rate limited - rate_limit_deltas = { - "charging_power": 100, - "added_energy": 50, - } - rate_limit_s = 10.0 - latest_rate_limit_publish = 0.0 - while True: - if mqttc.is_connected(): - result = sql_execute(DB_QUERY) - assert result - for k, v in ENTITIES_CONFIG.items(): - if "getter" in v: - result[k] = v["getter"]() - publish_rate_limited = latest_rate_limit_publish + rate_limit_s < time.time() - for key, val in result.items(): - if published.get(key) != val: - if key in rate_limit_deltas and not publish_rate_limited: - if abs(published.get(key, 0) - val) < rate_limit_deltas[key]: - continue - logger.info("Publishing: %s %s", key, val) - mqttc.publish(topic_prefix + "/" + key + "/state", val, retain=True) - published[key] = val - if publish_rate_limited: - latest_rate_limit_publish = time.time() - mqttc.loop(timeout=polling_interval_seconds) - -finally: - # Intentionally not calling mqttc.disconnect() so that the broker sends the will (offline to the availability_topic) - connection.close() - redis_connection.close() diff --git a/mqtt-bridge/install.sh b/mqtt-bridge/install.sh deleted file mode 100755 index 98de99b..0000000 --- a/mqtt-bridge/install.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash - -if [ ! -d "$HOME/.wallbox" ]; then - echo "This script should only be run on a Wallbox!" - exit 1 -fi - -base_dir="/home/root/mqtt-bridge" -if [ ! -d "$base_dir" ]; then - echo "$base_dir not found!" - exit 1 -fi -files=("bridge.py" "bridge.ini" "mqtt-bridge.service" "requirements.txt") -for f in "${files[@]}"; do - if [ ! -f "$base_dir/$f" ]; then - echo "$base_dir/$f not found!" - exit 1 - fi -done - -cd "$base_dir" || exit 1 - -echo "Setting up virtual environment" -python3 -m venv venv - -echo "Installing Python dependencies" -venv/bin/pip install -r requirements.txt - -echo "Setting up the MQTT bridge systemd service" -ln -s /home/root/mqtt-bridge/mqtt-bridge.service /lib/systemd/system/mqtt-bridge.service - -echo "Enable the service to start on boot.." -systemctl enable mqtt-bridge - -echo "..and launch the service now" -systemctl restart mqtt-bridge - -echo "Sleeping for 3 seconds before checking the status of the systemd service" -sleep 3 -systemctl status mqtt-bridge --no-pager diff --git a/mqtt-bridge/mqtt-bridge.service b/mqtt-bridge/mqtt-bridge.service deleted file mode 100644 index 649bc3a..0000000 --- a/mqtt-bridge/mqtt-bridge.service +++ /dev/null @@ -1,15 +0,0 @@ -[Unit] -Description=MQTT Bridge -After=network.target -Requires=mysqld.service -StartLimitIntervalSec=0 - -[Service] -Type=simple -Restart=always -RestartSec=1 -User=root -ExecStart=/home/root/mqtt-bridge/venv/bin/python3 /home/root/mqtt-bridge/bridge.py - -[Install] -WantedBy=multi-user.target diff --git a/mqtt-bridge/requirements.txt b/mqtt-bridge/requirements.txt deleted file mode 100644 index a0b5ea9..0000000 --- a/mqtt-bridge/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -paho-mqtt==1.6.1 -pymysql==0.10.1 -redis==3.5.3 diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index da4ca2e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,2 +0,0 @@ -[tool.ruff] -line-length = 120