Add EVSE Master (Besen, Telestar, Morec, Sync chargers) (#28359)

This commit is contained in:
Nazeeh Maxim Kanaaneh 2026-04-26 09:48:10 +03:00 • committed by GitHub
parent e9dc3cf8e6
commit 873da67448
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 817 additions and 0 deletions

View file

@ -91,6 +91,8 @@ EXPOSE 5353/udp
EXPOSE 7070/tcp EXPOSE 7070/tcp
# KEBA charger # KEBA charger
EXPOSE 7090/udp EXPOSE 7090/udp
# EVSE Master charger
EXPOSE 28376/udp
# OCPP charger # OCPP charger
EXPOSE 8887/tcp EXPOSE 8887/tcp
# Modbus UDP # Modbus UDP

334
charger/evsemaster.go Normal file
View 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
}

View 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)
}

View 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)
}
}
}

View 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
}

View 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 }}