Initial implementation: MQTT-based energy meter subscriber

Subscribes to Eastron SDM630 power meter data via MQTT broker,
decodes Modbus RTU frames, and writes readings to InfluxDB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:01:17 +02:00
commit 066fa5ca51
10 changed files with 742 additions and 0 deletions

112
requester.go Normal file
View File

@@ -0,0 +1,112 @@
package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"sync/atomic"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// ── CRC-16 / Modbus ───────────────────────────────────────────────────────────
// crc16 computes the Modbus RTU CRC-16 (poly 0xA001, initial value 0xFFFF).
// The result must be appended as [low_byte, high_byte].
func crc16(data []byte) uint16 {
crc := uint16(0xFFFF)
for _, b := range data {
crc ^= uint16(b)
for range 8 {
if crc&1 != 0 {
crc = (crc >> 1) ^ 0xA001
} else {
crc >>= 1
}
}
}
return crc
}
// ── Frame building ────────────────────────────────────────────────────────────
// buildFC4Frame builds a Modbus RTU FC4 (Read Input Registers) request frame.
//
// [slave, 0x04, regAddr_hi, regAddr_lo, count_hi, count_lo, crc_lo, crc_hi]
//
// regAddr and count are in register (word) units.
func buildFC4Frame(slave byte, regAddr, count uint16) []byte {
frame := make([]byte, 6)
frame[0] = slave
frame[1] = 0x04
binary.BigEndian.PutUint16(frame[2:], regAddr)
binary.BigEndian.PutUint16(frame[4:], count)
crc := crc16(frame)
return append(frame, byte(crc), byte(crc>>8)) // little-endian CRC
}
// ── MQTT payload ──────────────────────────────────────────────────────────────
type requestPayload struct {
Data struct {
ModbusFrame struct {
Value []int `json:"value"`
Unit string `json:"unit"`
} `json:"modbus_frame"`
} `json:"data"`
Timestamp struct {
Incarnation int64 `json:"incarnation"`
SystemTime int64 `json:"system_time"`
} `json:"timestamp"`
}
// ── Requester ─────────────────────────────────────────────────────────────────
// Requester publishes Modbus FC4 read requests to the MQTT broker.
// Topic pattern: <topicPrefix>/<segment>/0x<slave_hex>
type Requester struct {
client mqtt.Client
topicPrefix string
incarnation atomic.Int64
startTime time.Time
}
func NewRequester(client mqtt.Client, topicPrefix string) *Requester {
return &Requester{
client: client,
topicPrefix: topicPrefix,
startTime: time.Now(),
}
}
// ReadInputRegisters sends an FC4 read request for `count` registers (words)
// starting at `regAddr` on the given slave within the named segment.
// count=2 reads one float32 value; use a multiple of 2 for more values.
func (r *Requester) ReadInputRegisters(segment string, slave byte, regAddr, count uint16) error {
frame := buildFC4Frame(slave, regAddr, count)
var p requestPayload
p.Data.ModbusFrame.Unit = "RAW"
p.Data.ModbusFrame.Value = bytesToInts(frame)
p.Timestamp.Incarnation = r.incarnation.Add(1)
p.Timestamp.SystemTime = time.Since(r.startTime).Nanoseconds()
payload, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("marshalling request: %w", err)
}
topic := fmt.Sprintf("%s/%s/0x%02x", r.topicPrefix, segment, slave)
token := r.client.Publish(topic, 0, false, payload)
token.Wait()
return token.Error()
}
func bytesToInts(b []byte) []int {
ints := make([]int, len(b))
for i, v := range b {
ints[i] = int(v)
}
return ints
}