EEBus: enable SHIP Pairing Service (#30842)

This commit is contained in:
andig 2026-07-04 11:36:53 +02:00 • committed by GitHub
parent f01ecb6909
commit 3a560d1254
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 663 additions and 39 deletions

View file

@ -9,6 +9,7 @@
:no-buttons="fromYaml" :no-buttons="fromYaml"
:confirm-remove="$t('config.eebus.removeConfirm')" :confirm-remove="$t('config.eebus.removeConfirm')"
@changed="$emit('changed')" @changed="$emit('changed')"
@open="loadPairings"
> >
<template #default="{ values }: { values: EebusConfig }"> <template #default="{ values }: { values: EebusConfig }">
<p v-if="fromYaml" class="text-muted"> <p v-if="fromYaml" class="text-muted">
@ -48,6 +49,63 @@
> >
<img :src="qrDataUrl" :alt="$t('config.eebus.qr')" class="qr-code" /> <img :src="qrDataUrl" :alt="$t('config.eebus.qr')" class="qr-code" />
</FormRow> </FormRow>
<div v-if="status.ski" class="mb-4">
<h6 class="mb-3">{{ $t("config.eebus.pairings") }}</h6>
<div
v-for="pairing in pairedDevices"
:key="pairing.shipID || pairing.ski"
data-testid="eebus-pairing"
class="mb-2"
>
<div
class="d-flex align-items-center justify-content-between py-2 ps-3 pe-2 border rounded"
>
<div class="flex-grow-1 fw-semibold text-truncate">
{{ pairing.shipID || pairing.ski }}
</div>
<small
v-if="pairing.shipID && pairing.ski"
class="text-muted ms-2 me-2 text-truncate"
>
{{ pairing.ski }}
</small>
<button
type="button"
class="btn btn-sm btn-outline-secondary border-0"
:aria-label="$t('config.eebus.removePairing')"
@click="removePairing(pairing)"
>
<shopicon-regular-trash
size="s"
class="flex-shrink-0"
></shopicon-regular-trash>
</button>
</div>
</div>
<div v-if="!pairedDevices.length" class="text-muted small mb-2">
{{ $t("config.eebus.noPairings") }}
</div>
</div>
<div v-if="status.ski" class="mb-4">
<h6 class="mb-3">{{ $t("config.eebus.configuredDevices") }}</h6>
<div
v-for="pairing in configuredDevices"
:key="pairing.ski"
data-testid="eebus-configured-device"
class="mb-2"
>
<div
class="d-flex align-items-center justify-content-between py-2 ps-3 pe-2 border rounded"
>
<div class="flex-grow-1 fw-semibold text-truncate">
{{ pairing.ski }}
</div>
</div>
</div>
<div v-if="!configuredDevices.length" class="text-muted small mb-2">
{{ $t("config.eebus.noConfiguredDevices") }}
</div>
</div>
<PropertyCollapsible v-if="!fromYaml"> <PropertyCollapsible v-if="!fromYaml">
<template #advanced> <template #advanced>
<div class="alert alert-danger"> <div class="alert alert-danger">
@ -127,8 +185,10 @@
<script lang="ts"> <script lang="ts">
import type { PropType } from "vue"; import type { PropType } from "vue";
import QRCode from "qrcode"; import QRCode from "qrcode";
import "@h2d2/shopicons/es/regular/trash";
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { EebusConfig, EebusStatus, YamlSource } from "@/types/evcc"; import type { EebusConfig, EebusPairing, EebusStatus, YamlSource } from "@/types/evcc";
import api from "@/api";
import JsonModal from "./JsonModal.vue"; import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue"; import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue"; import PropertyField from "./PropertyField.vue";
@ -155,12 +215,19 @@ export default {
data() { data() {
return { return {
qrDataUrl: null as string | null, qrDataUrl: null as string | null,
pairings: [] as EebusPairing[],
}; };
}, },
computed: { computed: {
fromYaml(): boolean { fromYaml(): boolean {
return this.yamlSource === "file"; return this.yamlSource === "file";
}, },
pairedDevices(): EebusPairing[] {
return this.pairings.filter((p) => p.source === "paired");
},
configuredDevices(): EebusPairing[] {
return this.pairings.filter((p) => p.source === "ski");
},
}, },
watch: { watch: {
"status.qr": { "status.qr": {
@ -182,6 +249,25 @@ export default {
formId(s: string) { formId(s: string) {
return `eebus-${s}`; return `eebus-${s}`;
}, },
async loadPairings() {
try {
const res = await api.get("config/service/eebus/pairings");
this.pairings = res.data || [];
} catch {
this.pairings = [];
}
},
async removePairing(pairing: EebusPairing) {
if (!window.confirm(this.$t("config.eebus.removePairingConfirm"))) {
return;
}
try {
const id = encodeURIComponent(pairing.shipID || pairing.ski);
await api.delete(`config/service/eebus/pairings/${id}`);
} finally {
await this.loadPairings();
}
},
}, },
}; };
</script> </script>

View file

@ -593,6 +593,12 @@ export type EebusStatus = {
qr?: string; qr?: string;
}; };
export type EebusPairing = {
ski: string;
shipID: string;
source: "paired" | "ski";
};
export type ModbusProxy = { export type ModbusProxy = {
port: number; port: number;
readonly: MODBUS_PROXY_READONLY; readonly: MODBUS_PROXY_READONLY;

View file

@ -937,6 +937,18 @@ func configureEEBus(conf *eebus.Config) error {
} }
} }
// generate a SHIP pairing secret for configs that predate SHIP pairing
if conf.Secret == "" {
secret, err := eebus.CreatePairingSecret()
if err != nil {
return err
}
conf.Secret = secret
if err := settings.SetJson(keys.EEBus, conf); err != nil {
return err
}
}
srv, err := eebus.NewServer(*conf) srv, err := eebus.NewServer(*conf)
if err != nil { if err != nil {
return fmt.Errorf("failed configuring eebus: %w", err) return fmt.Errorf("failed configuring eebus: %w", err)

View file

@ -264,15 +264,21 @@
"public": "Öffentliches Zertifikat", "public": "Öffentliches Zertifikat",
"title": "Zertifikate" "title": "Zertifikate"
}, },
"configuredDevices": "Konfigurierte Geräte",
"description": "Konfiguration zur Kommunikation mit EEBus-kompatiblen Geräten wie Wallboxen oder dem Steuergerät des Netzbetreibers. Alle notwendigen Initialisierungen und die Zertifikatsgenerierung erfolgen automatisch beim ersten Start.", "description": "Konfiguration zur Kommunikation mit EEBus-kompatiblen Geräten wie Wallboxen oder dem Steuergerät des Netzbetreibers. Alle notwendigen Initialisierungen und die Zertifikatsgenerierung erfolgen automatisch beim ersten Start.",
"descriptionAdvanced": "Keine Änderungen erforderlich. Bei Änderung ist Neukoppelung aller Geräte erforderlich.", "descriptionAdvanced": "Keine Änderungen erforderlich. Bei Änderung ist Neukoppelung aller Geräte erforderlich.",
"interfaces": "Schnittstellen", "interfaces": "Schnittstellen",
"interfacesHelp": "Begrenzt die Netzwerkschnittstellen, die EEBus nutzen soll, um Kommunikationsprobleme zu vermeiden. Feld leer lassen, um alle Schnittstellen zu verwenden. Ein Eintrag pro Zeile.", "interfacesHelp": "Begrenzt die Netzwerkschnittstellen, die EEBus nutzen soll, um Kommunikationsprobleme zu vermeiden. Feld leer lassen, um alle Schnittstellen zu verwenden. Ein Eintrag pro Zeile.",
"noConfiguredDevices": "Keine per SKI konfigurierten Geräte.",
"noPairings": "Keine gekoppelten Geräte.",
"pairings": "Gekoppelte Geräte",
"port": "Port", "port": "Port",
"portHelp": "Der zu verwendende Port.", "portHelp": "Der zu verwendende Port.",
"qr": "Kopplungscode", "qr": "Kopplungscode",
"qrExplain": "Während der Installation von einem anderen EEBus-Gerät scannen, um automatisch zu koppeln, ohne die SKI manuell einzugeben.", "qrExplain": "Während der Installation von einem anderen EEBus-Gerät scannen, um automatisch zu koppeln, ohne die SKI manuell einzugeben.",
"removeConfirm": "Die gesamte EEBus-Konfiguration wird entfernt. Neue Zertifikate und Kennungen werden beim nächsten Start generiert. Bist du sicher?", "removeConfirm": "Die gesamte EEBus-Konfiguration wird entfernt. Neue Zertifikate und Kennungen werden beim nächsten Start generiert. Bist du sicher?",
"removePairing": "Kopplung entfernen",
"removePairingConfirm": "Die Verbindung zum Gerät wird getrennt und es muss neu gekoppelt werden. Bist du sicher?",
"shipid": "SHIP-ID", "shipid": "SHIP-ID",
"shipidExplain": "Permanente Gerätekennung zur Identifikation im EEBus-Netzwerk.", "shipidExplain": "Permanente Gerätekennung zur Identifikation im EEBus-Netzwerk.",
"shipidHelp": "Diese SHIP-ID ist mit den unten stehenden Zertifikaten verknüpft.", "shipidHelp": "Diese SHIP-ID ist mit den unten stehenden Zertifikaten verknüpft.",

View file

@ -264,15 +264,21 @@
"public": "Public certificate", "public": "Public certificate",
"title": "Certificates" "title": "Certificates"
}, },
"configuredDevices": "Configured devices",
"description": "Configuration that enables evcc to communicate with EEBus compatible devices like chargers or a control unit of your grid operator. All relevant initialization and certificate generation is done automatically on first start.", "description": "Configuration that enables evcc to communicate with EEBus compatible devices like chargers or a control unit of your grid operator. All relevant initialization and certificate generation is done automatically on first start.",
"descriptionAdvanced": "No changes required. Only perform changes if you really know what you're doing. If you change either the SHIP-id or the certificates, you'll need to pair your devices again.", "descriptionAdvanced": "No changes required. Only perform changes if you really know what you're doing. If you change either the SHIP-id or the certificates, you'll need to pair your devices again.",
"interfaces": "Interfaces", "interfaces": "Interfaces",
"interfacesHelp": "Limit the network interfaces that EEBus should use to avoid communication problems. Leave the field blank to use all interfaces. One entry per line.", "interfacesHelp": "Limit the network interfaces that EEBus should use to avoid communication problems. Leave the field blank to use all interfaces. One entry per line.",
"noConfiguredDevices": "No devices configured by SKI.",
"noPairings": "No paired devices.",
"pairings": "Paired devices",
"port": "Port", "port": "Port",
"portHelp": "The port to be used.", "portHelp": "The port to be used.",
"qr": "Pairing code", "qr": "Pairing code",
"qrExplain": "Scan from another EEBus device during installation to pair automatically, without entering the SKI manually.", "qrExplain": "Scan from another EEBus device during installation to pair automatically, without entering the SKI manually.",
"removeConfirm": "All EEBus configuration will be removed. New certificates and identifiers will be generated on next start. Are you sure?", "removeConfirm": "All EEBus configuration will be removed. New certificates and identifiers will be generated on next start. Are you sure?",
"removePairing": "Remove pairing",
"removePairingConfirm": "The device will be disconnected and must be paired again. Are you sure?",
"shipid": "SHIP-ID", "shipid": "SHIP-ID",
"shipidExplain": "Permanent device identifier for identification in the EEBus network.", "shipidExplain": "Permanent device identifier for identification in the EEBus network.",
"shipidHelp": "This SHIP-ID is linked to the certificates below.", "shipidHelp": "This SHIP-ID is linked to the certificates below.",

View file

@ -85,6 +85,9 @@ type EEBus struct {
ski string ski string
paired []shipapi.ServiceIdentity // devices paired via SHIP Pairing Service
connected map[string]bool // connection state per ski
clients map[string][]Device clients map[string][]Device
} }
@ -151,15 +154,18 @@ func NewServer(other Config) (*EEBus, error) {
return nil, err return nil, err
} }
pairingConfig, ringBuffer, err := pairing(cc.Secret)
if err != nil {
return nil, err
}
configuration, err := eebusapi.NewConfiguration( configuration, err := eebusapi.NewConfiguration(
BrandName, BrandName, Model, serial, BrandName, BrandName, Model, serial,
[]shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeEnergyManagementSystem}, []shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeEnergyManagementSystem},
model.DeviceTypeTypeEnergyManagementSystem, model.DeviceTypeTypeEnergyManagementSystem,
[]model.EntityTypeType{model.EntityTypeTypeCEM}, []model.EntityTypeType{model.EntityTypeTypeCEM},
cc.Port, certificate, time.Second*4, cc.Port, certificate, time.Second*4,
// no SHIP Pairing (and thus no ring buffer persistence): remote services are pairingConfig, ringBuffer,
// trusted by their configured SKI via RegisterRemoteService
nil, nil,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -179,9 +185,10 @@ func NewServer(other Config) (*EEBus, error) {
} }
c := &EEBus{ c := &EEBus{
log: util.NewLogger("eebus"), log: util.NewLogger("eebus"),
ski: ski, ski: ski,
clients: make(map[string][]Device), clients: make(map[string][]Device),
connected: make(map[string]bool),
} }
c.service = service.NewService(configuration, c) c.service = service.NewService(configuration, c)
@ -270,6 +277,18 @@ func NewServer(other Config) (*EEBus, error) {
c.service.AddUseCase(uc) c.service.AddUseCase(uc)
} }
// re-establish trust for devices paired via the SHIP Pairing Service
if pairingConfig != nil {
identities, err := trustedDevices()
if err != nil {
c.log.ERROR.Printf("loading paired devices: %v", err)
}
c.paired = identities
for _, identity := range c.paired {
c.service.RegisterRemoteService(identity)
}
}
started = sync.OnceValue(c.service.Start) started = sync.OnceValue(c.service.Start)
instance = c instance = c
@ -281,6 +300,111 @@ func (c *EEBus) Ski() string {
return c.ski return c.ski
} }
// PairingSource identifies how trust for a device was established
type PairingSource string
const (
PairingSourcePaired PairingSource = "paired" // trusted via SHIP Pairing Service, removable
PairingSourceSki PairingSource = "ski" // trusted by configured SKI, not removable here
)
// PairingInfo describes a trusted device, regardless of how trust was established
type PairingInfo struct {
shipapi.ServiceIdentity
Source PairingSource `json:"source"`
}
// Pairings returns all trusted devices, tagged by how trust was established
func (c *EEBus) Pairings() []PairingInfo {
c.mux.Lock()
defer c.mux.Unlock()
res := make([]PairingInfo, 0, len(c.paired)+len(c.clients))
for _, identity := range c.paired {
res = append(res, PairingInfo{ServiceIdentity: identity, Source: PairingSourcePaired})
}
for ski := range c.clients {
if ski == "" || c.pairedIndex(shipapi.NewServiceIdentity(ski, "", "")) >= 0 {
continue
}
res = append(res, PairingInfo{
ServiceIdentity: shipapi.NewServiceIdentity(ski, "", ""),
Source: PairingSourceSki,
})
}
slices.SortFunc(res, func(a, b PairingInfo) int {
return strings.Compare(a.SKI+a.ShipID, b.SKI+b.ShipID)
})
return res
}
// RemovePairing removes a single pairing identified by ship id or ski and revokes its trust
func (c *EEBus) RemovePairing(id string) bool {
c.mux.Lock()
idx := slices.IndexFunc(c.paired, func(i shipapi.ServiceIdentity) bool {
return (i.ShipID != "" && i.ShipID == id) || (i.SKI != "" && i.SKI == id)
})
var identity shipapi.ServiceIdentity
if idx >= 0 {
identity = c.paired[idx]
c.paired = slices.Delete(c.paired, idx, idx+1)
c.persistPairings()
}
c.mux.Unlock()
if idx < 0 {
return false
}
// release mutex before cross-layer call, see UnregisterDevice
c.service.UnregisterRemoteService(identity)
return true
}
// persistPairings stores the current pairings (mux must be held)
func (c *EEBus) persistPairings() {
if err := storeTrustedDevices(c.paired); err != nil {
c.log.ERROR.Printf("persisting pairings: %v", err)
}
}
// pairedIndex returns the index of the pairing matching identity, or -1 (mux must be held)
func (c *EEBus) pairedIndex(identity shipapi.ServiceIdentity) int {
return slices.IndexFunc(c.paired, func(i shipapi.ServiceIdentity) bool {
return (identity.Fingerprint != "" && identity.Fingerprint == i.Fingerprint) ||
(identity.ShipID != "" && identity.ShipID == i.ShipID) ||
(identity.SKI != "" && identity.SKI == i.SKI)
})
}
// upsertPairing adds or updates a pairing and persists it (mux must be held)
func (c *EEBus) upsertPairing(identity shipapi.ServiceIdentity) {
if idx := c.pairedIndex(identity); idx >= 0 {
if c.paired[idx] == identity {
return
}
c.paired[idx] = identity
} else {
c.paired = append(c.paired, identity)
}
c.persistPairings()
}
// clientsFor returns the devices registered for ski, including devices registered
// without ski when ski is a SHIP-paired device (mux must be held)
func (c *EEBus) clientsFor(ski string) []Device {
res := c.clients[ski]
if ski != "" && slices.ContainsFunc(c.paired, func(i shipapi.ServiceIdentity) bool { return i.SKI == ski }) {
res = append(slices.Clone(res), c.clients[""]...)
}
return res
}
// RegisterDevice subscribes a device to the remote service with given ski.
// An empty ski subscribes to the device paired via the SHIP Pairing Service.
func (c *EEBus) RegisterDevice(ski, ip string, device Device) error { func (c *EEBus) RegisterDevice(ski, ip string, device Device) error {
ski = shiputil.NormalizeSKI(ski) ski = shiputil.NormalizeSKI(ski)
c.log.TRACE.Printf("registering ski: %s", ski) c.log.TRACE.Printf("registering ski: %s", ski)
@ -289,16 +413,30 @@ func (c *EEBus) RegisterDevice(ski, ip string, device Device) error {
return errors.New("device ski can not be identical to host ski") return errors.New("device ski can not be identical to host ski")
} }
identity := shipapi.NewServiceIdentity(ski, "", "") // trust for the paired device is established by pairing, not by configuration
if len(ip) > 0 { if ski != "" {
identity.IPv4 = ip identity := shipapi.NewServiceIdentity(ski, "", "")
if len(ip) > 0 {
identity.IPv4 = ip
}
c.service.RegisterRemoteService(identity)
} }
c.service.RegisterRemoteService(identity)
c.mux.Lock() c.mux.Lock()
defer c.mux.Unlock() defer c.mux.Unlock()
c.clients[ski] = append(c.clients[ski], device) c.clients[ski] = append(c.clients[ski], device)
// the remote service may already be connected
connected := c.connected[ski]
if ski == "" {
connected = slices.ContainsFunc(c.paired, func(i shipapi.ServiceIdentity) bool {
return i.SKI != "" && c.connected[i.SKI]
})
}
if connected {
device.Connect(true)
}
return nil return nil
} }
@ -318,7 +456,10 @@ func (c *EEBus) UnregisterDevice(ski string, device Device) {
// which calls back into evcc's connect(ski, false) — and that needs to // which calls back into evcc's connect(ski, false) — and that needs to
// acquire c.mux. Holding c.mux across this cross-layer call would // acquire c.mux. Holding c.mux across this cross-layer call would
// deadlock the same goroutine on its own non-reentrant mutex. See #28942. // deadlock the same goroutine on its own non-reentrant mutex. See #28942.
defer c.service.UnregisterRemoteService(shipapi.NewServiceIdentity(ski, "", "")) // The paired device (empty ski) stays trusted until unpaired.
if ski != "" {
defer c.service.UnregisterRemoteService(shipapi.NewServiceIdentity(ski, "", ""))
}
} }
} }
c.mux.Unlock() c.mux.Unlock()
@ -357,35 +498,38 @@ func (c *EEBus) ucCallback(ski string, device spineapi.DeviceRemoteInterface, en
c.log.DEBUG.Printf("ski %s event %s", ski, event) c.log.DEBUG.Printf("ski %s event %s", ski, event)
if clients, ok := c.clients[ski]; ok { for _, client := range c.clientsFor(ski) {
for _, client := range clients { client.UseCaseEvent(device, entity, event)
client.UseCaseEvent(device, entity, event)
}
} }
} }
// EEBUSServiceHandler // EEBUSServiceHandler
func (c *EEBus) connect(ski string, connected bool) { func (c *EEBus) connect(identity shipapi.ServiceIdentity, connected bool) {
action := map[bool]string{true: "connected", false: "disconnected"}[connected] action := map[bool]string{true: "connected", false: "disconnected"}[connected]
c.log.DEBUG.Printf("ski %s %s", ski, action) c.log.DEBUG.Printf("ski %s %s", identity.SKI, action)
c.mux.Lock() c.mux.Lock()
defer c.mux.Unlock() defer c.mux.Unlock()
if clients, ok := c.clients[ski]; ok { // learn the ski of a device paired via the SHIP Pairing Service
for _, client := range clients { if c.pairedIndex(identity) >= 0 {
client.Connect(connected) c.upsertPairing(identity)
} }
c.connected[identity.SKI] = connected
for _, client := range c.clientsFor(identity.SKI) {
client.Connect(connected)
} }
} }
func (c *EEBus) RemoteServiceConnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) { func (c *EEBus) RemoteServiceConnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
c.connect(identity.SKI, true) c.connect(identity, true)
} }
func (c *EEBus) RemoteServiceDisconnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) { func (c *EEBus) RemoteServiceDisconnected(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
c.connect(identity.SKI, false) c.connect(identity, false)
} }
// report all currently visible EEBUS services // report all currently visible EEBUS services
@ -400,7 +544,14 @@ func (c *EEBus) VisibleRemoteMdnsServicesUpdated(service eebusapi.ServiceInterfa
// Provides updated service information (ShipID, fingerprint, ...) discovered // Provides updated service information (ShipID, fingerprint, ...) discovered
// during the handshake process. This needs to be persisted and passed on for // during the handshake process. This needs to be persisted and passed on for
// future remote service connections when using `RegisterRemoteService` // future remote service connections when using `RegisterRemoteService`
func (c *EEBus) ServiceUpdated(identity shipapi.ServiceIdentity) {} func (c *EEBus) ServiceUpdated(identity shipapi.ServiceIdentity) {
c.mux.Lock()
defer c.mux.Unlock()
if c.pairedIndex(identity) >= 0 {
c.upsertPairing(identity)
}
}
// Provides the current pairing state for the remote service // Provides the current pairing state for the remote service
// This is called whenever the state changes and can be used to // This is called whenever the state changes and can be used to
@ -419,18 +570,30 @@ func (c *EEBus) ServicePairingDetailUpdate(identity shipapi.ServiceIdentity, det
} }
} }
// SHIP Pairing Service events: evcc trusts remote services by configured SKI via // SHIP Pairing Service events: the trusted device identity is persisted so it
// RegisterRemoteService and does not use SHIP Pairing; logged for visibility only // reconnects across restarts and is routed to consumers registered without ski
func (c *EEBus) ServiceAutoTrusted(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) { func (c *EEBus) ServiceAutoTrusted(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity) {
c.log.INFO.Printf("service trusted: %s", identity.SKI) c.log.INFO.Printf("service trusted: %s", identity.ShipID)
c.mux.Lock()
defer c.mux.Unlock()
c.upsertPairing(identity)
} }
func (c *EEBus) ServiceAutoTrustFailed(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason error) { func (c *EEBus) ServiceAutoTrustFailed(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason error) {
c.log.INFO.Printf("service trust failed: %s: %v", identity.SKI, reason) c.log.INFO.Printf("service trust failed: %s: %v", identity.ShipID, reason)
} }
func (c *EEBus) ServiceAutoTrustRemoved(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason string) { func (c *EEBus) ServiceAutoTrustRemoved(service eebusapi.ServiceInterface, identity shipapi.ServiceIdentity, reason string) {
c.log.INFO.Printf("service trust removed: %s: %s", identity.SKI, reason) c.log.INFO.Printf("service trust removed: %s: %s", identity.ShipID, reason)
c.mux.Lock()
defer c.mux.Unlock()
if idx := c.pairedIndex(identity); idx >= 0 {
c.paired = slices.Delete(c.paired, idx, idx+1)
c.persistPairings()
}
} }
// EEBUS Logging interface // EEBUS Logging interface

79
server/eebus/pairing.go Normal file
View file

@ -0,0 +1,79 @@
package eebus
import (
"encoding/hex"
"errors"
"sync"
shipapi "github.com/enbility/ship-go/api"
"github.com/evcc-io/evcc/server/db/settings"
)
const (
ringBufferKey = "eebus.pairing.ringbuffer"
trustedDeviceKey = "eebus.pairing.trusted"
)
// trustedDevices returns the persisted identities of devices paired via the
// SHIP Pairing Service
func trustedDevices() ([]shipapi.ServiceIdentity, error) {
var identities []shipapi.ServiceIdentity
err := settings.Json(trustedDeviceKey, &identities)
if errors.Is(err, settings.ErrNotFound) {
err = nil
}
return identities, err
}
// storeTrustedDevices persists the identities of devices paired via the
// SHIP Pairing Service
func storeTrustedDevices(identities []shipapi.ServiceIdentity) error {
return settings.SetJson(trustedDeviceKey, identities)
}
// pairing builds the SHIP Pairing Service config (listener mode) and a ring
// buffer from the hex secret. An empty secret disables SHIP pairing.
func pairing(secret string) (*shipapi.PairingConfig, shipapi.RingBufferPersistence, error) {
if secret == "" {
return nil, nil, nil
}
b, err := hex.DecodeString(secret)
if err != nil {
return nil, nil, err
}
return shipapi.NewPairingConfig(shipapi.PairingModeListener, shipapi.PairingSecret(b)), new(ringBuffer), nil
}
// ringBuffer persists the SHIP pairing replay-protection digests via settings.
type ringBuffer struct {
mu sync.Mutex
}
type ringBufferState struct {
Entries []shipapi.DigestEntry `json:"entries"`
NextIndex int `json:"nextIndex"`
}
func (r *ringBuffer) LoadRingBuffer() ([]shipapi.DigestEntry, int, error) {
r.mu.Lock()
defer r.mu.Unlock()
var s ringBufferState
if err := settings.Json(ringBufferKey, &s); err != nil {
// no data yet is not an error
if errors.Is(err, settings.ErrNotFound) {
return nil, 0, nil
}
return nil, 0, err
}
return s.Entries, s.NextIndex, nil
}
func (r *ringBuffer) SaveRingBuffer(entries []shipapi.DigestEntry, nextIndex int) error {
r.mu.Lock()
defer r.mu.Unlock()
return settings.SetJson(ringBufferKey, ringBufferState{Entries: entries, NextIndex: nextIndex})
}

View file

@ -10,6 +10,8 @@ import (
func init() { func init() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /services", getServices) mux.HandleFunc("GET /services", getServices)
mux.HandleFunc("GET /pairings", getPairings)
mux.HandleFunc("DELETE /pairings/{id}", deletePairing)
service.Register("eebus", mux) service.Register("eebus", mux)
} }
@ -23,3 +25,19 @@ func getServices(w http.ResponseWriter, req *http.Request) {
} }
json.NewEncoder(w).Encode(res) json.NewEncoder(w).Encode(res)
} }
// getPairings returns all trusted devices, tagged by how trust was established
func getPairings(w http.ResponseWriter, req *http.Request) {
res := []PairingInfo{}
if instance != nil {
res = append(res, instance.Pairings()...)
}
json.NewEncoder(w).Encode(res)
}
// deletePairing removes a single pairing identified by ship id or ski
func deletePairing(w http.ResponseWriter, req *http.Request) {
if instance == nil || !instance.RemovePairing(req.PathValue("id")) {
w.WriteHeader(http.StatusNotFound)
}
}

View file

@ -34,6 +34,18 @@ type controlbox struct {
} }
func createControlbox(ctx context.Context, remoteSki string, port int) (*controlbox, error) { func createControlbox(ctx context.Context, remoteSki string, port int) (*controlbox, error) {
h, err := newControlbox(nil, port)
if err != nil {
return nil, err
}
h.myService.RegisterRemoteService(shipapi.NewServiceIdentity(remoteSki, "", ""))
h.start(ctx)
return h, nil
}
func newControlbox(pairing *shipapi.PairingConfig, port int) (*controlbox, error) {
certificate, err := cert.CreateCertificate("Demo", "Demo", "DE", "Demo-Unit-01") certificate, err := cert.CreateCertificate("Demo", "Demo", "DE", "Demo-Unit-01")
if err != nil { if err != nil {
return nil, err return nil, err
@ -48,16 +60,19 @@ func createControlbox(ctx context.Context, remoteSki string, port int) (*control
ski: ski, ski: ski,
} }
// unique per instance: a shared serial collides on ShipID when multiple controlboxes run concurrently
serial := ski[:8]
configuration, err := api.NewConfiguration( configuration, err := api.NewConfiguration(
"Demo", "Demo", "ControlBox", "123456789", "Demo", "Demo", "ControlBox", serial,
[]shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeGridConnectionHub}, []shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeGridConnectionHub},
model.DeviceTypeTypeElectricitySupplySystem, model.DeviceTypeTypeElectricitySupplySystem,
[]model.EntityTypeType{model.EntityTypeTypeGridGuard}, []model.EntityTypeType{model.EntityTypeTypeGridGuard},
port, certificate, time.Second*60, nil, nil) port, certificate, time.Second*60, pairing, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
configuration.SetAlternateIdentifier("Demo-ControlBox-123456789") configuration.SetAlternateIdentifier("Demo-ControlBox-" + serial)
h.myService = service.NewService(configuration, h) h.myService = service.NewService(configuration, h)
// h.myService.SetLogging(h) // h.myService.SetLogging(h)
@ -73,15 +88,16 @@ func createControlbox(ctx context.Context, remoteSki string, port int) (*control
h.uclpp = lpp.NewLPP(localEntity, h.OnLPPEvent) h.uclpp = lpp.NewLPP(localEntity, h.OnLPPEvent)
h.myService.AddUseCase(h.uclpp) h.myService.AddUseCase(h.uclpp)
h.myService.RegisterRemoteService(shipapi.NewServiceIdentity(remoteSki, "", "")) return h, nil
}
func (h *controlbox) start(ctx context.Context) {
h.myService.Start() h.myService.Start()
go func() { go func() {
<-ctx.Done() <-ctx.Done()
h.myService.Shutdown() h.myService.Shutdown()
}() }()
return h, nil
} }
func (h *controlbox) remoteEntity(event api.EventType) []spineapi.EntityRemoteInterface { func (h *controlbox) remoteEntity(event api.EventType) []spineapi.EntityRemoteInterface {

View file

@ -47,11 +47,10 @@ func TestEEBus(t *testing.T) {
eventC := make(chan api.EventType, 16) eventC := make(chan api.EventType, 16)
box.remoteEventC = eventC box.remoteEventC = eventC
hems, err := hems.NewEEBus(t.Context(), box.ski, eebus.Limits{}, nil, nil, time.Second) // no hems.Run(): the run loop applies limits via the nil site and is not needed here
_, err = hems.NewEEBus(t.Context(), box.ski, eebus.Limits{}, nil, nil, time.Second)
require.NoError(t, err, "hems") require.NoError(t, err, "hems")
go hems.Run()
// wait for DataUpdateLimit which signals that limit descriptions and data are available // wait for DataUpdateLimit which signals that limit descriptions and data are available
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
return len(box.remoteEntity(lpc.DataUpdateLimit)) > 0 return len(box.remoteEntity(lpc.DataUpdateLimit)) > 0

View file

@ -0,0 +1,208 @@
package eebus
import (
"context"
"encoding/hex"
"encoding/json"
"net"
"strings"
"testing"
"time"
"github.com/enbility/eebus-go/usecases/eg/lpc"
shipapi "github.com/enbility/ship-go/api"
"github.com/enbility/ship-go/cert"
hems "github.com/evcc-io/evcc/hems/eebus"
"github.com/evcc-io/evcc/server/db/settings"
server "github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
)
// freePort returns a currently unused tcp port
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// qrField extracts a field value from the SHIP QR code text
func qrField(qr, key string) string {
for f := range strings.SplitSeq(qr, ";") {
if v, ok := strings.CutPrefix(f, key+":"); ok {
return v
}
}
return ""
}
// serverQR returns the SHIP QR code text of the running eebus server
func serverQR(t *testing.T) string {
t.Helper()
b, err := json.Marshal(server.GetStatus())
require.NoError(t, err)
var status struct{ QR string }
require.NoError(t, json.Unmarshal(b, &status))
return status.QR
}
// createPairingControlbox creates a controlbox that pairs with the remote service
// via the SHIP Pairing Service (announcer mode) using the scanned QR code text
func createPairingControlbox(ctx context.Context, qr string, port int) (*controlbox, error) {
secret, err := hex.DecodeString(qrField(qr, "SPSEC"))
if err != nil {
return nil, err
}
target := shipapi.PairingTarget{
SKI: qrField(qr, "SKI"),
Fingerprint: qrField(qr, "FPH256"),
ShipID: qrField(qr, "ID"),
Secret: secret,
}
h, err := newControlbox(shipapi.NewPairingConfig(shipapi.PairingModeAnnouncer, nil), port)
if err != nil {
return nil, err
}
// devZ must trust devA before announcing
h.myService.RegisterRemoteService(shipapi.NewServiceIdentity(target.SKI, target.Fingerprint, target.ShipID))
h.start(ctx)
// hub start is asynchronous- retry until announcing
for range 10 {
if err = h.myService.StartAnnouncementTo(target); err == nil {
break
}
time.Sleep(time.Second)
}
return h, err
}
// connectHems creates the hems consumer for ski (empty ski = SHIP-paired device)
// and returns the connect result asynchronously
func connectHems(ctx context.Context, ski string) <-chan error {
errC := make(chan error, 1)
go func() {
_, err := hems.NewEEBus(ctx, ski, hems.Limits{}, nil, nil, time.Second)
errC <- err
}()
return errC
}
// findPairing returns the first pairing matching source
func findPairing(pairings []server.PairingInfo, source server.PairingSource) (server.PairingInfo, bool) {
for _, p := range pairings {
if p.Source == source {
return p, true
}
}
return server.PairingInfo{}, false
}
func TestShipPairing(t *testing.T) {
util.LogLevel("error", map[string]string{"eebus": "trace"})
// tear down a server instance left over from other tests in this package
if inst, err := server.Instance(); err == nil {
inst.Shutdown()
}
certificate, err := cert.CreateCertificate("Demo", "Demo", "DE", "Demo-Pairing-01")
require.NoError(t, err, "certificate")
public, private, err := server.GetX509KeyPair(certificate)
require.NoError(t, err, "decode certificate")
secret, err := server.CreatePairingSecret()
require.NoError(t, err, "secret")
conf := server.Config{
Port: freePort(t),
Secret: secret,
Certificate: server.Certificate{
Public: public,
Private: private,
},
}
_, err = server.NewServer(conf)
require.NoError(t, err, "server")
inst, err := server.Instance()
require.NoError(t, err, "instance")
qr := serverQR(t)
require.Contains(t, qr, "SPSEC:", "expected SHIP Pairing Service QR")
// consumer for the SHIP-paired controlbox must connect once pairing completes
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
hemsC := connectHems(ctx, "")
box, err := createPairingControlbox(t.Context(), qr, freePort(t))
require.NoError(t, err, "controlbox")
require.NoError(t, <-hemsC, "paired device not routed to consumer")
// LPC data flows to the controlbox
require.Eventually(t, func() bool {
return len(box.remoteEntity(lpc.DataUpdateLimit)) > 0
}, 30*time.Second, 100*time.Millisecond, "waiting for lpc.DataUpdateLimit")
// trusted device identity is persisted
var identities []shipapi.ServiceIdentity
require.NoError(t, settings.Json("eebus.pairing.trusted", &identities), "trusted device not persisted")
require.Len(t, identities, 1, "trusted device not persisted")
require.NotEmpty(t, identities[0].SKI, "paired device ski not persisted")
// restart evcc- the paired device must reconnect without re-pairing
inst.Shutdown()
_, err = server.NewServer(conf)
require.NoError(t, err, "server restart")
inst, err = server.Instance()
require.NoError(t, err, "instance restart")
ctx2, cancel2 := context.WithTimeout(t.Context(), time.Minute)
defer cancel2()
require.NoError(t, <-connectHems(ctx2, ""), "paired device not reconnected after restart")
// a device trusted by configured SKI must be listed too, tagged accordingly,
// and must not be removable via RemovePairing
box2, err := createControlbox(t.Context(), inst.Ski(), freePort(t))
require.NoError(t, err, "ski-configured controlbox")
ctx3, cancel3 := context.WithTimeout(t.Context(), time.Minute)
defer cancel3()
require.NoError(t, <-connectHems(ctx3, box2.ski), "ski-configured device not connected")
pairings := inst.Pairings()
require.Len(t, pairings, 2)
pairedEntry, ok := findPairing(pairings, server.PairingSourcePaired)
require.True(t, ok, "paired entry missing")
skiEntry, ok := findPairing(pairings, server.PairingSourceSki)
require.True(t, ok, "ski entry missing")
require.Equal(t, box2.ski, skiEntry.SKI)
require.False(t, inst.RemovePairing(skiEntry.SKI), "ski-configured pairing must not be removable")
require.Len(t, inst.Pairings(), 2)
// remove the individual pairing- trust is revoked and persisted state cleared
require.True(t, inst.RemovePairing(pairedEntry.ShipID), "pairing not removed")
require.Len(t, inst.Pairings(), 1)
require.NoError(t, settings.Json("eebus.pairing.trusted", &identities))
require.Empty(t, identities, "removed pairing still persisted")
}

View file

@ -1,6 +1,8 @@
package eebus package eebus
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
@ -26,6 +28,7 @@ type Config struct {
ShipID string `json:"shipid"` ShipID string `json:"shipid"`
Interfaces []string `json:"interfaces,omitempty"` Interfaces []string `json:"interfaces,omitempty"`
Certificate Certificate `json:"certificate"` Certificate Certificate `json:"certificate"`
Secret string `json:"secret,omitempty"` // hex SHIP pairing secret
} }
// IsConfigured returns true if the EEbus server is configured // IsConfigured returns true if the EEbus server is configured
@ -52,6 +55,15 @@ func createShipID() string {
return fmt.Sprintf("%s-%0x", "EVCC", protectedID[:8]) return fmt.Sprintf("%s-%0x", "EVCC", protectedID[:8])
} }
// CreatePairingSecret returns a 16-byte SHIP pairing secret as hex string.
func CreatePairingSecret() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func DefaultConfig(conf *Config) (*Config, error) { func DefaultConfig(conf *Config) (*Config, error) {
cert, err := CreateCertificate() cert, err := CreateCertificate()
if err != nil { if err != nil {
@ -69,9 +81,15 @@ func DefaultConfig(conf *Config) (*Config, error) {
port = 4712 port = 4712
} }
secret, err := CreatePairingSecret()
if err != nil {
return nil, err
}
res := Config{ res := Config{
Port: port, Port: port,
ShipID: createShipID(), ShipID: createShipID(),
Secret: secret,
Certificate: Certificate{ Certificate: Certificate{
Public: public, Public: public,
Private: private, Private: private,

View file

@ -20,6 +20,13 @@ test.describe("eebus", async () => {
await expect(modal.getByLabel("SHIP-ID")).not.toBeEmpty(); await expect(modal.getByLabel("SHIP-ID")).not.toBeEmpty();
await expect(modal.getByLabel("SKI")).not.toBeEmpty(); await expect(modal.getByLabel("SKI")).not.toBeEmpty();
// pairing QR code is displayed
await expect(modal.getByAltText("Pairing code")).toBeVisible();
// no devices paired via SHIP Pairing Service
await expect(modal.getByRole("heading", { name: "Paired devices" })).toBeVisible();
await expect(modal.getByText("No paired devices.")).toBeVisible();
await page.getByRole("button", { name: "Show advanced settings" }).click(); await page.getByRole("button", { name: "Show advanced settings" }).click();
await expect(modal.getByLabel("Port")).toHaveValue(String(eebusPort())); await expect(modal.getByLabel("Port")).toHaveValue(String(eebusPort()));
await expect(modal.getByLabel("Interfaces")).toBeVisible(); await expect(modal.getByLabel("Interfaces")).toBeVisible();