evcc-io/charger/evsemaster/protocol.go

221 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package evsemaster implements the binary UDP protocol used by EVSE Master
// compatible charging stations (tested on Sync EV and generic EVSE Master devices).
// Protocol reverse-engineered from https://github.com/johnwoo-nl/emproto
package evsemaster
import (
"encoding/binary"
"encoding/hex"
"fmt"
"net"
"time"
)
const (
// Port is the default EVSE Master UDP port
Port = 28376
packetHeader = uint16(0x0601)
packetTail = uint16(0x0f02)
headerSize = 25 // bytes: hdr(2)+len(2)+keytype(1)+serial(8)+passwd(6)+cmd(2)+csum(2)+tail(2)
// Commands sent by App → EVSE
CmdRequestLogin = uint16(0x8002) // Send password to request login
CmdLoginConfirm = uint16(0x8001) // Confirm successful login
CmdHeadingResp = uint16(0x8003) // Respond to EVSE keepalive
CmdStatusAck = uint16(0x8004) // Acknowledge SingleACStatus
CmdChargingAck = uint16(0x0006) // Acknowledge charging session status
CmdChargeStart = uint16(0x8007) // Start charging
CmdChargeStop = uint16(0x8008) // Stop charging
CmdSetCurrent = uint16(0x8107) // Set maximum output current
CmdHeading = uint16(0x0003) // Trigger EVSE to start pushing status
// Commands received from EVSE → App
CmdLoginBroadcast = uint16(0x0001) // EVSE discovery broadcast (also carries device info)
CmdLoginResp = uint16(0x0002) // Password accepted (in response to RequestLogin)
CmdHeadingFromEVSE = uint16(0x0003) // EVSE keepalive ping (same wire code as CmdHeading)
CmdACStatus = uint16(0x0004) // Real-time charger status
CmdChargeStatus = uint16(0x0005) // Charging session status
CmdPasswordError = uint16(0x0155) // Wrong password
CmdSetCurrentResp = uint16(0x0107) // Confirmation of current change
)
// Packet is a decoded EVSE Master UDP datagram.
// The wire format is:
//
// hdr(2) | total_len(2) | keytype(1) | serial(8) | passwd(6) | cmd(2) | payload(N) | checksum(2) | tail(2)
type Packet struct {
Serial string // 16-char lowercase hex string representing 8 serial bytes
Password string // up to 6 ASCII characters
Command uint16
Payload []byte
}
// ReceivedPacket wraps a decoded packet together with its UDP source address.
type ReceivedPacket struct {
*Packet
From *net.UDPAddr
}
// Pack serialises the packet to a UDP payload.
func (p *Packet) Pack() ([]byte, error) {
serialBytes, err := hex.DecodeString(p.Serial)
if err != nil || len(serialBytes) != 8 {
return nil, fmt.Errorf("invalid serial %q: must be 16-char hex", p.Serial)
}
size := headerSize + len(p.Payload)
buf := make([]byte, size)
binary.BigEndian.PutUint16(buf[0:], packetHeader)
binary.BigEndian.PutUint16(buf[2:], uint16(size))
buf[4] = 0x00 // key_type
copy(buf[5:13], serialBytes)
pw := []byte(p.Password)
if len(pw) > 6 {
pw = pw[:6]
}
copy(buf[13:19], pw)
binary.BigEndian.PutUint16(buf[19:], p.Command)
copy(buf[21:], p.Payload)
var checksum uint32
for _, b := range buf[:size-4] {
checksum += uint32(b)
}
binary.BigEndian.PutUint16(buf[size-4:], uint16(checksum%0xFFFF))
binary.BigEndian.PutUint16(buf[size-2:], packetTail)
return buf, nil
}
// Unpack deserialises a packet from raw UDP bytes.
func Unpack(buf []byte) (*Packet, error) {
if len(buf) < headerSize {
return nil, fmt.Errorf("packet too short (%d bytes)", len(buf))
}
if binary.BigEndian.Uint16(buf[0:]) != packetHeader {
return nil, fmt.Errorf("invalid packet header")
}
totalLen := int(binary.BigEndian.Uint16(buf[2:]))
if totalLen > len(buf) || totalLen < headerSize {
return nil, fmt.Errorf("invalid length field: %d", totalLen)
}
// Verify checksum
var sum uint32
for _, b := range buf[:totalLen-4] {
sum += uint32(b)
}
if uint16(sum%0xFFFF) != binary.BigEndian.Uint16(buf[totalLen-4:]) {
return nil, fmt.Errorf("checksum mismatch")
}
if binary.BigEndian.Uint16(buf[totalLen-2:]) != packetTail {
return nil, fmt.Errorf("invalid packet tail")
}
p := &Packet{
Serial: hex.EncodeToString(buf[5:13]),
Command: binary.BigEndian.Uint16(buf[19:]),
}
// Trim null bytes from password field
var pw [6]byte
copy(pw[:], buf[13:19])
end := 6
for i, b := range pw {
if b == 0 {
end = i
break
}
}
p.Password = string(pw[:end])
payloadLen := totalLen - headerSize
if payloadLen > 0 {
p.Payload = make([]byte, payloadLen)
copy(p.Payload, buf[21:21+payloadLen])
}
return p, nil
}
// ACStatus holds real-time charger state from command 0x0004 (SingleACStatus).
// GunState: 0=unknown, 1=disconnected, 2=connected_unlocked, 3=negotiating, 4=connected_locked
// OutputState: 0=idle, 1=charging
type ACStatus struct {
GunState int
OutputState int
Power float64 // W
TotalEnergy float64 // kWh (lifetime total)
L1Voltage float64 // V
L1Current float64 // A
L2Voltage float64 // V
L2Current float64 // A
L3Voltage float64 // V
L3Current float64 // A
}
// ParseACStatus decodes the payload of command 0x0004.
func ParseACStatus(payload []byte) (*ACStatus, error) {
if len(payload) < 25 {
return nil, fmt.Errorf("ACStatus payload too short: %d bytes", len(payload))
}
s := &ACStatus{
L1Voltage: float64(binary.BigEndian.Uint16(payload[1:])) * 0.1,
L1Current: float64(binary.BigEndian.Uint16(payload[3:])) * 0.01,
Power: float64(binary.BigEndian.Uint32(payload[5:])),
TotalEnergy: float64(binary.BigEndian.Uint32(payload[9:])) * 0.01,
GunState: int(payload[18]),
OutputState: int(payload[19]),
}
if len(payload) >= 33 {
s.L2Voltage = float64(binary.BigEndian.Uint16(payload[25:])) * 0.1
s.L2Current = float64(binary.BigEndian.Uint16(payload[27:])) * 0.01
s.L3Voltage = float64(binary.BigEndian.Uint16(payload[29:])) * 0.1
s.L3Current = float64(binary.BigEndian.Uint16(payload[31:])) * 0.01
}
return s, nil
}
// PackChargeStart builds the 47-byte payload for command 0x8007 (ChargeStart).
// maxAmps must be in the range 6–32.
func PackChargeStart(maxAmps int) ([]byte, error) {
if maxAmps < 6 || maxAmps > 32 {
return nil, fmt.Errorf("maxAmps must be 6-32, got %d", maxAmps)
}
buf := make([]byte, 47)
buf[0] = 1 // line_id
// user_id (16 bytes, null-padded) – same default as the mobile app
copy(buf[1:17], []byte("emmgr"))
// charge_id (16 bytes, null-padded) – use current Unix timestamp as unique ID
ts := fmt.Sprintf("%d", time.Now().Unix())
if len(ts) > 16 {
ts = ts[:16]
}
copy(buf[17:33], []byte(ts))
buf[33] = 0 // not a reservation (immediate start)
binary.BigEndian.PutUint32(buf[34:], uint32(time.Now().Unix()))
buf[38] = 1 // start_type
buf[39] = 1 // charge_type
binary.BigEndian.PutUint16(buf[40:], 0xFFFF) // max_duration = unlimited
binary.BigEndian.PutUint16(buf[42:], 0xFFFF) // max_energy = unlimited
binary.BigEndian.PutUint16(buf[44:], 0xFFFF) // param3
buf[46] = byte(maxAmps)
return buf, nil
}
// PackSetCurrent builds the 2-byte payload for command 0x8107 (SetAndGetOutputElectricity).
// amps must be in the range 6–32.
func PackSetCurrent(amps int) []byte {
return []byte{0x01, byte(amps)} // action=SET, value
}