Add EVSE Master (Besen, Telestar, Morec, Sync chargers) (#28359)
This commit is contained in:
parent
e9dc3cf8e6
commit
873da67448
6 changed files with 817 additions and 0 deletions
|
|
@ -91,6 +91,8 @@ EXPOSE 5353/udp
|
|||
EXPOSE 7070/tcp
|
||||
# KEBA charger
|
||||
EXPOSE 7090/udp
|
||||
# EVSE Master charger
|
||||
EXPOSE 28376/udp
|
||||
# OCPP charger
|
||||
EXPOSE 8887/tcp
|
||||
# Modbus UDP
|
||||
|
|
|
|||
334
charger/evsemaster.go
Normal file
334
charger/evsemaster.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package charger
|
||||
|
||||
// LICENSE
|
||||
|
||||
// Copyright (c) evcc.io (andig, naltatis, premultiply)
|
||||
|
||||
// This module is NOT covered by the MIT license. All rights reserved.
|
||||
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
// EVSE Master UDP charger integration.
|
||||
// Protocol credit: https://github.com/johnwoo-nl/emproto (reverse-engineering)
|
||||
// Reference implementation: https://github.com/Oniric75/evsemasterudp (Home Assistant)
|
||||
//
|
||||
// Key protocol insight: the EVSE sends FROM its own port (e.g. 11938) TO the
|
||||
// app's port 28376. All replies must go back to the EVSE's source address
|
||||
// (ip:11938), NOT to ip:28376. The EVSE's source port is therefore learned
|
||||
// from its Login broadcast and stored; no URI/IP is needed in the config.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/charger/evsemaster"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
)
|
||||
|
||||
const (
|
||||
evsemasterTimeout = 60 * time.Second
|
||||
evsemasterConnectTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// EVSEMaster implements api.Charger (and api.Meter / api.MeterEnergy /
|
||||
// api.PhaseCurrents / api.PhaseVoltages) for charging stations that use the
|
||||
// EVSE Master UDP protocol – e.g. Sync EV and generic Chinese EVSE devices.
|
||||
//
|
||||
// The device is auto-discovered: its IP and ephemeral port are learned from
|
||||
// its periodic Login broadcast, so only serial and password are required.
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// type: evsemaster-udp
|
||||
// serial: 0906252400004617 # 16-char hex serial printed on the device
|
||||
// password: 123456 # password set in the EVSE Master mobile app
|
||||
type EVSEMaster struct {
|
||||
log *util.Logger
|
||||
conn *evsemaster.Connection
|
||||
|
||||
data *util.Monitor[*evsemaster.ACStatus]
|
||||
current int // last value set by MaxCurrent
|
||||
|
||||
// evseAddr is the EVSE's source address (e.g. 192.168.1.100:11938).
|
||||
// It is learned from the first Login broadcast and used for all sends.
|
||||
evseAddr atomic.Pointer[net.UDPAddr]
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("evsemaster-udp", NewEVSEMasterFromConfig)
|
||||
}
|
||||
|
||||
// NewEVSEMasterFromConfig creates an EVSEMaster charger from a generic config map.
|
||||
func NewEVSEMasterFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
|
||||
var cc struct {
|
||||
Serial string
|
||||
Password string
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewEVSEMaster(ctx, cc.Serial, cc.Password)
|
||||
}
|
||||
|
||||
// NewEVSEMaster creates a new EVSEMaster charger. It returns immediately with a
|
||||
// safe default state (no car connected) and connects to the EVSE in the
|
||||
// background. Real status is available once the EVSE sends its first Login
|
||||
// broadcast – check serial, password, and that the charger is on the same
|
||||
// network segment (UDP broadcast does not cross VLANs).
|
||||
func NewEVSEMaster(ctx context.Context, serial, password string) (*EVSEMaster, error) {
|
||||
log := util.NewLogger("evsemaster")
|
||||
|
||||
if len(serial) != 16 {
|
||||
return nil, fmt.Errorf("serial must be a 16-character hex string, got %q", serial)
|
||||
}
|
||||
|
||||
conn, err := evsemaster.NewConnection(log, serial, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !sponsor.IsAuthorized() {
|
||||
return nil, api.ErrSponsorRequired
|
||||
}
|
||||
|
||||
wb := &EVSEMaster{
|
||||
log: log,
|
||||
conn: conn,
|
||||
current: 6,
|
||||
data: util.NewMonitor[*evsemaster.ACStatus](evsemasterTimeout),
|
||||
}
|
||||
|
||||
// Subscribe before starting the goroutine to avoid missing a Login broadcast
|
||||
// that arrives between go wb.run(ctx) and when run() actually calls Subscribe.
|
||||
recv := make(chan *evsemaster.ReceivedPacket, 32)
|
||||
conn.Subscribe(recv)
|
||||
|
||||
go wb.run(ctx, recv)
|
||||
|
||||
select {
|
||||
case <-wb.data.Done():
|
||||
return wb, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(evsemasterConnectTimeout):
|
||||
return nil, api.ErrTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// send writes a command datagram to the EVSE's stored source address.
|
||||
func (wb *EVSEMaster) send(cmd uint16, payload []byte) error {
|
||||
addr := wb.evseAddr.Load()
|
||||
if addr == nil {
|
||||
return api.ErrMustRetry
|
||||
}
|
||||
return wb.conn.Send(cmd, payload, addr)
|
||||
}
|
||||
|
||||
// run is the background goroutine that maintains the EVSE session.
|
||||
// recv is subscribed by the constructor before this goroutine starts.
|
||||
func (wb *EVSEMaster) run(ctx context.Context, recv chan *evsemaster.ReceivedPacket) {
|
||||
defer wb.conn.Unsubscribe()
|
||||
|
||||
if addr := wb.conn.Addr(nil); addr != nil {
|
||||
wb.evseAddr.Store(addr)
|
||||
_ = wb.send(evsemaster.CmdHeading, nil)
|
||||
}
|
||||
|
||||
for tick := time.NewTicker(10 * time.Second); ; {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-tick.C:
|
||||
// Reclaim the slot only if empty (validate may hold it temporarily),
|
||||
// then request a fresh ACStatus.
|
||||
wb.conn.Reclaim(recv)
|
||||
if err := wb.send(evsemaster.CmdHeading, nil); err != nil && !errors.Is(err, api.ErrMustRetry) {
|
||||
wb.log.DEBUG.Printf("keepalive: %v", err)
|
||||
}
|
||||
|
||||
case pkt := <-recv:
|
||||
switch pkt.Command {
|
||||
case evsemaster.CmdLoginBroadcast:
|
||||
// Learn (or refresh) the EVSE's source address and persist it.
|
||||
wb.evseAddr.Store(pkt.From)
|
||||
wb.conn.Addr(pkt.From)
|
||||
|
||||
if err := wb.send(evsemaster.CmdLoginConfirm, []byte{0x00}); err != nil {
|
||||
wb.log.DEBUG.Printf("CmdLoginConfirm: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := wb.send(evsemaster.CmdHeading, nil); err != nil {
|
||||
wb.log.DEBUG.Printf("CmdHeading: %v", err)
|
||||
}
|
||||
wb.log.DEBUG.Printf("logged in, EVSE at %s", pkt.From)
|
||||
|
||||
case evsemaster.CmdHeadingFromEVSE:
|
||||
if err := wb.send(evsemaster.CmdHeadingResp, nil); err != nil {
|
||||
wb.log.DEBUG.Printf("HeadingResp: %v", err)
|
||||
}
|
||||
|
||||
case evsemaster.CmdACStatus:
|
||||
if s, err := evsemaster.ParseACStatus(pkt.Payload); err == nil {
|
||||
wb.data.Set(s)
|
||||
} else {
|
||||
wb.log.DEBUG.Printf("ACStatus parse: %v", err)
|
||||
}
|
||||
if err := wb.send(evsemaster.CmdStatusAck, []byte{0x01}); err != nil {
|
||||
wb.log.DEBUG.Printf("ack: %v", err)
|
||||
}
|
||||
|
||||
case evsemaster.CmdChargeStatus:
|
||||
if err := wb.send(evsemaster.CmdChargingAck, []byte{0x00}); err != nil {
|
||||
wb.log.DEBUG.Printf("ack: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status implements the api.Charger interface.
|
||||
//
|
||||
// GunState (TypeScript ref): 0=unknown, 1=disconnected, 2=connected_unlocked,
|
||||
// 3=negotiating, 4=connected_locked
|
||||
// OutputState: 0=idle, 1=charging, 2+=other active state
|
||||
func (wb *EVSEMaster) Status() (api.ChargeStatus, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return api.StatusNone, err
|
||||
}
|
||||
if res == nil {
|
||||
return api.StatusNone, api.ErrTimeout
|
||||
}
|
||||
|
||||
switch {
|
||||
case res.OutputState == 1:
|
||||
return api.StatusC, nil
|
||||
case res.GunState >= 2:
|
||||
return api.StatusB, nil
|
||||
default:
|
||||
return api.StatusA, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled implements the api.Charger interface.
|
||||
func (wb *EVSEMaster) Enabled() (bool, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if res == nil {
|
||||
return false, api.ErrTimeout
|
||||
}
|
||||
|
||||
return res.OutputState == 1, nil
|
||||
}
|
||||
|
||||
// Enable implements the api.Charger interface.
|
||||
func (wb *EVSEMaster) Enable(enable bool) error {
|
||||
var err error
|
||||
if enable {
|
||||
var b []byte
|
||||
if b, err = evsemaster.PackChargeStart(wb.current); err != nil {
|
||||
return err
|
||||
}
|
||||
err = wb.send(evsemaster.CmdChargeStart, b)
|
||||
} else {
|
||||
err = wb.send(evsemaster.CmdChargeStop, nil)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_ = wb.send(evsemaster.CmdHeading, nil) // request immediate status update
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MaxCurrent implements the api.Charger interface.
|
||||
func (wb *EVSEMaster) MaxCurrent(current int64) error {
|
||||
if err := wb.send(evsemaster.CmdSetCurrent, evsemaster.PackSetCurrent(int(current))); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = wb.send(evsemaster.CmdHeading, nil) // request immediate status update
|
||||
|
||||
wb.current = int(current)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ api.Meter = (*EVSEMaster)(nil)
|
||||
|
||||
// CurrentPower implements the api.Meter interface.
|
||||
func (wb *EVSEMaster) CurrentPower() (float64, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return 0, api.ErrTimeout
|
||||
}
|
||||
|
||||
return res.Power, nil
|
||||
}
|
||||
|
||||
var _ api.MeterEnergy = (*EVSEMaster)(nil)
|
||||
|
||||
// TotalEnergy implements the api.MeterEnergy interface.
|
||||
func (wb *EVSEMaster) TotalEnergy() (float64, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return 0, api.ErrTimeout
|
||||
}
|
||||
|
||||
return res.TotalEnergy, nil
|
||||
}
|
||||
|
||||
var _ api.PhaseCurrents = (*EVSEMaster)(nil)
|
||||
|
||||
// Currents implements the api.PhaseCurrents interface.
|
||||
func (wb *EVSEMaster) Currents() (float64, float64, float64, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return 0, 0, 0, api.ErrTimeout
|
||||
}
|
||||
|
||||
return res.L1Current, res.L2Current, res.L3Current, nil
|
||||
}
|
||||
|
||||
var _ api.PhaseVoltages = (*EVSEMaster)(nil)
|
||||
|
||||
// Voltages implements the api.PhaseVoltages interface.
|
||||
func (wb *EVSEMaster) Voltages() (float64, float64, float64, error) {
|
||||
res, err := wb.data.Get()
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return 0, 0, 0, api.ErrTimeout
|
||||
}
|
||||
|
||||
return res.L1Voltage, res.L2Voltage, res.L3Voltage, nil
|
||||
}
|
||||
66
charger/evsemaster/connection.go
Normal file
66
charger/evsemaster/connection.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package evsemaster
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// Connection holds per-device credentials and routes sends through the shared listener.
|
||||
// Multiple Connection instances with different serials can coexist; the Listener
|
||||
// routes incoming packets to each by serial number.
|
||||
type Connection struct {
|
||||
lst *Listener
|
||||
serial string
|
||||
password string
|
||||
recv chan<- *ReceivedPacket // channel passed to Subscribe, used to identify on Unsubscribe
|
||||
}
|
||||
|
||||
// NewConnection creates a Connection for a device identified by serial and password.
|
||||
func NewConnection(log *util.Logger, serial, password string) (*Connection, error) {
|
||||
lst, err := Instance(log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Connection{lst: lst, serial: serial, password: password}, nil
|
||||
}
|
||||
|
||||
// Send packs a command with device credentials and sends it to the given EVSE address.
|
||||
func (c *Connection) Send(cmd uint16, payload []byte, addr *net.UDPAddr) error {
|
||||
pkt := &Packet{
|
||||
Serial: c.serial,
|
||||
Password: c.password,
|
||||
Command: cmd,
|
||||
Payload: payload,
|
||||
}
|
||||
buf, err := pkt.Pack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.lst.Send(buf, addr)
|
||||
}
|
||||
|
||||
// Subscribe registers ch to receive all packets from this device's serial.
|
||||
func (c *Connection) Subscribe(ch chan<- *ReceivedPacket) {
|
||||
c.recv = ch
|
||||
c.lst.Subscribe(c.serial, ch)
|
||||
}
|
||||
|
||||
// Reclaim registers ch only if no subscriber currently holds the slot.
|
||||
// Used on keepalive ticks so the long-running instance does not displace
|
||||
// a temporary validate instance that is still active.
|
||||
func (c *Connection) Reclaim(ch chan<- *ReceivedPacket) {
|
||||
c.recv = ch
|
||||
c.lst.Reclaim(c.serial, ch)
|
||||
}
|
||||
|
||||
// Unsubscribe removes this connection's subscription only if its channel is
|
||||
// still the active one, so a stale unsubscribe cannot displace a newer subscriber.
|
||||
func (c *Connection) Unsubscribe() {
|
||||
c.lst.Unsubscribe(c.serial, c.recv)
|
||||
}
|
||||
|
||||
// Addr gets or sets the last known EVSE address for this device.
|
||||
func (c *Connection) Addr(addr *net.UDPAddr) *net.UDPAddr {
|
||||
return c.lst.Addr(c.serial, c.password, addr)
|
||||
}
|
||||
154
charger/evsemaster/listener.go
Normal file
154
charger/evsemaster/listener.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package evsemaster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
var (
|
||||
listenerMu sync.Mutex
|
||||
sharedListener *Listener
|
||||
)
|
||||
|
||||
// Listener is a singleton UDP listener that routes incoming EVSE Master packets
|
||||
// to subscribers by device serial number.
|
||||
//
|
||||
// EVSE Master stations broadcast on port 28376 and always reply to the sender
|
||||
// on the same port, so a shared listener is required – the same pattern as the
|
||||
// KEBA UDP listener.
|
||||
type Listener struct {
|
||||
mu sync.RWMutex
|
||||
log *util.Logger
|
||||
conn *net.UDPConn
|
||||
clients map[string]chan<- *ReceivedPacket // keyed by 16-char hex serial
|
||||
|
||||
addrsMu sync.Mutex
|
||||
addrs map[string]*net.UDPAddr // keyed by serial:password
|
||||
}
|
||||
|
||||
// Instance returns the singleton listener, creating it on first call.
|
||||
func Instance(log *util.Logger) (*Listener, error) {
|
||||
listenerMu.Lock()
|
||||
defer listenerMu.Unlock()
|
||||
|
||||
if sharedListener == nil {
|
||||
var err error
|
||||
sharedListener, err = newListener(log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return sharedListener, nil
|
||||
}
|
||||
|
||||
func newListener(log *util.Logger) (*Listener, error) {
|
||||
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", Port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind :%d: %w (is another process using this port?)", Port, err)
|
||||
}
|
||||
|
||||
l := &Listener{
|
||||
log: log,
|
||||
conn: conn,
|
||||
clients: make(map[string]chan<- *ReceivedPacket),
|
||||
addrs: make(map[string]*net.UDPAddr),
|
||||
}
|
||||
|
||||
go l.listen()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// Subscribe registers ch to receive all packets from the given serial,
|
||||
// replacing any existing subscriber.
|
||||
func (l *Listener) Subscribe(serial string, ch chan<- *ReceivedPacket) {
|
||||
l.mu.Lock()
|
||||
l.clients[serial] = ch
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Reclaim registers ch only if the serial has no current subscriber.
|
||||
// Used by the long-running instance to reclaim its slot after a temporary
|
||||
// validate instance has finished and unsubscribed.
|
||||
func (l *Listener) Reclaim(serial string, ch chan<- *ReceivedPacket) {
|
||||
l.mu.Lock()
|
||||
if _, ok := l.clients[serial]; !ok {
|
||||
l.clients[serial] = ch
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Unsubscribe removes the subscription for the given serial only if ch is
|
||||
// still the current subscriber, preventing a stale unsubscribe from displacing
|
||||
// a newer subscriber (e.g. after a validate instance is replaced by main).
|
||||
func (l *Listener) Unsubscribe(serial string, ch chan<- *ReceivedPacket) {
|
||||
l.mu.Lock()
|
||||
if l.clients[serial] == ch {
|
||||
delete(l.clients, serial)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// Addr gets or sets the last known EVSE address for a serial+password key.
|
||||
// Keyed by both so that a different-password validate does not reuse a cached
|
||||
// address and get a false-positive result.
|
||||
// If addr is non-nil it is stored; the stored value is always returned.
|
||||
func (l *Listener) Addr(serial, password string, addr *net.UDPAddr) *net.UDPAddr {
|
||||
key := serial + ":" + password
|
||||
l.addrsMu.Lock()
|
||||
defer l.addrsMu.Unlock()
|
||||
if addr != nil {
|
||||
l.addrs[key] = addr
|
||||
return addr
|
||||
}
|
||||
return l.addrs[key]
|
||||
}
|
||||
|
||||
// Send sends buf to the given address using the shared listener socket.
|
||||
func (l *Listener) Send(buf []byte, addr *net.UDPAddr) error {
|
||||
_, err := l.conn.WriteTo(buf, addr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Listener) listen() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, src, err := l.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
l.log.ERROR.Printf("evsemaster listener: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
pkt, err := Unpack(buf[:n])
|
||||
if err != nil {
|
||||
l.log.TRACE.Printf("unpack error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
l.mu.RLock()
|
||||
ch, ok := l.clients[pkt.Serial]
|
||||
l.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
l.log.TRACE.Printf("no subscriber for serial %s (cmd 0x%04x)", pkt.Serial, pkt.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
rp := &ReceivedPacket{Packet: pkt, From: src}
|
||||
|
||||
select {
|
||||
case ch <- rp:
|
||||
default:
|
||||
l.log.TRACE.Printf("recv channel full for %s", pkt.Serial)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
charger/evsemaster/protocol.go
Normal file
221
charger/evsemaster/protocol.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// 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
|
||||
}
|
||||
40
templates/definition/charger/evsemaster-udp.yaml
Normal file
40
templates/definition/charger/evsemaster-udp.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
template: evsemaster-udp
|
||||
requirements:
|
||||
evcc: ["sponsorship", "skiptest"]
|
||||
description:
|
||||
de: >
|
||||
Die Ladestation und evcc müssen sich im selben Netzwerksegment (VLAN) befinden,
|
||||
da die Erkennung über UDP-Broadcast erfolgt. Broadcasts werden von den meisten
|
||||
Routern nicht zwischen VLANs weitergeleitet.
|
||||
en: >
|
||||
The charger and evcc must be on the same network segment (VLAN).
|
||||
Discovery relies on UDP broadcast, which most routers do not forward across VLANs.
|
||||
products:
|
||||
- brand: EVSE Master
|
||||
params:
|
||||
- name: serial
|
||||
required: true
|
||||
description:
|
||||
de: Seriennummer (16-stellige Hex-Zeichenkette)
|
||||
en: Serial number (16-character hex string)
|
||||
example: "0906252400004617"
|
||||
help:
|
||||
de: Die Seriennummer steht auf dem Typenschild der Ladestation (8 Byte als Hex).
|
||||
en: Found on the device label. Enter as a 16-character hex string (8 bytes).
|
||||
- name: password
|
||||
required: true
|
||||
mask: true
|
||||
description:
|
||||
de: Passwort (wie in der EVSE Master App gesetzt)
|
||||
en: Password (set in the EVSE Master app)
|
||||
help:
|
||||
de: >
|
||||
Das Passwort wird in der EVSE Master App unter Geräteeinstellungen
|
||||
konfiguriert. App und evcc dürfen nicht gleichzeitig verbunden sein.
|
||||
en: >
|
||||
Set in the EVSE Master app under device settings.
|
||||
The app and evcc must not be connected at the same time.
|
||||
render: |
|
||||
type: evsemaster-udp
|
||||
serial: {{ .serial }}
|
||||
password: {{ .password }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue