evcc-io/charger/etek.go

271 lines
7.6 KiB
Go

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.
import (
"context"
"encoding/binary"
"fmt"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
)
// https://www.etek-electric.com/epc-ev-charge-controller/ekepc2-cs-ev-charging-station-controller
// https://www.etek-electric.com/ev-charging-station-knowledge/how-to-setting-rs485-communication-for-ekepc2-c-s-epc-controller
// ETEK EKEPC2 charger implementation
type Etek struct {
implement.Caps
log *util.Logger
conn *modbus.Connection
}
const (
etekRegInvalidMeterAddr = 0xffff // Indicates no external meter configured
// Meter configuration registers
etekRegMeterVoltagesAddr = 90 // Meter voltages address
etekRegMeterCurrentAddr = 93 // Meter total current address
etekRegMeterPowerAddr = 94 // Meter total power address
etekRegMeterEnergyAddr = 95 // Meter total energy address
// Write registers
etekRegRemoteControl = 89 // Remote start/stop (0=invalid, 1=start, 2=stop)
etekRegMaxCurrent = 109 // Max Output Current PWM Duty cycle (*100)
// Read registers
etekRegStatus = 141 // Current working status (0-19)
etekRegCPVoltage = 153 // CP positive voltage
etekRegPWMDuty = 152 // Current output PWM duty cycle
// Metering registers
etekRegVoltages = 159 // Meter voltages
etekRegCurrent = 162 // Meter average phase current
etekRegPower = 163 // Total power of the meter (W)
etekRegEnergy = 164 // Total energy (2 registers, 32-bit, Wh)
)
func init() {
registry.AddCtx("etek", NewEtekFromConfig)
}
// NewEtekFromConfig creates an ETEK EKEPC2 charger from generic config
func NewEtekFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := modbus.Settings{
ID: 255,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
wb, err := NewEtek(ctx, cc)
if err != nil {
return nil, err
}
// Check if external meter is configured
// If register value is 65535 (0xffff), no external meter is configured
// Check power register (94)
if b, err := wb.conn.ReadHoldingRegisters(etekRegMeterPowerAddr, 1); err == nil {
if binary.BigEndian.Uint16(b) != etekRegInvalidMeterAddr {
implement.Has(wb, implement.Meter(wb.currentPower))
}
}
// Check energy register (95)
if b, err := wb.conn.ReadHoldingRegisters(etekRegMeterEnergyAddr, 1); err == nil {
if binary.BigEndian.Uint16(b) != etekRegInvalidMeterAddr {
implement.Has(wb, implement.MeterEnergy(wb.totalEnergy))
}
}
// Check voltage registers (90, 91, 92) - if any is configured, enable voltages
if b, err := wb.conn.ReadHoldingRegisters(etekRegMeterVoltagesAddr, 3); err == nil {
l1 := binary.BigEndian.Uint16(b[0:2])
l2 := binary.BigEndian.Uint16(b[2:4])
l3 := binary.BigEndian.Uint16(b[4:6])
if l1 != etekRegInvalidMeterAddr && l2 != etekRegInvalidMeterAddr && l3 != etekRegInvalidMeterAddr {
implement.Has(wb, implement.PhaseVoltages(wb.voltages))
}
}
return wb, nil
}
// NewEtek creates an ETEK EKEPC2 charger
func NewEtek(ctx context.Context, settings modbus.Settings) (*Etek, error) {
conn, err := settings.Connection(ctx)
if err != nil {
return nil, err
}
log := util.NewLogger("etek")
conn.Logger(log.TRACE)
wb := &Etek{
Caps: implement.New(),
log: log,
conn: conn,
}
return wb, nil
}
// getStatus reads the current working status from register 141
func (wb *Etek) getStatus() (uint16, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegStatus, 1)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
// getCPVoltage reads the CP positive voltage from register 153
func (wb *Etek) getCPVoltage() (uint16, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegCPVoltage, 1)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b), nil
}
// Status implements the api.Charger interface
func (wb *Etek) Status() (api.ChargeStatus, error) {
status, err := wb.getStatus()
if err != nil {
return api.StatusNone, err
}
// Status mapping based on EKEPC2 documentation and user feedback:
// 0: Initialization
// 1: Ready (no vehicle connected)
// 2: Fault
// 3,4: Connected (vehicle connected, not charging)
// 5: Charging
// 19: Emergency stop (when disabled via register 89)
//
// When status is 19 (emergency stop), we need to check CP voltage
// to determine if a vehicle is connected (voltage > 300 means connected)
switch status {
case 1:
// Ready - no vehicle connected
return api.StatusA, nil
case 3, 4:
// Vehicle connected, not charging
return api.StatusB, nil
case 5:
// Charging
return api.StatusC, nil
case 19:
// Emergency stop - check CP voltage to determine actual status
voltage, err := wb.getCPVoltage()
if err != nil {
return api.StatusNone, err
}
if voltage < 300 {
// No vehicle connected
return api.StatusA, nil
}
// Vehicle connected but not charging (disabled)
return api.StatusB, nil
default:
return api.StatusNone, fmt.Errorf("unknown status: %d", status)
}
}
// Enabled implements the api.Charger interface
func (wb *Etek) Enabled() (bool, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegRemoteControl, 1)
if err != nil {
return false, err
}
// Register 89: 0=invalid/disabled, 1=start/enabled, 2=stop/disabled
return binary.BigEndian.Uint16(b) == 1, nil
}
// Enable implements the api.Charger interface
func (wb *Etek) Enable(enable bool) error {
value := uint16(2) // Stop charging
if enable {
value = 1 // Start charging
}
_, err := wb.conn.WriteSingleRegister(etekRegRemoteControl, value)
return err
}
// MaxCurrent implements the api.Charger interface
func (wb *Etek) MaxCurrent(current int64) error {
return wb.MaxCurrentMillis(float64(current))
}
var _ api.ChargerEx = (*Etek)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (wb *Etek) MaxCurrentMillis(current float64) error {
// The PWM value is calculated as: current (A) * 167
if current < 6 {
return fmt.Errorf("current %.1fA is below minimum of 6A", current)
}
_, err := wb.conn.WriteSingleRegister(etekRegMaxCurrent, uint16(current*167))
return err
}
// currentPower implements the api.Meter interface
func (wb *Etek) currentPower() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegPower, 1)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint16(b)), nil
}
// totalEnergy implements the api.MeterEnergy interface
func (wb *Etek) totalEnergy() (float64, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegEnergy, 2)
if err != nil {
return 0, err
}
return float64(binary.BigEndian.Uint32(b)) / 1e3, nil // Convert to kWh
}
// voltages implements the api.PhaseVoltages interface
func (wb *Etek) voltages() (float64, float64, float64, error) {
b, err := wb.conn.ReadHoldingRegisters(etekRegVoltages, 3)
if err != nil {
return 0, 0, 0, err
}
l1 := float64(binary.BigEndian.Uint16(b[0:2]))
l2 := float64(binary.BigEndian.Uint16(b[2:4]))
l3 := float64(binary.BigEndian.Uint16(b[4:6]))
return l1, l2, l3, nil
}