├── .github └── workflows │ └── build.yml ├── .golangci.yml ├── LICENSE ├── README.grafana.png ├── README.md ├── cmd ├── common.go ├── ve-ess-shelly │ ├── controller.go │ ├── controller_test.go │ ├── inverter.go │ ├── main.go │ ├── meterReader.go │ └── pid.go ├── ve-shell │ ├── commands.go │ └── main.go └── ve-sim-shelly3em │ └── main.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum └── pkg ├── mk2 ├── mk2.go ├── mk2ess.go └── mk2io.go ├── ringbuf ├── ringbuf.go └── ringbuf_test.go ├── shelly ├── gen2meterem.go └── gen2meterem_test.go └── vebus ├── constants.go ├── types.go └── types_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | on: 3 | pull_request: 4 | push: 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2.4.0 10 | - uses: cachix/install-nix-action@v15 11 | with: 12 | extra_nix_config: | 13 | access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} 14 | system-features = nixos-test benchmark big-parallel kvm 15 | - run: nix build 16 | - run: nix flake check 17 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - bodyclose 5 | - errorlint 6 | - godot 7 | - lll 8 | - nakedret 9 | - predeclared 10 | - revive 11 | - staticcheck 12 | - usestdlibvars 13 | - whitespace 14 | exclusions: 15 | generated: lax 16 | presets: 17 | - comments 18 | - common-false-positives 19 | - legacy 20 | - std-error-handling 21 | paths: 22 | - third_party$ 23 | - builtin$ 24 | - examples$ 25 | formatters: 26 | enable: 27 | - gofmt 28 | - gofumpt 29 | - goimports 30 | exclusions: 31 | generated: lax 32 | paths: 33 | - third_party$ 34 | - builtin$ 35 | - examples$ 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yves Fischer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.grafana.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesf/ve-ctrl-tool/2b225a2dcf0e7f79247439507b05796f9ee1915f/README.grafana.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ve-ctrl-tool (Victron Energy VE.Bus MK2 protocol tool) 2 | 3 | this is a library and cli tools to interact with Victron (https://www.victronenergy.com/) devices 4 | over the Mk3 adapter. 5 | 6 | The author and the project are not affiliated with the Victron Company. The brand name is used 7 | only in educational context. Everything here is shared for educational purpose only and 8 | for use at your own risk only. 9 | 10 | ## Code structure 11 | 12 | - `cmd/` commands/servers/entrypoints 13 | - `pkg/` potentially re-usable packages. 14 | 15 | ## Usage 16 | 17 | Interactive mode: 18 | 19 | ```shell 20 | $ go run ./cmd/ve-shell 21 | Mk2> read-ram 1 22 | value=14 value=0b1110 value=0xe 23 | Mk2> (exit with EOF / CTRL-D) 24 | ``` 25 | 26 | Commandline invocation: 27 | 28 | ```shell 29 | $ go run ./cmd/ve-shell read-ram 1 30 | value=14 value=0b1110 value=0xe 31 | ``` 32 | 33 | Run the `help` command to get a list of commands. 34 | 35 | ## Run with Shelly 3em 36 | 37 | ```shell 38 | go run ./cmd/ve-ess-shelly http://10.1....shelly-address 39 | ``` 40 | 41 | Monitoring: 42 | 43 | ```shell 44 | $ watch -n 0.1 bash -c '"curl -s localhost:18001/metrics | grep -v -E '^#' | sort"' 45 | ``` 46 | 47 | Screenshot of monitoring of a 12V Multiplus (smallest available model): 48 | 49 | ![](README.grafana.png) 50 | 51 | ## NixOS flake 52 | 53 | Configure NixOS Module: 54 | ```nix 55 | { 56 | services.ve-ess-shelly = { 57 | enable = true; 58 | maxInverter = 120; 59 | shellyEM3 = "http://shellyem3-.1.localnet.cc"; 60 | }; 61 | } 62 | ``` 63 | 64 | 65 | -------------------------------------------------------------------------------- /cmd/common.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/bsm/openmetrics" 14 | "github.com/bsm/openmetrics/omhttp" 15 | "github.com/yvesf/ve-ctrl-tool/pkg/mk2" 16 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 17 | ) 18 | 19 | var ( 20 | flagSerialDevice = flag.String("serialDevice", "/dev/ttyUSB0", "Device") 21 | flagLow = flag.Bool("low", false, "Do not attempt to upgrade to 115200 baud") 22 | flagVEAddress = flag.Int("veAddress", 0, "Set other address than 0") 23 | flagDebug = flag.Bool("debug", false, "Set log level to debug") 24 | flagMetricsHTTP = flag.String("metricsHTTP", "", "Address of a http server serving metrics under /metrics") 25 | ) 26 | 27 | func CommonInit(ctx context.Context) *mk2.Adapter { 28 | flag.Parse() 29 | 30 | logLevel := slog.LevelInfo 31 | if *flagDebug { 32 | logLevel = slog.LevelDebug 33 | } 34 | h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}) 35 | slog.SetDefault(slog.New(h)) 36 | 37 | // Metrics HTTP endpoint 38 | if *flagMetricsHTTP != `` { 39 | mux := http.NewServeMux() 40 | mux.Handle("/metrics", omhttp.NewHandler(openmetrics.DefaultRegistry())) 41 | 42 | var lc net.ListenConfig 43 | ln, err := lc.Listen(ctx, "tcp", *flagMetricsHTTP) 44 | if err != nil { 45 | slog.Error("Listen on http failed", slog.String("addr", *flagMetricsHTTP)) 46 | os.Exit(1) 47 | } 48 | 49 | srv := &http.Server{Handler: mux} 50 | go func() { 51 | err := srv.Serve(ln) 52 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 53 | slog.Error("http server failed", slog.Any("err", err)) 54 | os.Exit(1) 55 | } 56 | }() 57 | } 58 | 59 | mk2, err := mk2.NewAdapter(*flagSerialDevice) 60 | if err != nil { 61 | panic(err) 62 | } 63 | 64 | // reset both in high and low speed 65 | err = mk2.SetBaudHigh() 66 | if err != nil { 67 | panic(err) 68 | } 69 | mk2.Write(vebus.CommandR.Frame().Marshal()) 70 | time.Sleep(time.Second * 1) 71 | err = mk2.SetBaudLow() 72 | if err != nil { 73 | panic(err) 74 | } 75 | 76 | // The following is supposed to switch the MK3 adapter to High-Speed mode. 77 | // This is undocumented and may break, therefore the switch to skip it. 78 | if !*flagLow { 79 | err := mk2.UpgradeHighSpeed() 80 | if err != nil { 81 | panic(err) 82 | } 83 | } 84 | 85 | err = mk2.StartReader() 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | err = mk2.SetAddress(ctx, byte(*flagVEAddress)) 91 | if err != nil { 92 | panic(err) 93 | } 94 | 95 | return mk2 96 | } 97 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "math" 8 | "time" 9 | 10 | "github.com/bsm/openmetrics" 11 | ) 12 | 13 | var metricControlInput = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 14 | Name: "ess_pid_input", 15 | Unit: "watt", 16 | Help: "The current input for the PID controller", 17 | }) 18 | 19 | type ESSControl interface { 20 | Stats(ctx context.Context) (EssStats, error) 21 | SetpointSet(ctx context.Context, value int16) error 22 | SetZero(ctx context.Context) error 23 | } 24 | 25 | // Run starts the control loop. 26 | // The control loop is blocking and can be stopped by cancelling ctx. 27 | func RunController(ctx context.Context, ess ESSControl, meter *meterReader) error { 28 | var ( 29 | pidLastUpdateAt time.Time 30 | lastStatsUpdateAt time.Time 31 | lastSetpointWrittenAt time.Time 32 | lastSetpointValue float64 33 | ) 34 | 35 | pidC := NewPIDWithMetrics(0.15, 0.1, 0.15) 36 | pidC.SetOutputLimits(-1*float64(*SettingsMaxWattCharge), float64(*SettingsMaxWattInverter)) 37 | 38 | controlLoop: 39 | for { 40 | select { 41 | case <-ctx.Done(): 42 | break controlLoop 43 | case <-time.After(time.Millisecond * 25): 44 | } 45 | 46 | m, lastMeasurement := meter.LastMeasurement() 47 | if lastMeasurement.IsZero() || time.Since(lastMeasurement) > time.Second*10 { 48 | slog.Info("no energy meter information", slog.Time("lastMeasurement", lastMeasurement)) 49 | err := ess.SetZero(ctx) 50 | if err != nil { 51 | return err 52 | } 53 | continue 54 | } 55 | 56 | controllerInputM := m.ConsumptionNegative() + float64(*SettingsPowerOffset) 57 | metricControlInput.With().Set(controllerInputM) 58 | 59 | if pidLastUpdateAt.IsZero() { 60 | pidLastUpdateAt = time.Now() 61 | } 62 | // Take consumption negative to regulate to 0. 63 | controllerOut := pidC.UpdateDuration(controllerInputM, time.Since(pidLastUpdateAt)) 64 | pidLastUpdateAt = time.Now() 65 | 66 | // round PID output to reduce the need for updating the setpoint for marginal changes. 67 | controllerOut = math.Round(controllerOut/float64(*SettingsSetpointRounding)) * float64(*SettingsSetpointRounding) 68 | 69 | // output zero around values +/- 10 around the control point. 70 | if controllerOut > -1*float64(*SettingsZeroPointWindow) && controllerOut < float64(*SettingsZeroPointWindow) { 71 | controllerOut = 0 72 | } 73 | 74 | // only update the ESS if 75 | // - value is different from last update. 76 | // - the value haven't been updated yet. 77 | // - 15 seconds passed. We have to write about every 30s to not let the ESS shutdown for safety reasons. 78 | if controllerOut != lastSetpointValue || 79 | lastSetpointWrittenAt.IsZero() || 80 | time.Since(lastSetpointWrittenAt) > time.Second*20 { 81 | err := ess.SetpointSet(ctx, int16(controllerOut)) 82 | if err != nil { 83 | return fmt.Errorf("failed to write ESS setpoint: %w", err) 84 | } 85 | 86 | lastSetpointValue = controllerOut 87 | lastSetpointWrittenAt = time.Now() 88 | } 89 | 90 | // collect statistics only every 10 seconds. 91 | if lastStatsUpdateAt.IsZero() || time.Since(lastStatsUpdateAt) > time.Second*10 { 92 | _, err := ess.Stats(ctx) 93 | if err != nil { 94 | return fmt.Errorf("failed to read ESS stats: %w", err) 95 | } 96 | lastStatsUpdateAt = time.Now() 97 | } 98 | } 99 | 100 | slog.Info("shutdown: reset ESS setpoint to 0") 101 | ctxSetpoint, cancel := context.WithTimeout(context.Background(), time.Second*10) 102 | defer cancel() 103 | 104 | err := ess.SetpointSet(ctxSetpoint, 0) 105 | if err != nil { 106 | return fmt.Errorf("failed to reset ESS setpoint to zero: %w", err) 107 | } 108 | 109 | return ctx.Err() 110 | } 111 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/controller_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | ) 9 | 10 | type essMock struct { 11 | setpointSet func(context.Context, int16) error 12 | stats func(context.Context) (EssStats, error) 13 | } 14 | 15 | func (m *essMock) SetpointSet(ctx context.Context, value int16) error { 16 | return m.setpointSet(ctx, value) 17 | } 18 | 19 | func (m *essMock) Stats(ctx context.Context) (EssStats, error) { 20 | return m.stats(ctx) 21 | } 22 | 23 | func (m *essMock) SetZero(context.Context) error { 24 | return nil 25 | } 26 | 27 | func TestRunController__exitOnCancelledContext(t *testing.T) { 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | cancel() 30 | essMock := &essMock{} 31 | essMock.setpointSet = func(_ context.Context, value int16) error { 32 | be.Equal(t, 0, value) 33 | return nil 34 | } 35 | 36 | err := RunController(ctx, essMock, nil) 37 | be.Equal(t, context.Canceled, err) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/inverter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/bsm/openmetrics" 9 | "github.com/yvesf/ve-ctrl-tool/pkg/mk2" 10 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 11 | ) 12 | 13 | var ( 14 | metricMultiplusSetpoint = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 15 | Name: "ess_multiplus_setpoint", 16 | Unit: "watt", 17 | Help: "The setpoint written to the multiplus", 18 | }) 19 | metricMultiplusIBat = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 20 | Name: "ess_multiplus_ibat", 21 | Unit: "ampere", 22 | Help: "Current of the multiplus battery, negative=discharge", 23 | }) 24 | metricMultiplusUBat = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 25 | Name: "ess_multiplus_ubat", 26 | Unit: "voltage", 27 | Help: "Voltage of the multiplus battery", 28 | }) 29 | metricMultiplusInverterPower = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 30 | Name: "ess_multiplus_inverter_power", 31 | Unit: "watt", 32 | Help: "Ram InverterPower1", 33 | }) 34 | ) 35 | 36 | type EssStats struct { 37 | IBat, UBat float64 38 | InverterPower int 39 | } 40 | 41 | // inverter implements the ESSControl interface. It supports controlling the setpoint and gathering status information 42 | // on the Victron ESS via mk2. 43 | type inverter struct { 44 | adapter *mk2.AdapterWithESS 45 | } 46 | 47 | func (m inverter) SetpointSet(ctx context.Context, value int16) error { 48 | metricMultiplusSetpoint.With().Set(float64(value)) 49 | return m.adapter.SetpointSet(ctx, value) 50 | } 51 | 52 | func (m inverter) SetZero(ctx context.Context) error { 53 | err := m.adapter.SetpointSet(ctx, 0) 54 | if err != nil { 55 | return err 56 | } 57 | metricMultiplusSetpoint.With().Reset(openmetrics.GaugeOptions{}) 58 | return nil 59 | } 60 | 61 | func (m inverter) Stats(ctx context.Context) (EssStats, error) { 62 | iBat, uBat, err := m.adapter.CommandReadRAMVarSigned16(ctx, vebus.RAMIDIBat, vebus.RAMIDUBat) 63 | if err != nil { 64 | return EssStats{}, fmt.Errorf("failed to read IBat/UBat: %w", err) 65 | } 66 | 67 | inverterPowerRAM, _, err := m.adapter.CommandReadRAMVarSigned16(ctx, vebus.RAMIDInverterPower1, 0) 68 | if err != nil { 69 | return EssStats{}, fmt.Errorf("failed to read InverterPower1: %w", err) 70 | } 71 | 72 | slog.Debug("multiplus stats", slog.Float64("IBat", float64(iBat)/10), 73 | slog.Float64("UBat", float64(uBat)/100), 74 | slog.Float64("InverterPower", float64(inverterPowerRAM))) 75 | 76 | stats := EssStats{ 77 | IBat: float64(iBat) / 10, 78 | UBat: float64(uBat) / 100, 79 | InverterPower: int(inverterPowerRAM), 80 | } 81 | 82 | metricMultiplusIBat.With().Set(float64(stats.IBat)) 83 | metricMultiplusUBat.With().Set(float64(stats.UBat)) 84 | metricMultiplusInverterPower.With().Set(float64(stats.InverterPower)) 85 | 86 | return stats, nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/yvesf/ve-ctrl-tool/cmd" 14 | "github.com/yvesf/ve-ctrl-tool/pkg/mk2" 15 | "github.com/yvesf/ve-ctrl-tool/pkg/shelly" 16 | ) 17 | 18 | var ( 19 | // MaxWattCharge [Watt] is the maximum power to charge the battery (negative ESS setpoint). 20 | SettingsMaxWattCharge = flag.Int("maxCharge", 250.0, "Maximum ESS Setpoint for charging (negative setpoint)") 21 | // MaxWattInverter [Watt] is the maximum power to generate (positive ESS setpoint). 22 | SettingsMaxWattInverter = flag.Int("maxInverter", 60.0, "Maximum ESS Setpoint for inverter (positive setpoint)") 23 | // PowerOffset [Watt] is a constant offset applied to the metered power flow. 24 | SettingsPowerOffset = flag.Int("offset", -4.0, "Power measurement offset") 25 | // SetpointRounding [Watt] is applied on the calculated setpoint to also lower the amount of ESS Communication. 26 | SettingsSetpointRounding = flag.Int("setpointRounding", 3.0, "Round setpoint to this step") 27 | // ZeroPointWindow [Watt] is a power window around zero in which no change is applied to lower the 28 | // amount of ESS communication. 29 | SettingsZeroPointWindow = flag.Int("zeroWindow", 10.0, "Do not operate if measurement is in this +/- window") 30 | ) 31 | 32 | func main() { 33 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 34 | defer cancel() 35 | 36 | adapter := cmd.CommonInit(ctx) 37 | 38 | mk2Ess, err := mk2.ESSInit(ctx, adapter) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | shelly := shelly.Gen2Meter{Addr: flag.Args()[0], Client: http.DefaultClient} 44 | m := &meterReader{Meter: shelly} 45 | 46 | var meterError error 47 | go func() { 48 | meterError = m.Run(ctx) 49 | cancel() 50 | }() 51 | 52 | err = RunController(ctx, &inverter{adapter: mk2Ess}, m) 53 | if err != nil && !errors.Is(err, context.Canceled) { 54 | slog.Error("run failed", slog.Any("err", err)) 55 | os.Exit(1) 56 | } 57 | 58 | if meterError != nil { 59 | slog.Error("reading from meter failed", slog.Any("err", err)) 60 | os.Exit(1) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/meterReader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "math" 8 | "math/rand/v2" 9 | "sync" 10 | "time" 11 | 12 | "github.com/bsm/openmetrics" 13 | "github.com/yvesf/ve-ctrl-tool/pkg/ringbuf" 14 | "github.com/yvesf/ve-ctrl-tool/pkg/shelly" 15 | ) 16 | 17 | var metricShellyPower = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 18 | Name: "ess_shelly_power", 19 | Unit: "watt", 20 | Help: "Power readings from shelly device", 21 | Labels: []string{"meter"}, 22 | }) 23 | 24 | // PowerFlowWatt type represent power that can flow in two directions: Production and Consumption 25 | // Flow is represented by positive/negative values. 26 | type PowerFlowWatt float64 27 | 28 | func ConsumptionPositive(watt float64) PowerFlowWatt { 29 | return PowerFlowWatt(watt) 30 | } 31 | 32 | func (p PowerFlowWatt) String() string { 33 | if p < 0 { 34 | return fmt.Sprintf("Production(%.2f)", -1*p) 35 | } 36 | return fmt.Sprintf("Consumption(%.2f)", p) 37 | } 38 | 39 | func (p PowerFlowWatt) ConsumptionPositive() float64 { 40 | return float64(p) 41 | } 42 | 43 | func (p PowerFlowWatt) ConsumptionNegative() float64 { 44 | return float64(-p) 45 | } 46 | 47 | // meterReader implements the EnergyMeter interface using the Shelly 3 EM. 48 | type meterReader struct { 49 | Meter shelly.Gen2Meter 50 | lock sync.Mutex 51 | lastMeasurement PowerFlowWatt 52 | time time.Time 53 | } 54 | 55 | // Run blocks until context is concelled or error occurs. 56 | func (m *meterReader) Run(ctx context.Context) error { 57 | const ( 58 | shellyReadInterval = time.Millisecond * 800 59 | backoffStart = shellyReadInterval 60 | backoffMax = 50 * shellyReadInterval 61 | ) 62 | 63 | t := time.NewTimer(0) 64 | defer slog.Debug("meterReader go-routine done") 65 | defer t.Stop() 66 | 67 | buf := ringbuf.NewRingbuf(5) 68 | retry := 0 69 | 70 | for { 71 | select { 72 | case <-t.C: 73 | value, err := m.Meter.Read() 74 | if err != nil { 75 | retry++ 76 | m.lock.Lock() 77 | m.time = time.Time{} // set invalid 78 | m.lock.Unlock() 79 | 80 | wait := time.Duration((1.0+rand.Float64())* // random 1..2 81 | float64(backoffStart.Milliseconds())* 82 | math.Pow(2, float64(retry))) * time.Millisecond 83 | if wait >= backoffMax { 84 | return fmt.Errorf("meterReader out of retries: %w", err) 85 | } 86 | slog.Error("failed to read from shelly, retry", slog.Duration("wait", wait), slog.Any("err", err)) 87 | t.Reset(wait) 88 | continue 89 | } 90 | retry = 0 91 | 92 | buf.Add(value.TotalPower()) 93 | mean := buf.Mean() 94 | metricShellyPower.With("totalMean").Set(mean) 95 | 96 | m.lock.Lock() 97 | m.time = time.Now() 98 | m.lastMeasurement = ConsumptionPositive(mean) 99 | m.lock.Unlock() 100 | 101 | t.Reset(shellyReadInterval) 102 | case <-ctx.Done(): 103 | return nil 104 | } 105 | } 106 | } 107 | 108 | // LastMeasurement returns the last known power measurement. If time is Zero then value is invalid. 109 | // The "Run" function needs to run within a goroutine to update the value returned here. 110 | func (m *meterReader) LastMeasurement() (value PowerFlowWatt, time time.Time) { 111 | m.lock.Lock() 112 | defer m.lock.Unlock() 113 | return m.lastMeasurement, m.time 114 | } 115 | -------------------------------------------------------------------------------- /cmd/ve-ess-shelly/pid.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bsm/openmetrics" 7 | "github.com/felixge/pidctrl" 8 | ) 9 | 10 | var ( 11 | metricControlSetpoint = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 12 | Name: "ess_pid_setpoint", 13 | Unit: "watt", 14 | Help: "The current setpoint calculated by the PID controller", 15 | }) 16 | metricControlPIDMin = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 17 | Name: "ess_pid_output_min", 18 | Unit: "watt", 19 | Help: "PID min", 20 | }) 21 | metricControlPIDMax = openmetrics.DefaultRegistry().Gauge(openmetrics.Desc{ 22 | Name: "ess_pid_output_max", 23 | Unit: "watt", 24 | Help: "PID max", 25 | }) 26 | ) 27 | 28 | type PIDWithMetrics struct { 29 | *pidctrl.PIDController 30 | } 31 | 32 | func NewPIDWithMetrics(p, i, d float64) PIDWithMetrics { 33 | return PIDWithMetrics{ 34 | PIDController: pidctrl.NewPIDController(p, i, d), 35 | } 36 | } 37 | 38 | func (p PIDWithMetrics) SetOutputLimits(valueMin, valueMax float64) { 39 | p.PIDController.SetOutputLimits(valueMin, valueMax) 40 | metricControlPIDMin.With().Set(valueMin) 41 | metricControlPIDMax.With().Set(valueMax) 42 | } 43 | 44 | func (p PIDWithMetrics) UpdateDuration(value float64, duration time.Duration) float64 { 45 | out := p.PIDController.UpdateDuration(value, duration) 46 | metricControlSetpoint.With().Set(out) 47 | return out 48 | } 49 | -------------------------------------------------------------------------------- /cmd/ve-shell/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/yvesf/ve-ctrl-tool/pkg/mk2" 14 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 15 | ) 16 | 17 | type c struct { 18 | command string 19 | args int 20 | fun func(ctx context.Context, adapter *mk2.Adapter, args ...string) error 21 | help string 22 | } 23 | 24 | var commands []c 25 | 26 | func init() { 27 | commands = []c{ 28 | { 29 | command: "help", 30 | args: 0, 31 | help: "help display this help", 32 | fun: func(context.Context, *mk2.Adapter, ...string) error { help(); return nil }, 33 | }, 34 | { 35 | command: "state", 36 | args: 0, 37 | help: "state (CommandGetSetDeviceState)", 38 | fun: func(ctx context.Context, adapter *mk2.Adapter, _ ...string) error { 39 | state, subState, err := adapter.CommandGetSetDeviceState(ctx, mk2.DeviceStateRequestStateInquiry) 40 | if err != nil { 41 | return fmt.Errorf("state command failed: %w", err) 42 | } 43 | println("device state", state, subState) 44 | 45 | return nil 46 | }, 47 | }, 48 | { 49 | command: "reset", 50 | args: 0, 51 | help: "reset requests sends \"R\" to request a device reset", 52 | fun: func(ctx context.Context, adapter *mk2.Adapter, _ ...string) error { 53 | _, _ = vebus.CommandR.Frame().WriteAndRead(ctx, adapter) 54 | time.Sleep(time.Second * 1) 55 | println("reset finished") 56 | return nil 57 | }, 58 | }, 59 | { 60 | command: "set-state", 61 | args: 1, 62 | help: "set-state 0|1|2|3 (CommandGetSetDeviceState)", 63 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 64 | if len(args) != 1 { 65 | return fmt.Errorf("wrong number of args") 66 | } 67 | setState, err := strconv.Atoi(args[0]) 68 | if err != nil { 69 | return fmt.Errorf("invalid argument format") 70 | } 71 | mainState, subState, err := adapter.CommandGetSetDeviceState(ctx, mk2.DeviceStateRequestState(setState)) 72 | if err != nil { 73 | return fmt.Errorf("command set-state failed: %w", err) 74 | } 75 | println("device state", mainState, subState) 76 | return nil 77 | }, 78 | }, 79 | { 80 | command: "read-setting", 81 | args: 2, 82 | help: "read-setting (CommandReadSetting)", 83 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 84 | if len(args) != 2 { 85 | return fmt.Errorf("wrong number of args") 86 | } 87 | low, err := strconv.Atoi(args[0]) 88 | if err != nil { 89 | return fmt.Errorf("low byte") 90 | } 91 | high, err := strconv.Atoi(args[1]) 92 | if err != nil { 93 | return fmt.Errorf("high byte") 94 | } 95 | lowValue, highValue, err := adapter.CommandReadSetting(ctx, byte(low), byte(high)) 96 | if err != nil { 97 | return fmt.Errorf("command read-setting failed: %w", err) 98 | } 99 | fmt.Printf("value=%d low=0x%x high=0x%x low=0b%b high=0b%b\n", 100 | int(lowValue)+int(highValue)<<8, lowValue, highValue, lowValue, highValue) 101 | return nil 102 | }, 103 | }, 104 | { 105 | command: "read-ram", 106 | args: 1, 107 | help: "read-ram (CommandReadRAMVar)", 108 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 109 | if len(args) != 1 { 110 | return fmt.Errorf("wrong no of args") 111 | } 112 | var ramIDs []byte 113 | for _, arg := range strings.Split(args[0], ",") { 114 | v, err := strconv.ParseUint(arg, 10, 8) 115 | if err != nil { 116 | return fmt.Errorf("failed to parse ramid: %w", err) 117 | } 118 | ramIDs = append(ramIDs, byte(v)) 119 | } 120 | if len(ramIDs) == 1 { 121 | ramIDs = append(ramIDs, 0) 122 | } 123 | 124 | value0, value1, err := adapter.CommandReadRAMVarUnsigned16(ctx, ramIDs[0], ramIDs[1]) 125 | if err != nil { 126 | return fmt.Errorf("read-ram command failed: %w", err) 127 | } 128 | fmt.Printf("value0=%d value0(signed)=%d value0=0b%b value0=0x%x\n", 129 | value0, vebus.ParseSigned16(value0), value0, value0) 130 | fmt.Printf("value1=%d value1(signed)=%d value1=0b%b value1=0x%x\n", 131 | value1, vebus.ParseSigned16(value1), value1, value1) 132 | return nil 133 | }, 134 | }, 135 | { 136 | command: "write-ram-signed", 137 | args: 2, 138 | help: "write-ram-signed (CommandWriteViaID)", 162 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 163 | if len(args) != 3 { 164 | return fmt.Errorf("wrong number of args") 165 | } 166 | ramID, err := strconv.Atoi(args[0]) 167 | if err != nil { 168 | return fmt.Errorf("parse ram-id failed: %w", err) 169 | } 170 | low, err := strconv.Atoi(args[1]) 171 | if err != nil { 172 | return fmt.Errorf("parse low-byte failed: %w", err) 173 | } 174 | high, err := strconv.Atoi(args[2]) 175 | if err != nil { 176 | return fmt.Errorf("parse high-byte failed: %w", err) 177 | } 178 | err = adapter.CommandWriteViaID(ctx, byte(ramID), byte(low), byte(high)) 179 | if err != nil { 180 | return fmt.Errorf("write-ram failed: %w", err) 181 | } 182 | return nil 183 | }, 184 | }, 185 | { 186 | command: "write-setting", 187 | args: 3, 188 | help: "write-setting (CommandWriteSettingData)", 189 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 190 | if len(args) != 3 { 191 | return fmt.Errorf("wrong number of args") 192 | } 193 | settingID, err := strconv.Atoi(args[0]) 194 | if err != nil { 195 | return fmt.Errorf("parse setting-id failed: %w", err) 196 | } 197 | low, err := strconv.Atoi(args[1]) 198 | if err != nil { 199 | return fmt.Errorf("parse low-byte failed: %w", err) 200 | } 201 | high, err := strconv.Atoi(args[2]) 202 | if err != nil { 203 | return fmt.Errorf("parse high-byte failed: %w", err) 204 | } 205 | err = adapter.CommandWriteSettingData(ctx, uint16(settingID), byte(low), byte(high)) 206 | if err != nil { 207 | return fmt.Errorf("write-setting failed: %w", err) 208 | } 209 | return nil 210 | }, 211 | }, 212 | { 213 | command: "voltage", 214 | args: 0, 215 | help: "voltage shows voltage information from ram", 216 | fun: func(ctx context.Context, adapter *mk2.Adapter, _ ...string) error { 217 | uBat, uInverter, err := adapter.CommandReadRAMVarUnsigned16(ctx, vebus.RAMIDUBat, vebus.RAMIDUInverterRMS) 218 | if err != nil { 219 | return fmt.Errorf("voltage access UInverterRMS failed: %w", err) 220 | } 221 | fmt.Printf("UBat: %.2f Volt UInverter: %.2f\n", float32(uBat)/100, float32(uInverter)/100) 222 | return nil 223 | }, 224 | }, 225 | { 226 | command: "set-address", 227 | args: 1, 228 | help: "set-address selects the address (\"A\" command, default 0)", 229 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 230 | addr, err := strconv.Atoi(args[0]) 231 | if err != nil { 232 | return fmt.Errorf("parse addr failed: %w", err) 233 | } 234 | err = adapter.SetAddress(ctx, byte(addr)) 235 | if err != nil { 236 | return fmt.Errorf("set-address failed: %w", err) 237 | } 238 | return nil 239 | }, 240 | }, 241 | { 242 | command: "get-address", 243 | args: 0, 244 | help: "get-address gets the current address (\"A\" command)", 245 | fun: func(ctx context.Context, adapter *mk2.Adapter, _ ...string) error { 246 | addr, err := adapter.GetAddress(ctx) 247 | if err != nil { 248 | return fmt.Errorf("get-address failed: %w", err) 249 | } 250 | fmt.Printf("address=0x%02x\n", addr) 251 | return nil 252 | }, 253 | }, 254 | { 255 | command: "ess-static", 256 | args: 1, 257 | help: "ess-static (run loop sending signed value to ESS Ram)", 258 | fun: func(ctx context.Context, adapter *mk2.Adapter, args ...string) error { 259 | setpointWatt, err := strconv.Atoi(args[0]) 260 | if err != nil { 261 | return fmt.Errorf("parse high-byte failed: %w", err) 262 | } 263 | 264 | mk2Ess, err := mk2.ESSInit(ctx, adapter) 265 | if err != nil { 266 | return err 267 | } 268 | 269 | fmt.Printf("Press enter to stop\n") 270 | childCtx, cancel := context.WithCancel(ctx) 271 | defer cancel() 272 | go func() { 273 | x := []byte{0} 274 | _, _ = os.Stdin.Read(x) 275 | cancel() 276 | }() 277 | 278 | errors := 0 279 | for childCtx.Err() == nil { 280 | err := mk2Ess.SetpointSet(ctx, int16(setpointWatt)) 281 | if err != nil { 282 | return fmt.Errorf("failed to set ESS setpoint: %w", err) 283 | } 284 | select { 285 | case <-childCtx.Done(): 286 | case <-time.After(time.Millisecond * 500): 287 | } 288 | 289 | var UInverterRMS, IInverterRMS uint16 290 | var InverterPower14, OutputPower int16 291 | var UBattery, IBattery int16 292 | UInverterRMS, IInverterRMS, err = adapter.CommandReadRAMVarUnsigned16(ctx, 293 | vebus.RAMIDUInverterRMS, vebus.RAMIDIINverterRMS) 294 | if err != nil { 295 | slog.Error("voltage access UInverterRMS failed", slog.Any("err", err)) 296 | goto handleError 297 | } 298 | InverterPower14, OutputPower, err = adapter.CommandReadRAMVarSigned16(ctx, 299 | vebus.RAMIDInverterPower1, vebus.RAMIDOutputPower) 300 | if err != nil { 301 | slog.Error("voltage access InverterPower14 failed", slog.Any("err", err)) 302 | goto handleError 303 | } 304 | UBattery, IBattery, err = adapter.CommandReadRAMVarSigned16(ctx, vebus.RAMIDUBatRMS, vebus.RAMIDIBat) 305 | if err != nil { 306 | slog.Error("voltage access InverterPower14 failed", slog.Any("err", err)) 307 | goto handleError 308 | } 309 | 310 | fmt.Printf("UInverterRMS=%.2f V\n", float32(UInverterRMS)/100.0) 311 | fmt.Printf("IInverterRMS=%.2f A\n", float32(IInverterRMS)/100.0) 312 | fmt.Printf("InverterPower14=%d W\n", InverterPower14) 313 | fmt.Printf("OutputPower=%d W\n", OutputPower) 314 | fmt.Printf("UBatteryRMS=%d V\n", UBattery) 315 | fmt.Printf("IBattery=%.1f A\n", float32(IBattery)/10.0) 316 | errors = 0 317 | continue 318 | 319 | handleError: 320 | errors++ 321 | if errors > 20 { 322 | slog.Error("fail after 20 retries") 323 | break 324 | } 325 | select { 326 | case <-childCtx.Done(): 327 | case <-time.After(time.Second * 5): 328 | } 329 | } 330 | 331 | slog.Info("reset ESS to 0") 332 | err = mk2Ess.SetpointSet(ctx, 0) 333 | if err != nil { 334 | slog.Error("failed to reset ESS to 0", slog.Any("err", err)) 335 | } 336 | 337 | return nil 338 | }, 339 | }, 340 | } 341 | } 342 | 343 | func help() { 344 | fmt.Printf("CLI flags help:\n") 345 | flag.PrintDefaults() 346 | 347 | fmt.Printf("\nCommands help:\n") 348 | for _, c := range commands { 349 | fmt.Printf("\t%s\n", strings.ReplaceAll(c.help, "\n", "\n\t")) 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /cmd/ve-shell/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "os" 11 | "os/signal" 12 | "strings" 13 | 14 | "github.com/mattn/go-shellwords" 15 | "github.com/peterh/liner" 16 | 17 | "github.com/yvesf/ve-ctrl-tool/cmd" 18 | "github.com/yvesf/ve-ctrl-tool/pkg/mk2" 19 | ) 20 | 21 | func main() { 22 | flag.CommandLine.Usage = help 23 | 24 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 25 | defer cancel() 26 | 27 | adapter := cmd.CommonInit(ctx) 28 | defer adapter.Wait() 29 | defer adapter.Shutdown() 30 | 31 | line := liner.NewLiner() 32 | defer line.Close() 33 | 34 | // if arguments passed then execute as command 35 | if args := flag.Args(); len(args) > 0 { 36 | if err := execute(ctx, adapter, args); err != nil { 37 | slog.Error("failed", slog.Any("err", err)) 38 | } 39 | return 40 | } 41 | 42 | // otherwise: start repl 43 | line.SetCtrlCAborts(true) 44 | line.SetCompleter(func(line string) (c []string) { 45 | for _, comm := range commands { 46 | if strings.HasPrefix(comm.command, line) { 47 | c = append(c, comm.command) 48 | } 49 | } 50 | return c 51 | }) 52 | 53 | for ctx.Err() == nil { 54 | if response, err := line.Prompt("Mk2> "); err == nil { 55 | inputTokens, err := shellwords.Parse(response) 56 | if err != nil { 57 | slog.Error("failed to parse input", slog.Any("err", err)) 58 | continue 59 | } 60 | if len(inputTokens) == 0 { 61 | continue 62 | } 63 | if len(inputTokens) == 1 && inputTokens[0] == `quit` { 64 | cancel() 65 | 66 | break 67 | } 68 | err = execute(ctx, adapter, inputTokens) 69 | if err != nil { 70 | fmt.Printf("Error: %v\n", err) 71 | } 72 | if err == nil { 73 | line.AppendHistory(response) 74 | } 75 | } else if errors.Is(err, liner.ErrPromptAborted) { 76 | fmt.Printf("Send EOF (CTRL-D) or execute 'quit' to exit\n") 77 | continue 78 | } else if errors.Is(err, io.EOF) { 79 | fmt.Printf("\n") 80 | cancel() 81 | break 82 | } else { 83 | slog.Error("error reading line", slog.Any("err", err)) 84 | } 85 | } 86 | slog.Info("start shutdown") 87 | } 88 | 89 | func execute(ctx context.Context, mk2 *mk2.Adapter, tokens []string) error { 90 | for _, comm := range commands { 91 | if comm.command != tokens[0] { 92 | continue 93 | } 94 | if comm.args != 0 && comm.args != len(tokens)-1 { 95 | return fmt.Errorf("invalid number of arguments for command %v, expected %v got %v", 96 | comm.command, comm.args, len(tokens)-1) 97 | } 98 | err := comm.fun(ctx, mk2, tokens[1:]...) 99 | if err != nil { 100 | return fmt.Errorf("command failed %v: %w", tokens, err) 101 | } 102 | return nil 103 | } 104 | return fmt.Errorf("command not found: %v", tokens[0]) 105 | } 106 | -------------------------------------------------------------------------------- /cmd/ve-sim-shelly3em/main.go: -------------------------------------------------------------------------------- 1 | // package main implements a simulator for a shelly em 3 2 | package main 3 | 4 | import ( 5 | "bufio" 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "log/slog" 13 | "net/http" 14 | "os" 15 | "os/signal" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "sync/atomic" 20 | "syscall" 21 | 22 | "github.com/yvesf/ve-ctrl-tool/pkg/shelly" 23 | ) 24 | 25 | var ( 26 | flagListenAddr = flag.String("l", "0.0.0.0:8082", "Address (host:port) to listen on") 27 | currentValue = int64(0) 28 | ) 29 | 30 | func main() { 31 | flag.Parse() 32 | 33 | server := http.Server{ 34 | Addr: *flagListenAddr, 35 | Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 36 | var doc shelly.Gen2MeterData 37 | doc.TotalPowerFloat = float64(atomic.LoadInt64(¤tValue)) 38 | 39 | err := json.NewEncoder(w).Encode(doc) 40 | if err != nil { 41 | slog.Error("failed to encode json response", slog.Any("err", err)) 42 | } 43 | }), 44 | } 45 | 46 | ctx, cancel := signal.NotifyContext(context.Background(), 47 | syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGINT) 48 | defer cancel() 49 | 50 | go func() { 51 | if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 52 | panic(err) 53 | } 54 | }() 55 | 56 | var wg sync.WaitGroup 57 | wg.Add(1) 58 | go func() { 59 | defer wg.Done() 60 | <-ctx.Done() 61 | _ = server.Shutdown(context.Background()) 62 | }() 63 | 64 | reader := bufio.NewReader(os.Stdin) 65 | for ctx.Err() == nil { 66 | fmt.Printf("TotalPower=> ") 67 | l, err := reader.ReadString('\n') 68 | if errors.Is(err, io.EOF) { 69 | slog.Info("shutdown") 70 | cancel() 71 | break 72 | } 73 | if err != nil { 74 | slog.Error("failed to read line", slog.Any("err", err)) 75 | continue 76 | } 77 | l = strings.TrimSpace(l) 78 | value, err := strconv.ParseInt(l, 10, 64) 79 | if err != nil { 80 | slog.Error("failed to parse line", slog.Any("err", err)) 81 | continue 82 | } 83 | atomic.StoreInt64(¤tValue, value) 84 | } 85 | 86 | wg.Wait() 87 | } 88 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1667395993, 6 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1748889542, 21 | "narHash": "sha256-Hb4iMhIbjX45GcrgOp3b8xnyli+ysRPqAgZ/LZgyT5k=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "10d7f8d34e5eb9c0f9a0485186c1ca691d2c5922", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "id": "nixpkgs", 29 | "ref": "nixos-25.05", 30 | "type": "indirect" 31 | } 32 | }, 33 | "root": { 34 | "inputs": { 35 | "flake-utils": "flake-utils", 36 | "nixpkgs": "nixpkgs" 37 | } 38 | } 39 | }, 40 | "root": "root", 41 | "version": 7 42 | } 43 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs.nixpkgs.url = "nixpkgs/nixos-25.05"; 3 | inputs.flake-utils.url = "github:numtide/flake-utils"; 4 | 5 | outputs = { self, nixpkgs, flake-utils }: 6 | let 7 | packageDef = { buildGo124Module }: buildGo124Module { 8 | pname = "ve-ctrl-tool"; 9 | version = "0.0.1"; 10 | src = ./.; 11 | vendorHash = "sha256-g65fqfyVwmXT0pajkT6h2eUkSyz9RL9eVLHhl98s5Y8="; 12 | }; 13 | in 14 | flake-utils.lib.eachDefaultSystem 15 | (system: 16 | rec { 17 | packages = { ve-ctrl-tool = nixpkgs.legacyPackages.${system}.callPackage packageDef { }; }; 18 | defaultPackage = packages.ve-ctrl-tool; 19 | devShell = 20 | with import nixpkgs { inherit system; }; mkShell { 21 | packages = [ go_1_24 nixpkgs-fmt golangci-lint golangci-lint-langserver gofumpt ]; 22 | }; 23 | }) // { 24 | nixosModule = { pkgs, config, lib, ... }: 25 | let ve-ctrl-tool = pkgs.callPackage packageDef { }; in 26 | { 27 | options.services.ve-ess-shelly = { 28 | enable = lib.mkEnableOption "the multiplus + shelly controller"; 29 | shellyEM3 = lib.mkOption { 30 | type = lib.types.str; 31 | default = "10.1.0.210"; 32 | description = "Address of the shelly EM 3 Energy Meter"; 33 | }; 34 | metricsAddress = lib.mkOption { 35 | type = lib.types.str; 36 | default = "127.0.0.1:18001"; 37 | description = "address to listen on for serving /metrics requests"; 38 | }; 39 | serialDevice = lib.mkOption { 40 | type = lib.types.str; 41 | default = "/dev/ttyUSB0"; 42 | description = "MK3 device"; 43 | }; 44 | maxCharge = lib.mkOption { 45 | type = lib.types.nullOr lib.types.int; 46 | default = null; 47 | }; 48 | maxInverter = lib.mkOption { 49 | type = lib.types.nullOr lib.types.int; 50 | default = null; 51 | }; 52 | }; 53 | config = 54 | let 55 | cfg = config.services.ve-ess-shelly; 56 | in 57 | lib.mkIf cfg.enable { 58 | environment.systemPackages = [ ve-ctrl-tool ]; # add to system because it's handy for debugging 59 | systemd.services.ve-ess-shelly = { 60 | description = "the multiplus + shelly controller"; 61 | wantedBy = [ "default.target" ]; 62 | unitConfig = { 63 | StartLimitInterval = 0; 64 | }; 65 | serviceConfig = { 66 | ExecStart = '' 67 | ${ve-ctrl-tool}/bin/ve-ess-shelly \ 68 | -metricsHTTP "${cfg.metricsAddress}" \ 69 | ${lib.optionalString (cfg.maxCharge != null) "-maxCharge ${toString cfg.maxCharge}"} \ 70 | ${lib.optionalString (cfg.maxInverter != null) "-maxInverter ${toString cfg.maxInverter}"} \ 71 | "${cfg.shellyEM3}" 72 | ''; 73 | LockPersonality = true; 74 | CapabilityBoundingSet = ""; 75 | DynamicUser = true; 76 | Group = "dialout"; 77 | MemoryDenyWriteExecute = true; 78 | NoNewPrivileges = true; 79 | PrivateUsers = true; 80 | ProtectClock = true; 81 | ProtectControlGroups = true; 82 | ProtectHome = true; 83 | ProtectHostname = true; 84 | ProtectKernelLogs = true; 85 | ProtectKernelModules = true; 86 | ProtectKernelTunables = true; 87 | ProtectProc = "noaccess"; 88 | ProtectSystem = "strict"; 89 | RemoveIPC = true; 90 | Restart = "always"; 91 | RestartSec = "10s"; 92 | RestrictAddressFamilies = "AF_INET AF_INET6"; 93 | RestrictNamespaces = true; 94 | RestrictRealtime = true; 95 | RestrictSUIDSGID = true; 96 | SystemCallArchitectures = "native"; 97 | SystemCallErrorNumber = "EPERM"; 98 | SystemCallFilter = [ "@system-service" ]; 99 | UMask = "0007"; 100 | }; 101 | }; 102 | }; 103 | }; 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yvesf/ve-ctrl-tool 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/bsm/openmetrics v0.3.1 9 | github.com/carlmjohnson/be v0.24.1 10 | github.com/felixge/pidctrl v0.0.0-20160307080219-7b13bcae7243 11 | github.com/goburrow/serial v0.1.0 12 | github.com/mattn/go-shellwords v1.0.12 13 | github.com/peterh/liner v1.2.2 14 | ) 15 | 16 | require ( 17 | github.com/mattn/go-runewidth v0.0.16 // indirect 18 | github.com/rivo/uniseg v0.4.7 // indirect 19 | golang.org/x/sys v0.33.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bsm/openmetrics v0.3.1 h1:nhR6QgaKaDmnbnvVP9R0JyPExt8Qa+n1cJk/ouGC4FY= 2 | github.com/bsm/openmetrics v0.3.1/go.mod h1:tabLMhjVjhdhFuwm9YenEVx0s54uvu56faEwYgD6L2g= 3 | github.com/carlmjohnson/be v0.24.1 h1:QNG+beMZHF6AZsElCrf7S4fVGa0EDtQGkXQiBFPuDZc= 4 | github.com/carlmjohnson/be v0.24.1/go.mod h1:KAgPUh0HpzWYZZI+IABdo80wTgY43YhbdsiLYAaSI/Q= 5 | github.com/felixge/pidctrl v0.0.0-20160307080219-7b13bcae7243 h1:QMnlBy37k7MuFqwzUQsbsALRyoKQidWlOv5zNUmqj3w= 6 | github.com/felixge/pidctrl v0.0.0-20160307080219-7b13bcae7243/go.mod h1:YjeiQT/MWPDtPKgk/UAVJ9ZvZLSczA9gobU202W8gPY= 7 | github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA= 8 | github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= 9 | github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 10 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 11 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 12 | github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= 13 | github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= 14 | github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= 15 | github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= 16 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 17 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 18 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 19 | golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 20 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 21 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 22 | -------------------------------------------------------------------------------- /pkg/mk2/mk2.go: -------------------------------------------------------------------------------- 1 | package mk2 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 10 | ) 11 | 12 | // Adapter wraps Mk2IO and adds generic functions to execute commands. 13 | type Adapter struct { 14 | *IO 15 | } 16 | 17 | func NewAdapter(address string) (*Adapter, error) { 18 | reader, err := NewReader(address) 19 | if err != nil { 20 | return nil, err 21 | } 22 | return &Adapter{reader}, nil 23 | } 24 | 25 | // SetAddress selects VE.Bus device at 'address'. 26 | func (m Adapter) SetAddress(ctx context.Context, address byte) error { 27 | slog.Debug(fmt.Sprintf("SetAddress 0x%x", address)) 28 | // 0x01 means "set" 29 | frame, err := vebus.CommandA.Frame(0x01, address).WriteAndRead(ctx, m) 30 | if err != nil { 31 | return fmt.Errorf("failed to select address: %w", err) 32 | } 33 | if frame.Data[0] != 0x01 { 34 | return fmt.Errorf("return action %v is not 0x01", frame.Data[0]) 35 | } 36 | if frame.Data[1] != address { 37 | return fmt.Errorf("return address %v is not the requested one %v", frame.Data[1], address) 38 | } 39 | slog.Debug(fmt.Sprintf("SetAddress selected 0x%x", address)) 40 | 41 | return nil 42 | } 43 | 44 | func (m Adapter) GetAddress(ctx context.Context) (byte, error) { 45 | slog.Debug("GetAddress") 46 | // 0x00 means "not set" 47 | frame, err := vebus.CommandA.Frame(0x00 /*ignored:*/, 0x00).WriteAndRead(ctx, m) 48 | if err != nil { 49 | return 0, fmt.Errorf("failed to select address: %w", err) 50 | } 51 | slog.Debug(fmt.Sprintf("GetAddress selected 0x%x", frame.Data[0])) 52 | 53 | return frame.Data[0], nil 54 | } 55 | 56 | type DeviceStateRequestState byte 57 | 58 | const ( 59 | DeviceStateRequestStateInquiry = 0x0 60 | DeviceStateRequestStateForceToEqualise = 0x1 61 | DeviceStateRequestStateForceToAbsorption = 0x2 62 | DeviceStateRequestStateForceToFloat = 0x3 63 | ) 64 | 65 | type DeviceStateResponseState string 66 | 67 | var DeviceStateResponseStates = map[int]DeviceStateResponseState{ 68 | -1: "", 69 | 0x00: "down", 70 | 0x01: "startup", 71 | 0x02: "off", 72 | 0x03: "slave-mode", 73 | 0x04: "invert-full", 74 | 0x05: "invert-half", 75 | 0x06: "invert-aes", 76 | 0x07: "power-assist", 77 | 0x08: "bypass", 78 | 0x09: "charge", 79 | } 80 | 81 | type DeviceStateResponseSubState string 82 | 83 | var DeviceStateResponseSubStates = map[int]DeviceStateResponseSubState{ 84 | -1: "", 85 | 0x00: "init", 86 | 0x01: "bulk", 87 | 0x02: "absorption", 88 | 0x03: "float", 89 | 0x04: "storage", 90 | 0x05: "repeated-absorption", 91 | 0x06: "forced-absorption", 92 | 0x07: "equalise", 93 | 0x08: "bulk-stopped", 94 | } 95 | 96 | // CommandGetSetDeviceState is used to read the state of the device or to force the unit to go into a specific state. 97 | // Passing 'state' 0 means no change, just reading the current state. 98 | func (m Adapter) CommandGetSetDeviceState(ctx context.Context, setState DeviceStateRequestState, 99 | ) (state DeviceStateResponseState, subState DeviceStateResponseSubState, err error) { 100 | slog.Debug(fmt.Sprintf("CommandGetSetDeviceState setState=0x%x", setState)) 101 | frame, err := vebus.WCommandGetSetDeviceState.Frame(byte(setState), 0x00).WriteAndRead(ctx, m) 102 | if err != nil { 103 | return "", "", fmt.Errorf("failed to execute CommandGetSetDeviceState: %w", err) 104 | } 105 | if frame.Reply != vebus.WReplyCommandGetSetDeviceStateOK { 106 | return "", "", fmt.Errorf("invalid response code to CommandGetSetDeviceState") 107 | } 108 | if len(frame.Data) < 2 { 109 | return "", "", fmt.Errorf("invalid response length to CommandGetSetDeviceState") 110 | } 111 | 112 | if s, ok := DeviceStateResponseStates[int(frame.Data[0])]; ok { 113 | state = s 114 | } else { 115 | state = DeviceStateResponseStates[-1] 116 | } 117 | if s, ok := DeviceStateResponseSubStates[int(frame.Data[1])]; ok { 118 | subState = s 119 | } else { 120 | subState = DeviceStateResponseSubStates[-1] 121 | } 122 | 123 | slog.Debug(fmt.Sprintf("CommandGetSetDeviceState state=%v subState=%v", state, subState)) 124 | 125 | return state, subState, nil 126 | } 127 | 128 | var ErrSettingNotSupported = errors.New("SETTING_NOT_SUPPORTED") 129 | 130 | func (m Adapter) CommandReadSetting(ctx context.Context, lowSettingID, highSettingID byte, 131 | ) (lowValue, highValue byte, err error) { 132 | frame, err := vebus.WCommandReadSetting.Frame(lowSettingID, highSettingID).WriteAndRead(ctx, m) 133 | if err != nil { 134 | return 0, 0, fmt.Errorf("failed to execute CommandGetSetDeviceState: %w", err) 135 | } 136 | switch frame.Reply { 137 | case vebus.WReplySettingNotSupported: 138 | return 0, 0, ErrSettingNotSupported 139 | case vebus.WReplyReadSettingOK: 140 | default: 141 | return 0, 0, fmt.Errorf("unknown response: %v", frame.Reply.String()) 142 | } 143 | 144 | if len(frame.Data) != 2 { 145 | return 0, 0, fmt.Errorf("invalid response length to CommandReadSetting") 146 | } 147 | 148 | return frame.Data[0], frame.Data[1], nil 149 | } 150 | 151 | var ErrVariableNotSupported = errors.New("VARIABLE_NOT_SUPPORTED") 152 | 153 | func (m Adapter) CommandReadRAMVar(ctx context.Context, ramID0, ramID1 byte, 154 | ) (value0Low, value0High, value1Low, value1High byte, err error) { 155 | frame, err := vebus.WCommandReadRAMVar.Frame(ramID0, ramID1).WriteAndRead(ctx, m) 156 | if err != nil { 157 | return 0, 0, 0, 0, fmt.Errorf("failed to execute CommandReadRAMVar: %w", err) 158 | } 159 | 160 | switch frame.Reply { 161 | case vebus.WReplyVariableNotSupported: 162 | return 0, 0, 0, 0, ErrVariableNotSupported 163 | case vebus.WReplyReadRAMOK: 164 | break 165 | default: 166 | return 0, 0, 0, 0, fmt.Errorf("unknown response: %v", frame.Reply.String()) 167 | } 168 | 169 | if len(frame.Data) != 4 && len(frame.Data) != 6 { 170 | // Old devices send 4, newer support requesting two ram-ids at the same time. 171 | // we drop the second as we asked for 0x00 (UMains) but don't really care about it. 172 | return 0, 0, 0, 0, fmt.Errorf("invalid response length to CommandReadRAMVar") 173 | } 174 | 175 | return frame.Data[0], frame.Data[1], frame.Data[2], frame.Data[3], nil 176 | } 177 | 178 | func (m Adapter) CommandReadRAMVarUnsigned16(ctx context.Context, ramID0, ramID1 byte, 179 | ) (value0, value1 uint16, err error) { 180 | v0l, v0h, v1l, v1h, err := m.CommandReadRAMVar(ctx, ramID0, ramID1) 181 | if err != nil { 182 | return 0, 0, err 183 | } 184 | return uint16(v0l) | uint16(v0h)<<8, uint16(v1l) | uint16(v1h)<<8, nil 185 | } 186 | 187 | func (m Adapter) CommandReadRAMVarSigned16(ctx context.Context, ramID0, ramID1 byte, 188 | ) (value0, value1 int16, err error) { 189 | v0l, v0h, v1l, v1h, err := m.CommandReadRAMVar(ctx, ramID0, ramID1) 190 | if err != nil { 191 | return 0, 0, err 192 | } 193 | return vebus.ParseSigned16Bytes(v0l, v0h), vebus.ParseSigned16Bytes(v1l, v1h), nil 194 | } 195 | 196 | func (m Adapter) CommandWriteRAMVarDataSigned(ctx context.Context, ram uint16, value int16) error { 197 | low, high := vebus.Signed16Bytes(value) 198 | return m.CommandWriteRAMVarData(ctx, ram, low, high) 199 | } 200 | 201 | func (m Adapter) CommandWriteRAMVarData(ctx context.Context, ram uint16, low, high byte) error { 202 | m.Write(vebus.WCommandWriteRAMVar.Frame(byte(ram&0xff), byte(ram>>8)).Marshal()) // no response 203 | frame, err := vebus.WCommandWriteData.Frame(low, high).WriteAndRead(ctx, m) 204 | if err != nil { 205 | return fmt.Errorf("failed to execute CommandWriteRAMVarData: %w", err) 206 | } 207 | switch frame.Reply { 208 | case vebus.WReplySuccesfulRAMWrite: 209 | return nil 210 | default: 211 | return fmt.Errorf("unknown response: %v", frame.Reply.String()) 212 | } 213 | } 214 | 215 | func (m Adapter) CommandWriteViaID(ctx context.Context, id byte, dataLow, dataHigh byte) error { 216 | // [1]: true => ram value only, false => ram and eeprom 217 | // [0]: true: setting, false: ram var 218 | flags := byte(0b00000000) 219 | frame, err := vebus.WCommandWriteViaID.Frame(flags, id, dataLow, dataHigh).WriteAndRead(ctx, m) 220 | if err != nil { 221 | return fmt.Errorf("failed to execute CommandWriteViaID: %w", err) 222 | } 223 | switch frame.Reply { 224 | case vebus.WReplySuccesfulRAMWrite, vebus.WReplySuccesfulSettingWrite: 225 | return nil 226 | default: 227 | return fmt.Errorf("unknown response: %v", frame.Reply.String()) 228 | } 229 | } 230 | 231 | func (m Adapter) CommandWriteSettingData(ctx context.Context, setting uint16, dataLow, dataHigh byte) error { 232 | m.Write(vebus.WCommandWriteSetting.Frame(byte(setting&0xff), byte(setting>>8)).Marshal()) // no response 233 | frame, err := vebus.WCommandWriteData.Frame(dataLow, dataHigh).WriteAndRead(ctx, m) 234 | if err != nil { 235 | return fmt.Errorf("failed to execute CommandWriteSettingData:: %w", err) 236 | } 237 | if frame.Reply != vebus.WReplySuccesfulSettingWrite { 238 | return fmt.Errorf("write failed") 239 | } 240 | return nil 241 | } 242 | -------------------------------------------------------------------------------- /pkg/mk2/mk2ess.go: -------------------------------------------------------------------------------- 1 | package mk2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 9 | ) 10 | 11 | // AdapterWithESS is wraps Adapter and adds function to configure the AdapterWithESS Assistant. 12 | type AdapterWithESS struct { 13 | *Adapter 14 | assistantRAMID uint16 15 | } 16 | 17 | // ESSInit searches for the ESS Assistent in RAM 18 | // if not found returns with error. 19 | func ESSInit(ctx context.Context, mk2 *Adapter) (*AdapterWithESS, error) { 20 | // 200 is arbitrary chosen upper bound. 21 | // should be corrected if information is available. 22 | for i := 128; i < 200; i++ { 23 | slog.Debug("probing ramid", slog.Int("ramID", i)) 24 | low, high, _, _, err := mk2.CommandReadRAMVar(ctx, byte(i), 0) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to enumerate ESS assistent ram records: %w", err) 27 | } 28 | if high == 0x0 && low == 0x0 { 29 | slog.Debug("found end of ramIDs in use") 30 | break 31 | } 32 | 33 | assistantID := (uint16(high)<<8 | uint16(low)) >> 4 34 | slog.Debug("id", slog.Int("assistantID", int(assistantID))) 35 | if assistantID == vebus.AssistantRAMIDESS { 36 | return &AdapterWithESS{ 37 | Adapter: mk2, 38 | assistantRAMID: uint16(i), 39 | }, nil 40 | } 41 | 42 | // this is not the ESS record, jump to next block 43 | i += int(low & 0xf) 44 | } 45 | 46 | return nil, fmt.Errorf("ESS RAM Record not found") 47 | } 48 | 49 | func (m *AdapterWithESS) SetpointSet(ctx context.Context, value int16) error { 50 | slog.Info("write setpoint", slog.Int("value", int(value)), slog.Int("record", int(m.assistantRAMID))) 51 | return m.CommandWriteRAMVarDataSigned(ctx, m.assistantRAMID+1, value) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/mk2/mk2io.go: -------------------------------------------------------------------------------- 1 | package mk2 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "sync" 11 | "time" 12 | 13 | "github.com/goburrow/serial" 14 | 15 | "github.com/yvesf/ve-ctrl-tool/pkg/vebus" 16 | ) 17 | 18 | // IO provides raw read/write to MK2-Adapter. 19 | type IO struct { 20 | listenerProduce chan chan []byte 21 | listenerClose chan chan []byte 22 | 23 | input serial.Port 24 | commandMutex sync.Mutex 25 | signalShutdown chan struct{} 26 | running bool 27 | wg sync.WaitGroup 28 | config serial.Config 29 | } 30 | 31 | func NewReader(address string) (*IO, error) { 32 | config := serial.Config{} 33 | config.Address = address 34 | config.BaudRate = 2400 35 | config.DataBits = 8 36 | config.Parity = "N" 37 | config.StopBits = 1 38 | config.Timeout = 5 * time.Second 39 | 40 | port, err := serial.Open(&config) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return &IO{ 46 | config: config, 47 | listenerProduce: make(chan chan []byte), 48 | listenerClose: make(chan chan []byte), 49 | input: port, 50 | commandMutex: sync.Mutex{}, 51 | }, nil 52 | } 53 | 54 | func (r *IO) SetBaudHigh() error { 55 | r.commandMutex.Lock() 56 | defer r.commandMutex.Unlock() 57 | r.config.BaudRate = 115200 58 | return r.input.Open(&r.config) 59 | } 60 | 61 | func (r *IO) SetBaudLow() error { 62 | r.commandMutex.Lock() 63 | defer r.commandMutex.Unlock() 64 | r.config.BaudRate = 2400 65 | return r.input.Open(&r.config) 66 | } 67 | 68 | // ReadAndWrite write a command and return the response 69 | // StartReader must have been called once before. 70 | func (r *IO) ReadAndWrite(ctx context.Context, data []byte, receiver func([]byte) bool) ([]byte, error) { 71 | r.commandMutex.Lock() 72 | defer r.commandMutex.Unlock() 73 | 74 | done := make(chan struct{}) 75 | defer close(done) 76 | 77 | response := make(chan []byte) 78 | go func() { 79 | l := r.newListenChannel() 80 | defer close(response) 81 | defer r.Close(l) 82 | 83 | r.Write(data) 84 | for { 85 | select { 86 | case frame := <-l: 87 | if receiver(frame) { 88 | select { 89 | case response <- frame: 90 | case <-done: 91 | } 92 | return 93 | } 94 | slog.Debug("dropping while waiting for response", slog.Any("frame.data", frame)) 95 | case <-done: // timeout 96 | return 97 | } 98 | } 99 | }() 100 | 101 | select { 102 | case frame := <-response: 103 | return frame, nil 104 | case <-time.After(time.Second * 2): 105 | return nil, errors.New("WriteAndReadFrame timed out waiting for response") 106 | case <-ctx.Done(): 107 | return nil, ctx.Err() 108 | } 109 | } 110 | 111 | // StartReader runs the go-routines that read from the port in the background. 112 | func (r *IO) StartReader() error { 113 | var listeners []chan []byte 114 | frames := make(chan []byte) 115 | wait := make(chan struct{}) 116 | waitOnce := sync.Once{} 117 | 118 | r.commandMutex.Lock() 119 | if r.running { 120 | r.commandMutex.Unlock() 121 | return fmt.Errorf("already running") 122 | } 123 | r.signalShutdown = make(chan struct{}) 124 | r.running = true 125 | r.commandMutex.Unlock() 126 | 127 | r.wg.Add(1) 128 | go func() { 129 | defer r.wg.Done() 130 | l := make(chan []byte) 131 | for { 132 | select { 133 | case <-r.signalShutdown: 134 | close(l) 135 | return 136 | case r.listenerProduce <- l: 137 | listeners = append(listeners, l) 138 | l = make(chan []byte) 139 | case unregL := <-r.listenerClose: 140 | for i := range listeners { 141 | if listeners[i] == unregL { 142 | listeners = append(listeners[:i], listeners[i+1:]...) 143 | close(unregL) 144 | break 145 | } 146 | } 147 | case f := <-frames: 148 | if len(f) == 8 && f[2] == 'V' { 149 | slog.Debug("received broadcast frame 'V'", slog.Any("data", f[2:])) 150 | } else { 151 | slog.Debug("received bytes", slog.Any("data", f), slog.Int("len", len(f))) 152 | for _, l := range listeners { 153 | select { 154 | case l <- f: 155 | case <-time.After(time.Millisecond * 100): 156 | slog.Warn("timeout signalling listener") 157 | } 158 | } 159 | } 160 | } 161 | } 162 | }() 163 | 164 | r.wg.Add(1) 165 | go func() { 166 | defer r.Shutdown() 167 | defer r.wg.Done() 168 | defer close(frames) 169 | 170 | var scannerBuffer bytes.Buffer 171 | synchronized := false 172 | frameBuf := make([]byte, 1024) 173 | for r.running { 174 | n, err := r.input.Read(frameBuf) 175 | if err != nil { 176 | slog.Warn(fmt.Sprintf("Error reading: %v", err)) 177 | continue 178 | } 179 | if n == 0 { 180 | continue 181 | } 182 | slog.Debug(fmt.Sprintf("Read %v bytes", n), slog.Any("data", frameBuf[0:n])) 183 | _, _ = scannerBuffer.Write(frameBuf[0:n]) 184 | slog.Debug("buffer", slog.Any("scannerBufHex", hex.EncodeToString(scannerBuffer.Bytes()))) 185 | 186 | if scannerBuffer.Len() == 0 { 187 | continue 188 | } 189 | 190 | // wait for at least 9 bytes in buffer before trying to sync 191 | for !synchronized && scannerBuffer.Len() >= 9 { 192 | slog.Debug("re-syncing", slog.Bool("synchronized", synchronized)) 193 | if scannerBuffer.Bytes()[1] != 0xff { 194 | _, _ = scannerBuffer.ReadByte() 195 | } else if length := scannerBuffer.Bytes()[0]; scannerBuffer.Len() < int(length) { 196 | break // read more data 197 | } else if vebus.Checksum(scannerBuffer.Bytes()[0:length+1]) == scannerBuffer.Bytes()[length+1] { 198 | slog.Debug("synchronized", slog.Any("buffer", scannerBuffer.Bytes())) 199 | synchronized = true 200 | // to wait for sync before returning from StartReader 201 | waitOnce.Do(func() { close(wait) }) 202 | 203 | break 204 | } else { 205 | // drop byte, try again 206 | _, _ = scannerBuffer.ReadByte() 207 | } 208 | } 209 | if !synchronized { 210 | continue // read more data 211 | } 212 | 213 | for scannerBuffer.Len() >= 3 { 214 | for scannerBuffer.Len() > 0 && scannerBuffer.Bytes()[0] == 0x00 { 215 | // drop 0x00 bytes 216 | _ = scannerBuffer.Next(1) 217 | } 218 | 219 | length := scannerBuffer.Bytes()[0] 220 | if scannerBuffer.Bytes()[1] != 0xff { 221 | slog.Warn(fmt.Sprintf("received 0x%x instead of 0xff marker, trigger re-sync", scannerBuffer.Bytes()[1])) 222 | synchronized = false 223 | scannerBuffer.Reset() 224 | break 225 | } 226 | 227 | if scannerBuffer.Len() < int(length+2) { 228 | slog.Debug("buffer too small, wait for more data") 229 | break 230 | } 231 | 232 | potentialFrame := scannerBuffer.Bytes()[0 : length+2] 233 | if cksum := vebus.Checksum(potentialFrame[0 : length+1]); cksum != potentialFrame[length+1] { 234 | slog.Warn(fmt.Sprintf("checksum mismatch, got 0x%x, expected 0x%x, trigger re-sync", 235 | cksum, potentialFrame[length+1])) 236 | synchronized = false 237 | scannerBuffer.Reset() 238 | break 239 | } 240 | 241 | fullFrame := scannerBuffer.Next(int(length) + 2) // drop successful read data 242 | f := fullFrame[:length+1] 243 | 244 | select { 245 | case <-r.signalShutdown: 246 | case frames <- f: 247 | } 248 | } 249 | } 250 | slog.Debug("reader exits") 251 | }() 252 | 253 | select { 254 | case <-wait: // for first broadcast 255 | return nil 256 | case <-r.signalShutdown: // shutdown during init 257 | return nil 258 | case <-time.After(time.Second * 50): // timeout 259 | r.Shutdown() 260 | return errors.New("could not do initial sync") 261 | } 262 | } 263 | 264 | // Write calculates the checksum and writes the frame to the port. 265 | func (r *IO) Write(data []byte) { 266 | n, err := r.input.Write(data) 267 | if err != nil { 268 | panic(err) // todo 269 | } 270 | slog.Debug("sent bytes", slog.Int("len", n), slog.Any("data", data)) 271 | } 272 | 273 | func (r *IO) Close(l chan []byte) { 274 | for { 275 | select { 276 | case r.listenerClose <- l: 277 | return 278 | case <-l: // drop remaining frames 279 | } 280 | } 281 | } 282 | 283 | // Shutdown initiates stop reading. 284 | // Call Wait() to make sure shutdown is completed. 285 | func (r *IO) Shutdown() { 286 | slog.Debug("try shutdown") 287 | r.commandMutex.Lock() 288 | if r.running { 289 | slog.Debug("trigger shutdown") 290 | close(r.signalShutdown) 291 | r.running = false 292 | } 293 | r.commandMutex.Unlock() 294 | } 295 | 296 | // Wait blocks until all reader go-routines finished. 297 | // Shutdown is initiated by calling Shutdown(). 298 | func (r *IO) Wait() { 299 | r.wg.Wait() 300 | } 301 | 302 | func (r *IO) newListenChannel() chan []byte { 303 | return <-r.listenerProduce 304 | } 305 | 306 | func (r *IO) UpgradeHighSpeed() error { 307 | time.Sleep(time.Millisecond * 100) 308 | 309 | n, err := r.input.Write([]byte{0x02, 0xff, 0x4e, 0xb1}) 310 | if err != nil { 311 | return fmt.Errorf("failed magic high-speed sequence: %w", err) 312 | } 313 | if n != 4 { 314 | return fmt.Errorf("failed magic high-speed sequence: incomplete write") 315 | } 316 | 317 | time.Sleep(time.Millisecond * 50) 318 | 319 | err = r.SetBaudHigh() 320 | if err != nil { 321 | return fmt.Errorf("failed to set high baud rate: %w", err) 322 | } 323 | 324 | n, err = r.input.Write([]byte("UUUUU")) 325 | if err != nil { 326 | return fmt.Errorf("failed write UUUUU: %w", err) 327 | } 328 | if n != 5 { 329 | return fmt.Errorf("failed write UUUUU: incomplete write") 330 | } 331 | 332 | time.Sleep(time.Millisecond * 100) 333 | 334 | return nil 335 | } 336 | -------------------------------------------------------------------------------- /pkg/ringbuf/ringbuf.go: -------------------------------------------------------------------------------- 1 | package ringbuf 2 | 3 | // Ringbuf is a simple circular buffer to calculate the average value of a series. 4 | type Ringbuf struct { 5 | buf []float64 6 | p int 7 | s int 8 | } 9 | 10 | func NewRingbuf(size int) *Ringbuf { 11 | return &Ringbuf{ 12 | s: size, 13 | } 14 | } 15 | 16 | func (r *Ringbuf) Add(v float64) { 17 | if r.s <= 0 { 18 | return 19 | } 20 | if len(r.buf) < r.s { 21 | r.buf = append(r.buf, v) 22 | return 23 | } 24 | r.buf[r.p] = v 25 | r.p = (r.p + 1) % r.s 26 | } 27 | 28 | func (r *Ringbuf) Mean() float64 { 29 | var sum float64 30 | for _, v := range r.buf { 31 | sum += v 32 | } 33 | return sum / float64(len(r.buf)) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/ringbuf/ringbuf_test.go: -------------------------------------------------------------------------------- 1 | package ringbuf_test 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "github.com/carlmjohnson/be" 8 | 9 | "github.com/yvesf/ve-ctrl-tool/pkg/ringbuf" 10 | ) 11 | 12 | func inRange(t *testing.T, expected, got, delta float64) { 13 | t.Helper() 14 | if got < expected-delta { 15 | t.Fatalf("%f < %f-%f", got, expected, delta) 16 | } 17 | if got > expected+delta { 18 | t.Fatalf("%f > %f+%f", got, expected, delta) 19 | } 20 | } 21 | 22 | func TestRingbuf(t *testing.T) { 23 | t.Run(`empty`, func(t *testing.T) { 24 | r := ringbuf.NewRingbuf(0) 25 | be.True(t, math.IsNaN(r.Mean())) 26 | r.Add(1) 27 | be.True(t, math.IsNaN(r.Mean())) 28 | }) 29 | t.Run(`size -1 invalid`, func(t *testing.T) { 30 | r := ringbuf.NewRingbuf(-1) 31 | be.True(t, math.IsNaN(r.Mean())) 32 | r.Add(1) 33 | be.True(t, math.IsNaN(r.Mean())) 34 | }) 35 | t.Run(`size 1`, func(t *testing.T) { 36 | r := ringbuf.NewRingbuf(1) 37 | be.True(t, math.IsNaN(r.Mean())) 38 | r.Add(2) 39 | be.Equal(t, float64(2), r.Mean()) 40 | r.Add(100) 41 | be.Equal(t, float64(100), r.Mean()) 42 | }) 43 | t.Run(`size 5`, func(t *testing.T) { 44 | r := ringbuf.NewRingbuf(5) 45 | be.True(t, math.IsNaN(r.Mean())) 46 | r.Add(1) 47 | be.Equal(t, float64(1), r.Mean()) 48 | r.Add(2) 49 | inRange(t, float64(1.5), r.Mean(), 0.0001) 50 | r.Add(1) 51 | inRange(t, float64(1.3), r.Mean(), 0.04) 52 | r.Add(1) 53 | inRange(t, float64(1.25), r.Mean(), 0.0001) 54 | r.Add(1) 55 | inRange(t, float64(1.2), r.Mean(), 0.00001) 56 | r.Add(1) 57 | inRange(t, float64(1.2), r.Mean(), 0.00001) 58 | r.Add(1) 59 | inRange(t, float64(1), r.Mean(), 0.0001) 60 | r.Add(-100) 61 | inRange(t, float64(-19.2), r.Mean(), 0.0001) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/shelly/gen2meterem.go: -------------------------------------------------------------------------------- 1 | package shelly 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type Gen2Meter struct { 12 | Client *http.Client 13 | Addr string 14 | } 15 | 16 | type Gen2MeterData struct { 17 | // Sum of the active power on all phases. 18 | // Positive values is power taken from the grid/uplink. 19 | // Negative values is power injected to the grid/uplink. 20 | TotalPowerFloat float64 `json:"total_act_power"` 21 | } 22 | 23 | func (d Gen2MeterData) TotalPower() float64 { 24 | return d.TotalPowerFloat 25 | } 26 | 27 | // Read returns the whole Shelly3EMData status update from the Shelly 3EM. 28 | func (s Gen2Meter) Read() (*Gen2MeterData, error) { 29 | url := url.URL{ 30 | Scheme: "http", 31 | Host: s.Addr, 32 | Path: "/rpc/EM.GetStatus", 33 | RawQuery: "id=0", 34 | } 35 | 36 | req, err := http.NewRequest(http.MethodGet, url.String(), nil) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to construct request: %w", err) 39 | } 40 | resp, err := s.Client.Do(req) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to read from shelly device: %w", err) 43 | } 44 | defer resp.Body.Close() 45 | 46 | if resp.StatusCode != http.StatusOK { 47 | return nil, fmt.Errorf("unexpected status code from shelly device: %v", resp.StatusCode) 48 | } 49 | 50 | // we expect no valid response larger than 1mb 51 | bodyReader := io.LimitReader(resp.Body, 1024*1024) 52 | 53 | data := new(Gen2MeterData) 54 | d := json.NewDecoder(bodyReader) 55 | err = d.Decode(data) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to parse response from shelly device: %w", err) 58 | } 59 | 60 | return data, nil 61 | } 62 | -------------------------------------------------------------------------------- /pkg/shelly/gen2meterem_test.go: -------------------------------------------------------------------------------- 1 | package shelly 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/carlmjohnson/be" 12 | ) 13 | 14 | func TestGen2Meter(t *testing.T) { 15 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 16 | wr := bufio.NewWriter(w) 17 | _, _ = wr.WriteString(`{"id":0,` + 18 | `"a_current":0.951,"a_voltage":229.7,"a_act_power":136.7,"a_aprt_power":218.4,"a_pf":-0.73,` + 19 | `"b_current":0.867,"b_voltage":230.5,"b_act_power":81.8,"b_aprt_power":199.8,"b_pf":-0.63,` + 20 | `"c_current":5.495,"c_voltage":233.7,"c_act_power":836.4,"c_aprt_power":1282.3,"c_pf":-0.74,` + 21 | `"n_current":null,"total_current":7.313,"total_act_power":1054.962,"total_aprt_power":1700.496}`) 22 | wr.Flush() 23 | })) 24 | defer server.Close() 25 | 26 | url, _ := url.Parse(server.URL) 27 | shelly := Gen2Meter{Addr: url.Host, Client: http.DefaultClient} 28 | d, err := shelly.Read() 29 | be.NilErr(t, err) 30 | 31 | be.Equal(t, 1054.962, d.TotalPower()) 32 | } 33 | 34 | func ExampleGen2Meter() { 35 | m := Gen2Meter{Addr: "shellypro3em-0cb815fc53bc", Client: http.DefaultClient} 36 | data, err := m.Read() 37 | if err != nil { 38 | panic(err) 39 | } 40 | fmt.Printf("%f\n", data.TotalPower()) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/vebus/constants.go: -------------------------------------------------------------------------------- 1 | package vebus 2 | 3 | import "fmt" 4 | 5 | // The following block defines RAM IDs according to "Interfacing with VE Bus products - MK2 Protocol 3 14.docx". 6 | const ( 7 | RAMIDUMainsRMS = 0 8 | RAMIDIMainsRMS = 1 9 | RAMIDUInverterRMS = 2 10 | RAMIDIINverterRMS = 3 11 | RAMIDUBat = 4 12 | RAMIDIBat = 5 13 | RAMIDUBatRMS = 6 // RMS=value of ripple voltage 14 | RAMIDInverterPeriodTime = 7 // time-base 0.1s 15 | RAMIDMainsPeriodTime = 8 // time-base 0.1s 16 | RAMIDSignedACLoadCurrent = 9 17 | RAMIDVirtualSwitchPosition = 10 18 | RAMIDIgnoreACInputState = 11 19 | RAMIDMultiFunctionalRelayState = 12 20 | RAMIDChargeState = 13 // battery monitor function 21 | RAMIDInverterPower1 = 14 // filtered. 16bit signed integer. Positive AC->DC. Negative DC->AC. 22 | RAMIDInverterPower2 = 15 // .. 23 | RAMIDOutputPower = 16 // AC Output. 16bit signed integer. 24 | RAMIDInverterPower1Unfiltered = 17 25 | RAMIDInverterPower2Unfiltered = 18 26 | RAMIDOutputPowerUnfiltered = 19 27 | ) 28 | 29 | // The following block defines Assistent ID to identify to which 30 | // assistant RAM records belong to. 31 | const ( 32 | AssistantRAMIDESS = 5 // ESS Assistant 33 | ) 34 | 35 | type Command byte 36 | 37 | func (c Command) Frame(data ...byte) VeCommandFrame { 38 | return VeCommandFrame{ 39 | command: c, 40 | Data: data, 41 | } 42 | } 43 | 44 | const ( 45 | CommandA Command = 'A' 46 | CommandW Command = 'W' 47 | CommandR Command = 'R' 48 | ) 49 | 50 | type WCommand byte 51 | 52 | func (c WCommand) Frame(data ...byte) VeWFrame { 53 | return VeWFrame{ 54 | Command: c, 55 | Data: data, 56 | } 57 | } 58 | 59 | const ( 60 | WCommandSendSoftwareVersionPart0 WCommand = 0x05 61 | WCommandSendSoftwareVersionPart1 WCommand = 0x06 62 | WCommandGetSetDeviceState WCommand = 0x0e 63 | WCommandReadRAMVar WCommand = 0x30 64 | WCommandReadSetting WCommand = 0x31 65 | WCommandWriteRAMVar WCommand = 0x32 66 | WCommandWriteSetting WCommand = 0x33 67 | WCommandWriteData WCommand = 0x34 68 | WCommandWriteViaID WCommand = 0x37 69 | ) 70 | 71 | type WReply uint8 72 | 73 | const ( 74 | WReplyCommandNotSupported = 0x80 75 | WReplyReadRAMOK = 0x85 76 | WReplyReadSettingOK = 0x86 77 | WReplySuccesfulRAMWrite = 0x87 78 | WReplySuccesfulSettingWrite = 0x88 79 | WReplyVariableNotSupported = 0x90 80 | WReplySettingNotSupported = 0x91 81 | WReplyCommandGetSetDeviceStateOK = 0x94 82 | WReplyAccessLevelRequired = 0x9b 83 | ) 84 | 85 | func (r WReply) String() string { 86 | switch r { 87 | case WReplyCommandNotSupported: 88 | return "Command not supported" 89 | case WReplyReadRAMOK: 90 | return "Read RAM OK" 91 | case WReplyReadSettingOK: 92 | return "Read setting OK" 93 | case WReplySuccesfulRAMWrite: 94 | return "Write ramvar OK" 95 | case WReplySuccesfulSettingWrite: 96 | return "Write setting OK" 97 | case WReplyVariableNotSupported: 98 | return "Variable not supported" 99 | case WReplySettingNotSupported: 100 | return "Setting not supported" 101 | case WReplyAccessLevelRequired: 102 | return "Access level required" 103 | default: 104 | return fmt.Sprintf("undefined reply 0x%02x", uint8(r)) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /pkg/vebus/types.go: -------------------------------------------------------------------------------- 1 | package vebus 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Signed16Bytes is nothing special but just literally implements the signed-integer encoding in ESS Mode 2 and 3. 8 | func Signed16Bytes(in int16) (uint8, uint8) { 9 | var val uint16 10 | if in < 0 { 11 | val = 1 + ^(uint16(in * -1)) 12 | } else { 13 | val = uint16(in) 14 | } 15 | return uint8(0xff & val), uint8(0xff & (val >> 8)) 16 | } 17 | 18 | // ParseSigned16 is nothing special but just literally implements the signed-integer encoding in ESS Mode 2 and 3. 19 | func ParseSigned16(in uint16) int16 { 20 | if 0x8000&in == 0x8000 { 21 | return -1 + int16(0x7fff & ^in)*-1 22 | } 23 | return int16(in) 24 | } 25 | 26 | func ParseSigned16Bytes(low, high byte) int16 { 27 | return ParseSigned16(uint16(low) | uint16(high)<<8) 28 | } 29 | 30 | // Checksum implements the check-summing algorithm for ve.bus. 31 | func Checksum(data []byte) byte { 32 | checksum := byte(0) 33 | for _, d := range data { 34 | checksum -= d 35 | } 36 | return checksum 37 | } 38 | 39 | type VeCommandFrame struct { 40 | command Command 41 | Data []byte 42 | } 43 | 44 | func (f VeCommandFrame) Marshal() []byte { 45 | if len(f.Data) > 253 { 46 | panic("invalid data length") 47 | } 48 | length := len(f.Data) + 1 + 1 49 | result := append([]byte{byte(length), 0xff, byte(f.command)}, f.Data...) 50 | chksum := Checksum(result) 51 | result = append(result, chksum) 52 | return result 53 | } 54 | 55 | func (f VeCommandFrame) ParseResponse(data []byte) *VeCommandFrame { 56 | if len(data) >= 3 { 57 | if data[2] == byte(f.command) { 58 | return &VeCommandFrame{ 59 | command: Command(data[2]), 60 | Data: data[3:], 61 | } 62 | } 63 | } 64 | return nil 65 | } 66 | 67 | func (f VeCommandFrame) WriteAndRead(ctx context.Context, io frameReadWriter) (response *VeCommandFrame, err error) { 68 | _, err = io.ReadAndWrite(ctx, f.Marshal(), func(d []byte) bool { 69 | response = f.ParseResponse(d) 70 | return response != nil 71 | }) 72 | return response, err 73 | } 74 | 75 | type VeWFrame struct { 76 | Command WCommand 77 | Data []byte 78 | } 79 | 80 | func (f VeWFrame) Marshal() []byte { 81 | return VeCommandFrame{ 82 | command: CommandW, 83 | Data: append([]byte{byte(f.Command)}, f.Data...), 84 | }.Marshal() 85 | } 86 | 87 | func (f VeWFrame) ParseResponse(data []byte) *VeWFrameReply { 88 | if len(data) >= 4 && data[2] == byte(CommandW) { 89 | return &VeWFrameReply{ 90 | Reply: WReply(data[3]), 91 | Data: data[4:], 92 | } 93 | } 94 | return nil 95 | } 96 | 97 | func (f VeWFrame) WriteAndRead(ctx context.Context, io frameReadWriter) (response *VeWFrameReply, err error) { 98 | _, err = io.ReadAndWrite(ctx, f.Marshal(), func(d []byte) bool { 99 | response = f.ParseResponse(d) 100 | return response != nil 101 | }) 102 | return response, err 103 | } 104 | 105 | type VeWFrameReply struct { 106 | Reply WReply 107 | Data []byte 108 | } 109 | 110 | type VeFrame interface { 111 | Marshal() []byte 112 | ParseResponse([]byte) VeFrame 113 | } 114 | 115 | type frameReadWriter interface { 116 | ReadAndWrite(context.Context, []byte, func([]byte) bool) ([]byte, error) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/vebus/types_test.go: -------------------------------------------------------------------------------- 1 | package vebus 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/carlmjohnson/be" 7 | ) 8 | 9 | func TestSigned16Bytes(t *testing.T) { 10 | for _, tc := range []struct { 11 | name string 12 | input int16 13 | outLow, outHigh byte 14 | }{ 15 | {name: "zero", input: 0, outLow: 0, outHigh: 0}, 16 | {name: "+1", input: 1, outLow: 1, outHigh: 0}, 17 | {name: "-1", input: -1, outLow: 0xff, outHigh: 0xff}, 18 | {name: "-200", input: -200, outLow: 0x38, outHigh: 0xff}, 19 | {name: "-32768", input: -32768, outLow: 0x00, outHigh: 0x80}, 20 | } { 21 | t.Run(tc.name, func(t *testing.T) { 22 | low, high := Signed16Bytes(tc.input) 23 | if low != tc.outLow { 24 | t.Errorf("low expected %v, got %v. expected 0x%02x, got 0x%02x", tc.outLow, low, tc.outLow, low) 25 | } 26 | if high != tc.outHigh { 27 | t.Errorf("high expected %v, got %v. expected 0x%02x, got 0x%02x", tc.outHigh, high, tc.outHigh, high) 28 | } 29 | 30 | input := ParseSigned16(uint16(high)<<8 | uint16(low)) 31 | if input != tc.input { 32 | t.Errorf("expected %v, got %v. expected 0x%02x, got 0x%02x", tc.input, input, tc.input, input) 33 | } 34 | }) 35 | } 36 | } 37 | 38 | func TestChecksum(t *testing.T) { 39 | be.Equal(t, byte(0xbb), Checksum([]byte{0x04, 0xff, 'A', 0x01, 0x00})) 40 | be.Equal(t, byte(0xba), Checksum([]byte{0x05, 0xff, 'A', 0x01, 0x00, 0x00})) 41 | be.Equal(t, byte(0xa0), Checksum([]byte{0x05, 0xff, 'W', 0x05, 0x00, 0x00})) 42 | be.Equal(t, byte(0x9f), Checksum([]byte{0x05, 0xff, 'W', 0x06, 0x00, 0x00})) 43 | be.Equal(t, byte(0x6b), Checksum([]byte{0x05, 0xff, 'W', 0x36, 0x04, 0x00})) 44 | be.Equal(t, byte(0x68), Checksum([]byte{0x05, 0xff, 'W', 0x30, 0x0d, 0x00})) 45 | be.Equal(t, byte(0xb8), Checksum([]byte{0x03, 0xff, 'F', 0x00})) 46 | be.Equal(t, byte(0xb3), Checksum([]byte{0x02, 0xff, 'L'})) 47 | be.Equal(t, byte(0xd2), Checksum([]byte{0x07, 0xff, 'S', 0x03, 0xc0, 0x10, 0x01, 0x01})) 48 | be.Equal(t, byte(0xac), Checksum([]byte{0x02, 0xff, 'S'})) 49 | be.Equal(t, byte(0x94), Checksum([]byte{7, 255, 86, 36, 219, 17, 0, 0})) 50 | be.Equal(t, byte(0x52), Checksum([]byte{7, 255, 86, 36, 219, 17, 0, 66})) 51 | be.Equal(t, byte(160), Checksum([]byte{5, 255, 87, 5, 0, 0})) 52 | be.Equal(t, byte(0x94), Checksum([]byte{0x7, 0xff, 'V', '$', 0xdb, 0x11, 0x00, 0x00})) 53 | be.Equal(t, byte(0x01), Checksum([]byte{0x7, 0xff, 'W', 0x85, 0xff, 0xfe, 0xc6, 0x5a})) 54 | } 55 | 56 | func TestVeCommandFrame_Marshall(t *testing.T) { 57 | be.AllEqual(t, []byte{0x04, 0xff, 'A', 0x01, 0x00, 0xbb}, 58 | VeCommandFrame{command: CommandA, Data: []byte{0x01, 0x00}}.Marshal()) 59 | be.AllEqual(t, []byte{0x05, 0xff, 'W', 0x05, 0x00, 0x00, 0xa0}, 60 | VeCommandFrame{command: CommandW, Data: []byte{0x05, 0x00, 0x00}}.Marshal()) 61 | } 62 | --------------------------------------------------------------------------------