diff --git a/Dockerfile b/Dockerfile index 53f9d1a1b..828b603e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/charger/evsemaster.go b/charger/evsemaster.go new file mode 100644 index 000000000..7cd0ec443 --- /dev/null +++ b/charger/evsemaster.go @@ -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 +} diff --git a/charger/evsemaster/connection.go b/charger/evsemaster/connection.go new file mode 100644 index 000000000..c8961f3ce --- /dev/null +++ b/charger/evsemaster/connection.go @@ -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) +} diff --git a/charger/evsemaster/listener.go b/charger/evsemaster/listener.go new file mode 100644 index 000000000..63a12dd3e --- /dev/null +++ b/charger/evsemaster/listener.go @@ -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) + } + } +} diff --git a/charger/evsemaster/protocol.go b/charger/evsemaster/protocol.go new file mode 100644 index 000000000..0451b304c --- /dev/null +++ b/charger/evsemaster/protocol.go @@ -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 +} diff --git a/templates/definition/charger/evsemaster-udp.yaml b/templates/definition/charger/evsemaster-udp.yaml new file mode 100644 index 000000000..5f2df7daa --- /dev/null +++ b/templates/definition/charger/evsemaster-udp.yaml @@ -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 }}