evcc-io/charger/e3dc.go

868 lines
26 KiB
Go

package charger
// E3DC Multi Connect II Wallbox Charger
//
// REQUIREMENTS - Configure the following in the E3DC portal for evcc control:
// - Sun Mode (Sonnenmodus): OFF - will be disabled automatically at startup
// - Auto Phase Switching: OFF - required if evcc should control 1p/3p switching
// - Charge Authorization: OFF or configure RFID - evcc needs to control charging
//
// Sun mode is automatically disabled at startup. Auto phase switching generates
// a warning but is not changed automatically (user may want to keep it).
//
// DEVELOPMENT STATUS:
// - Tested with E3DC Multi Connect II Wallbox (FW 7.0.6.0/1.0.3.0)
// - Phase switching (1p3p): E3DC handles ramping internally (tested)
// - Requires testing with additional E3DC systems before production use
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"sync"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/sirupsen/logrus"
"github.com/spali/go-rscp/rscp"
"github.com/spf13/cast"
)
// E3dc charger implementation using RSCP protocol.
// Communicates with the E3DC Hauskraftwerk via TCP connection.
type E3dc struct {
log *util.Logger // Logger instance for debug/warning output
conn *rscp.Client // RSCP client connection to E3DC system
id uint8 // Wallbox index (0 = first wallbox, 1 = second, etc.)
}
func init() {
registry.AddCtx("e3dc-rscp", NewE3dcFromConfig)
}
// NewE3dcFromConfig creates an E3DC charger from generic config.
// Called by evcc's charger registry when type "e3dc-rscp" is configured.
//
// Configuration parameters:
// - uri: IP:Port of E3DC system (default port 5033)
// - user: E3DC portal username
// - password: E3DC portal password
// - key: RSCP encryption key (configured in E3DC Hauskraftwerk settings)
// - id: Wallbox index (0 = first wallbox)
// - timeout: Connection timeout (optional)
func NewE3dcFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
cc := struct {
Uri string
User string
Password string
Key string
Id uint8
Timeout time.Duration
}{
Timeout: request.Timeout,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
host, portStr, err := net.SplitHostPort(util.DefaultPort(cc.Uri, 5033))
if err != nil {
return nil, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port: %w", err)
}
cfg := rscp.ClientConfig{
Address: host,
Port: uint16(port),
Username: cc.User,
Password: cc.Password,
Key: cc.Key,
ConnectionTimeout: cc.Timeout,
SendTimeout: cc.Timeout,
ReceiveTimeout: cc.Timeout,
}
return NewE3dc(ctx, cfg, cc.Id)
}
var e3dcOnce sync.Once
// NewE3dc creates E3DC charger
func NewE3dc(ctx context.Context, cfg rscp.ClientConfig, id uint8) (*E3dc, error) {
log := util.NewLogger("e3dc")
// Configure RSCP library logging to use evcc's TRACE level.
// Setting DebugLevel ensures we get detailed RSCP protocol output,
// but routing to TRACE.Writer() means it only appears when evcc is in trace mode.
e3dcOnce.Do(func() {
rscp.Log.SetLevel(logrus.DebugLevel)
rscp.Log.SetOutput(log.TRACE.Writer())
})
conn, err := rscp.NewClient(cfg)
if err != nil {
return nil, err
}
if !sponsor.IsAuthorized() {
return nil, api.ErrSponsorRequired
}
wb := &E3dc{
log: log,
conn: conn,
id: id,
}
// Check wallbox configuration and warn if not optimal for evcc control
if err := wb.checkConfiguration(); err != nil {
return nil, err
}
return wb, nil
}
// checkConfiguration verifies wallbox settings and adjusts them for evcc control
func (wb *E3dc) checkConfiguration() error {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil),
*rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil),
}))
if err != nil {
return fmt.Errorf("failed to query wallbox configuration: %w", err)
}
wbData, err := rscpContainer(*res, 3)
if err != nil {
return fmt.Errorf("failed to parse wallbox configuration: %v", err)
}
// Check and disable sun mode - evcc needs to control charging
// Note: Sun mode is also checked in ensureSunModeDisabled() on every control command
// because the user could re-enable it in the E3DC portal at any time
if sunMode, err := rscpBool(wbData[1]); err == nil && sunMode {
wb.log.WARN.Println("wallbox sun mode is enabled - disabling for evcc control")
wb.disableSunMode()
}
// Warn about auto phase switching - user may want to keep it for non-evcc use
// Note: We only warn here; the actual check happens in Phases1p3p() because
// the user could change this setting in the E3DC portal at any time
if autoPhase, err := rscpBool(wbData[2]); err == nil && autoPhase {
wb.log.WARN.Println("wallbox auto phase switching is enabled - disable in E3DC portal if you want evcc to control 1p/3p switching")
}
return nil
}
// disableSunMode sends the command to disable sun mode
func (wb *E3dc) disableSunMode() {
if _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SET_SUN_MODE_ACTIVE, false),
})); err != nil {
wb.log.ERROR.Printf("failed to disable sun mode: %v", err)
}
}
// ensureSunModeDisabled checks if sun mode is active and disables it.
// Called before control commands (Enable, MaxCurrent) because the user could
// re-enable sun mode in the E3DC portal at any time without restarting evcc.
func (wb *E3dc) ensureSunModeDisabled() {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil),
}))
if err != nil {
return
}
wbData, err := rscpContainer(*res, 2)
if err != nil {
return
}
if sunMode, err := rscpBool(wbData[1]); err == nil && sunMode {
wb.log.WARN.Println("sun mode was re-enabled - disabling for evcc control")
wb.disableSunMode()
}
}
// getExternDataAlg retrieves the WB_EXTERN_DATA_ALG status byte array.
// This is the primary source for wallbox status information.
//
// Returns a byte array where:
// - Byte 0: Unknown
// - Byte 1: Number of phases (1 or 3)
// - Byte 2: Status flags (see Status() and Enabled() for bit definitions)
// - Byte 3: Max charge current in Ampere
//
// Used by Status() and Enabled() to determine charging state.
func (wb *E3dc) getExternDataAlg() ([]byte, error) {
// RSCP request pattern: WB_REQ_DATA container with WB_INDEX + request tags
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_EXTERN_DATA_ALG, nil),
}))
if err != nil {
return nil, err
}
// Response structure: WB_DATA[WB_INDEX, WB_EXTERN_DATA_ALG[WB_INDEX, ByteArray]]
wbData, err := rscpContainer(*res, 2)
if err != nil {
return nil, err
}
// WB_EXTERN_DATA_ALG is itself a container with index and data
wbExtDataAlg, err := rscpContainer(wbData[1], 2)
if err != nil {
return nil, err
}
b, err := rscpBytes(wbExtDataAlg[1])
if err != nil {
return nil, err
}
if len(b) < 3 {
return nil, fmt.Errorf("invalid WB_EXTERN_DATA_ALG length: %d", len(b))
}
return b, nil
}
// Enabled implements the api.Charger interface
func (wb *E3dc) Enabled() (bool, error) {
b, err := wb.getExternDataAlg()
if err != nil {
return false, err
}
// WB_EXTERN_DATA_ALG Byte 2, Bit 6 (0b01000000): 0 = enabled, 1 = disabled (abort active)
return b[2]&0b01000000 == 0, nil
}
// Enable implements the api.Charger interface
// Controls charging by setting the abort flag (inverted logic: abort=false means enabled)
func (wb *E3dc) Enable(enable bool) error {
wb.ensureSunModeDisabled()
_, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SET_ABORT_CHARGING, !enable),
}))
return err
}
// Status implements the api.Charger interface
// Returns the charging state by reading status flags from WB_EXTERN_DATA_ALG
func (wb *E3dc) Status() (api.ChargeStatus, error) {
b, err := wb.getExternDataAlg()
if err != nil {
return api.StatusNone, err
}
// WB_EXTERN_DATA_ALG Byte 2 status bits:
// Bit 5 (0b00100000): Charging active
// Bit 3 (0b00001000): Vehicle connected
// Bit 2 (0b00000100): Ready, no vehicle
//
// IMPORTANT: Check order is C→B→A (not A→B→C) because bits are not mutually exclusive!
// When charging (StatusC), both Bit 5 AND Bit 3 are set (e.g., 0b00101000).
// We must check Bit 5 first, otherwise 0b00101000 would incorrectly match StatusB.
//
// Explicitly checking Bit 2 for StatusA allows us to detect error states
// like 0b00000000 (no bits set) or 0b01000000 (only disabled flag).
switch {
case b[2]&0b00100000 != 0: // Bit 5: charging active → StatusC
return api.StatusC, nil
case b[2]&0b00001000 != 0: // Bit 3: vehicle connected → StatusB
return api.StatusB, nil
case b[2]&0b00000100 != 0: // Bit 2: ready, no vehicle → StatusA
return api.StatusA, nil
default:
return api.StatusNone, fmt.Errorf("unknown wallbox status: 0x%02x", b[2])
}
}
// MaxCurrent implements the api.Charger interface.
// Sets the maximum charging current in Ampere (whole numbers only, 6-32A typical range).
func (wb *E3dc) MaxCurrent(current int64) error {
wb.ensureSunModeDisabled()
_, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SET_MAX_CHARGE_CURRENT, uint8(current)),
}))
return err
}
var _ api.Meter = (*E3dc)(nil)
// CurrentPower implements the api.Meter interface
// Returns the total charging power by summing all three phases
func (wb *E3dc) CurrentPower() (float64, error) {
p1, p2, p3, err := wb.powers()
if err != nil {
return 0, err
}
return p1 + p2 + p3, nil
}
var _ api.MeterEnergy = (*E3dc)(nil)
// TotalEnergy implements the api.MeterEnergy interface
//
// E3DC stores wallbox energy in two separate counters that must be added:
// - DB_TEC_WALLBOX_ENERGYALL: Historical energy stored in the database (persisted)
// - WB_ENERGY_ALL: Energy since last database sync (volatile, resets on sync)
//
// The sum of both values matches the total energy shown in the E3DC portal.
// Testing showed: DB_TEC (8319 kWh) + WB_ENERGY (699 kWh) = 9018 kWh ≈ Portal (9019 kWh)
func (wb *E3dc) TotalEnergy() (float64, error) {
// Query both energy sources sequentially
res, err := wb.conn.Send(*rscp.NewMessage(rscp.DB_REQ_TEC_WALLBOX_VALUES, nil))
if err != nil {
return 0, err
}
// Parse DB_TEC_WALLBOX_VALUES response
// Structure: DB_TEC_WALLBOX_VALUES -> DB_TEC_WALLBOX_VALUES -> []DB_TEC_WALLBOX_VALUE
// Each DB_TEC_WALLBOX_VALUE contains: DB_TEC_WALLBOX_INDEX, DB_TEC_WALLBOX_ENERGYALL, DB_TEC_WALLBOX_WB_ENERGY_SOLAR
outer, err := rscpContainer(*res, 1)
if err != nil {
return 0, err
}
inner, err := rscpContainer(outer[0], 1)
if err != nil {
return 0, err
}
// Find the wallbox with matching index
var dbEnergy float64
var found bool
for _, wbValue := range inner {
wbData, err := rscpContainer(wbValue, 3)
if err != nil {
continue
}
idx, err := rscpUint8(wbData[0])
if err != nil || idx != wb.id {
continue
}
dbEnergy, err = rscpFloat64(wbData[1])
if err != nil {
return 0, err
}
found = true
break
}
if !found {
wb.log.WARN.Printf("wallbox index %d not found in DB_TEC_WALLBOX_VALUES - total energy may be inaccurate", wb.id)
}
// Query WB_ENERGY_ALL for energy since last DB sync
res, err = wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_ENERGY_ALL, nil),
}))
if err != nil {
return 0, err
}
wbData, err := rscpContainer(*res, 2)
if err != nil {
return 0, err
}
wbEnergy, err := rscpFloat64(wbData[1])
if err != nil {
return 0, err
}
// Sum both counters and convert Wh to kWh
return (dbEnergy + wbEnergy) / 1000.0, nil
}
// powers returns the charging power for each individual phase in watts.
// Used internally by CurrentPower() and Currents().
// Returns (L1, L2, L3) power values - unused phases return 0.
func (wb *E3dc) powers() (float64, float64, float64, error) {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_PM_POWER_L1, nil),
*rscp.NewMessage(rscp.WB_REQ_PM_POWER_L2, nil),
*rscp.NewMessage(rscp.WB_REQ_PM_POWER_L3, nil),
}))
if err != nil {
return 0, 0, 0, err
}
// Response: WB_DATA[WB_INDEX, WB_PM_POWER_L1, WB_PM_POWER_L2, WB_PM_POWER_L3]
wbData, err := rscpContainer(*res, 4)
if err != nil {
return 0, 0, 0, err
}
// Extract power values (index 0 is WB_INDEX, 1-3 are the power values)
p1, err := rscpFloat64(wbData[1])
if err != nil {
return 0, 0, 0, err
}
p2, err := rscpFloat64(wbData[2])
if err != nil {
return 0, 0, 0, err
}
p3, err := rscpFloat64(wbData[3])
if err != nil {
return 0, 0, 0, err
}
return p1, p2, p3, nil
}
// var _ api.PhaseCurrents = (*E3dc)(nil)
// // Currents implements the api.PhaseCurrents interface
// // Calculates current from power readings as voltage readings are not accessible
// func (wb *E3dc) Currents() (float64, float64, float64, error) {
// p1, p2, p3, err := wb.powers()
// if err != nil {
// return 0, 0, 0, err
// }
// // Calculate current from power using nominal 230V
// // Note: WB_REQ_DIAG_PHASE_VOLTAGE returns ERR_ACCESS_DENIED
// const voltage = 230.0
// i1 := p1 / voltage
// i2 := p2 / voltage
// i3 := p3 / voltage
// return i1, i2, i3, nil
// }
var _ api.PhaseGetter = (*E3dc)(nil)
// GetPhases implements the api.PhaseGetter interface
// Returns the configured number of phases (1 or 3)
// Note: WB_PM_ACTIVE_PHASES reports physical wiring, WB_NUMBER_PHASES reports actual configuration
func (wb *E3dc) GetPhases() (int, error) {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_NUMBER_PHASES, nil),
}))
if err != nil {
return 0, err
}
wbData, err := rscpContainer(*res, 2)
if err != nil {
return 0, err
}
phases, err := rscpUint8(wbData[1])
if err != nil {
return 0, err
}
return int(phases), nil
}
var _ api.CurrentLimiter = (*E3dc)(nil)
// GetMinMaxCurrent implements the api.CurrentLimiter interface
// Returns the wallbox's hardware current limits (typically 6-32A)
func (wb *E3dc) GetMinMaxCurrent() (float64, float64, error) {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_LOWER_CURRENT_LIMIT, nil),
*rscp.NewMessage(rscp.WB_REQ_UPPER_CURRENT_LIMIT, nil),
}))
if err != nil {
return 0, 0, err
}
wbData, err := rscpContainer(*res, 3)
if err != nil {
return 0, 0, err
}
minCurrent, err := rscpFloat64(wbData[1])
if err != nil {
return 0, 0, err
}
maxCurrent, err := rscpFloat64(wbData[2])
if err != nil {
return 0, 0, err
}
return minCurrent, maxCurrent, nil
}
var _ api.CurrentGetter = (*E3dc)(nil)
// GetMaxCurrent implements the api.CurrentGetter interface
// Returns the currently configured maximum charging current
func (wb *E3dc) GetMaxCurrent() (float64, error) {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_MAX_CHARGE_CURRENT, nil),
}))
if err != nil {
return 0, err
}
wbData, err := rscpContainer(*res, 2)
if err != nil {
return 0, err
}
current, err := rscpFloat64(wbData[1])
if err != nil {
return 0, err
}
return current, nil
}
// getSessionData retrieves the session data container from WB_REQ_SESSION.
// Returns all session-related messages (energy, time, RFID, etc.).
// If no vehicle is connected, returns only WB_INDEX with no session data.
func (wb *E3dc) getSessionData() ([]rscp.Message, error) {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_SESSION, nil))
if err != nil {
return nil, err
}
return rscpContainer(*res, 1)
}
// sessionMessage finds a specific tag in the WB_SESSION response data.
// Used by ChargedEnergy, ChargeDuration, and Identify to extract session values.
// Returns (message, true, nil) if found, (empty, false, nil) if no active session,
// or (empty, false, error) on communication failure.
func (wb *E3dc) sessionMessage(tag rscp.Tag) (rscp.Message, bool, error) {
sessionData, err := wb.getSessionData()
if err != nil {
return rscp.Message{}, false, err
}
for _, msg := range sessionData {
if msg.Tag == tag {
return msg, true, nil
}
}
return rscp.Message{}, false, nil
}
var _ api.ChargeRater = (*E3dc)(nil)
// ChargedEnergy implements the api.ChargeRater interface
// Returns the energy charged in the current session from WB_SESSION_CHARGED_ENERGY
func (wb *E3dc) ChargedEnergy() (float64, error) {
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_CHARGED_ENERGY)
if err != nil || !found {
return 0, err
}
energy, err := rscpFloat64(msg)
if err != nil {
return 0, err
}
return energy / 1000.0, nil // Wh -> kWh
}
var _ api.ChargeTimer = (*E3dc)(nil)
// ChargeDuration implements the api.ChargeTimer interface
// Returns the active charging duration from WB_SESSION_ACTIVE_CHARGE_TIME
func (wb *E3dc) ChargeDuration() (time.Duration, error) {
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_ACTIVE_CHARGE_TIME)
if err != nil || !found {
return 0, err
}
// Session time is in milliseconds (Uint64)
ms, err := rscpUint64(msg)
if err != nil {
return 0, err
}
return time.Duration(ms) * time.Millisecond, nil
}
var _ api.Identifier = (*E3dc)(nil)
// Identify implements the api.Identifier interface
// Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active
func (wb *E3dc) Identify() (string, error) {
msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA)
if err != nil || !found {
return "", err
}
return rscpString(msg)
}
var _ api.PhaseSwitcher = (*E3dc)(nil)
// Phases1p3p implements the api.PhaseSwitcher interface
// Switches between 1-phase and 3-phase charging
// The wallbox handles the safe switching sequence internally (reduce current, switch, ramp up)
// Requirements: WB_AUTO_PHASE_SWITCH_ENABLED must be disabled in the E3DC dashboard
func (wb *E3dc) Phases1p3p(phases int) error {
if phases != 1 && phases != 3 {
return fmt.Errorf("invalid phases: %d (must be 1 or 3)", phases)
}
wb.ensureSunModeDisabled()
// Check if automatic phase switching is disabled (required for manual control)
// Note: We query this on every call rather than caching because the user can
// change this setting in the E3DC portal at any time without restarting evcc.
// A startup warning is also issued in checkConfiguration().
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil),
}))
if err != nil {
return err
}
wbData, err := rscpContainer(*res, 2)
if err != nil {
return err
}
autoPhaseSwitch, err := rscpBool(wbData[1])
if err != nil {
return err
}
if autoPhaseSwitch {
return errors.New("automatic phase switching is enabled - please disable it in the E3DC dashboard to allow manual phase control")
}
// Perform phase switch
_, err = wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_SET_NUMBER_PHASES, uint8(phases)),
}))
return err
}
var _ api.Diagnosis = (*E3dc)(nil)
// Diagnose implements the api.Diagnosis interface.
// Outputs wallbox information for debugging via evcc's "evcc charger" command.
// Shows device name, firmware, current limits, phase config, and status flags.
func (wb *E3dc) Diagnose() {
res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{
*rscp.NewMessage(rscp.WB_INDEX, wb.id),
*rscp.NewMessage(rscp.WB_REQ_DEVICE_NAME, nil),
*rscp.NewMessage(rscp.WB_REQ_FIRMWARE_VERSION, nil),
*rscp.NewMessage(rscp.WB_REQ_MAX_CHARGE_CURRENT, nil),
*rscp.NewMessage(rscp.WB_REQ_LOWER_CURRENT_LIMIT, nil),
*rscp.NewMessage(rscp.WB_REQ_UPPER_CURRENT_LIMIT, nil),
*rscp.NewMessage(rscp.WB_REQ_NUMBER_PHASES, nil),
*rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil),
*rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil),
*rscp.NewMessage(rscp.WB_REQ_EXTERN_DATA_ALG, nil),
}))
if err != nil {
fmt.Printf("\tError: %v\n", err)
return
}
wbData, err := rscpContainer(*res, 10)
if err != nil {
fmt.Printf("\tError: %v\n", err)
return
}
if name, err := rscpString(wbData[1]); err == nil {
fmt.Printf("\tDevice:\t%s\n", name)
}
if fw, err := rscpString(wbData[2]); err == nil {
fmt.Printf("\tFirmware:\t%s\n", fw)
}
if current, err := rscpFloat64(wbData[3]); err == nil {
fmt.Printf("\tMax current:\t%.0fA\n", current)
}
if minI, err := rscpFloat64(wbData[4]); err == nil {
if maxI, err := rscpFloat64(wbData[5]); err == nil {
fmt.Printf("\tCurrent limits:\t%.0f-%.0fA\n", minI, maxI)
}
}
if phases, err := rscpUint8(wbData[6]); err == nil {
fmt.Printf("\tPhases:\t%d\n", phases)
}
if sunMode, err := rscpBool(wbData[7]); err == nil {
fmt.Printf("\tSun mode:\t%t\n", sunMode)
}
if autoPhase, err := rscpBool(wbData[8]); err == nil {
fmt.Printf("\tAuto phase switch:\t%t\n", autoPhase)
}
if extData, err := rscpContainer(wbData[9], 2); err == nil {
if b, err := rscpBytes(extData[1]); err == nil && len(b) >= 3 {
status := b[2]
var state string
switch {
case status&0b00100000 != 0:
state = "C (charging)"
case status&0b00001000 != 0:
state = "B (connected)"
case status&0b00000100 != 0:
state = "A (available)"
default:
state = "unknown"
}
enabled := status&0b01000000 == 0
fmt.Printf("\tStatus:\t%s\n", state)
fmt.Printf("\tEnabled:\t%t\n", enabled)
fmt.Printf("\tStatus bits:\t%08b\n", status)
}
}
}
// ===========================================================================
// RSCP Helper Functions
// ===========================================================================
// These functions handle the parsing of RSCP protocol responses.
// RSCP messages contain typed values that need to be extracted and validated.
//
// Typical usage pattern:
// 1. Send request via wb.conn.Send()
// 2. Parse response container via rscpContainer()
// 3. Extract typed values via rscpFloat64(), rscpBool(), rscpString(), etc.
// ===========================================================================
// rscpError extracts error messages from RSCP responses.
// RSCP uses a special Error datatype to indicate failures (e.g., ERR_ACCESS_DENIED).
func rscpError(msg ...rscp.Message) error {
var errs []error
for _, m := range msg {
if m.DataType == rscp.Error {
errs = append(errs, errors.New(rscp.RscpError(cast.ToUint32(m.Value)).String()))
}
}
return errors.Join(errs...)
}
// rscpContainer extracts and validates a container message.
// RSCP containers hold multiple sub-messages (like WB_DATA holding WB_INDEX + values).
// The length parameter specifies minimum expected sub-messages.
func rscpContainer(msg rscp.Message, length int) ([]rscp.Message, error) {
if err := rscpError(msg); err != nil {
return nil, err
}
if msg.DataType != rscp.Container {
return nil, errors.New("invalid response")
}
res, ok := msg.Value.([]rscp.Message)
if !ok {
return nil, errors.New("invalid response")
}
if l := len(res); l < length {
return nil, fmt.Errorf("invalid length: expected at least %d, got %d", length, l)
}
return res, nil
}
// rscpBytes extracts a byte array from an RSCP message.
// Used for WB_EXTERN_DATA_ALG which contains status flags as raw bytes.
func rscpBytes(msg rscp.Message) ([]byte, error) {
return rscpValue(msg, func(data any) ([]byte, error) {
b, ok := data.([]uint8)
if !ok {
return nil, errors.New("invalid response")
}
return b, nil
})
}
// rscpFloat64 extracts a float64 value from an RSCP message.
// Used for power (W), energy (Wh), and current (A) values.
// Handles automatic type conversion from RSCP's various numeric types.
func rscpFloat64(msg rscp.Message) (float64, error) {
return rscpValue(msg, func(data any) (float64, error) {
return cast.ToFloat64E(data)
})
}
// rscpUint8 extracts a uint8 value from an RSCP message.
// Used for WB_INDEX, WB_NUMBER_PHASES, and similar small integer values.
func rscpUint8(msg rscp.Message) (uint8, error) {
return rscpValue(msg, func(data any) (uint8, error) {
return cast.ToUint8E(data)
})
}
// rscpString extracts a string value from an RSCP message.
// Used for WB_DEVICE_NAME, WB_FIRMWARE_VERSION, WB_SESSION_AUTH_DATA (RFID), etc.
func rscpString(msg rscp.Message) (string, error) {
return rscpValue(msg, func(data any) (string, error) {
return cast.ToStringE(data)
})
}
// rscpBool extracts a bool value from an RSCP message.
// Used for WB_SUN_MODE_ACTIVE, WB_AUTO_PHASE_SWITCH_ENABLED, etc.
func rscpBool(msg rscp.Message) (bool, error) {
return rscpValue(msg, func(data any) (bool, error) {
b, ok := data.(bool)
if !ok {
return false, errors.New("invalid response")
}
return b, nil
})
}
// rscpUint64 extracts a uint64 value from an RSCP message.
// Used for WB_SESSION_ACTIVE_CHARGE_TIME (milliseconds), etc.
func rscpUint64(msg rscp.Message) (uint64, error) {
return rscpValue(msg, func(data any) (uint64, error) {
return cast.ToUint64E(data)
})
}
// rscpValue is a generic helper for extracting typed values from RSCP messages.
// Takes a conversion function that transforms the raw value to the desired type.
// First checks for RSCP errors, then applies the conversion function.
func rscpValue[T any](msg rscp.Message, fun func(any) (T, error)) (T, error) {
var zero T
if err := rscpError(msg); err != nil {
return zero, err
}
return fun(msg.Value)
}