EEBus: enable SHIP Pairing Service (#30842)
This commit is contained in:
parent
f01ecb6909
commit
3a560d1254
13 changed files with 663 additions and 39 deletions
|
|
@ -9,6 +9,7 @@
|
|||
:no-buttons="fromYaml"
|
||||
:confirm-remove="$t('config.eebus.removeConfirm')"
|
||||
@changed="$emit('changed')"
|
||||
@open="loadPairings"
|
||||
>
|
||||
<template #default="{ values }: { values: EebusConfig }">
|
||||
<p v-if="fromYaml" class="text-muted">
|
||||
|
|
@ -48,6 +49,63 @@
|
|||
>
|
||||
<img :src="qrDataUrl" :alt="$t('config.eebus.qr')" class="qr-code" />
|
||||
</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">
|
||||
<template #advanced>
|
||||
<div class="alert alert-danger">
|
||||
|
|
@ -127,8 +185,10 @@
|
|||
<script lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import "@h2d2/shopicons/es/regular/trash";
|
||||
// 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 FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
|
|
@ -155,12 +215,19 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
qrDataUrl: null as string | null,
|
||||
pairings: [] as EebusPairing[],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
fromYaml(): boolean {
|
||||
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: {
|
||||
"status.qr": {
|
||||
|
|
@ -182,6 +249,25 @@ export default {
|
|||
formId(s: string) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -593,6 +593,12 @@ export type EebusStatus = {
|
|||
qr?: string;
|
||||
};
|
||||
|
||||
export type EebusPairing = {
|
||||
ski: string;
|
||||
shipID: string;
|
||||
source: "paired" | "ski";
|
||||
};
|
||||
|
||||
export type ModbusProxy = {
|
||||
port: number;
|
||||
readonly: MODBUS_PROXY_READONLY;
|
||||
|
|
|
|||
12
cmd/setup.go
12
cmd/setup.go
|
|
@ -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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed configuring eebus: %w", err)
|
||||
|
|
|
|||
|
|
@ -264,15 +264,21 @@
|
|||
"public": "Öffentliches Zertifikat",
|
||||
"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.",
|
||||
"descriptionAdvanced": "Keine Änderungen erforderlich. Bei Änderung ist Neukoppelung aller Geräte erforderlich.",
|
||||
"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.",
|
||||
"noConfiguredDevices": "Keine per SKI konfigurierten Geräte.",
|
||||
"noPairings": "Keine gekoppelten Geräte.",
|
||||
"pairings": "Gekoppelte Geräte",
|
||||
"port": "Port",
|
||||
"portHelp": "Der zu verwendende Port.",
|
||||
"qr": "Kopplungscode",
|
||||
"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?",
|
||||
"removePairing": "Kopplung entfernen",
|
||||
"removePairingConfirm": "Die Verbindung zum Gerät wird getrennt und es muss neu gekoppelt werden. Bist du sicher?",
|
||||
"shipid": "SHIP-ID",
|
||||
"shipidExplain": "Permanente Gerätekennung zur Identifikation im EEBus-Netzwerk.",
|
||||
"shipidHelp": "Diese SHIP-ID ist mit den unten stehenden Zertifikaten verknüpft.",
|
||||
|
|
|
|||
|
|
@ -264,15 +264,21 @@
|
|||
"public": "Public certificate",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
"portHelp": "The port to be used.",
|
||||
"qr": "Pairing code",
|
||||
"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?",
|
||||
"removePairing": "Remove pairing",
|
||||
"removePairingConfirm": "The device will be disconnected and must be paired again. Are you sure?",
|
||||
"shipid": "SHIP-ID",
|
||||
"shipidExplain": "Permanent device identifier for identification in the EEBus network.",
|
||||
"shipidHelp": "This SHIP-ID is linked to the certificates below.",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ type EEBus struct {
|
|||
|
||||
ski string
|
||||
|
||||
paired []shipapi.ServiceIdentity // devices paired via SHIP Pairing Service
|
||||
connected map[string]bool // connection state per ski
|
||||
|
||||
clients map[string][]Device
|
||||
}
|
||||
|
||||
|
|
@ -151,15 +154,18 @@ func NewServer(other Config) (*EEBus, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
pairingConfig, ringBuffer, err := pairing(cc.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configuration, err := eebusapi.NewConfiguration(
|
||||
BrandName, BrandName, Model, serial,
|
||||
[]shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeEnergyManagementSystem},
|
||||
model.DeviceTypeTypeEnergyManagementSystem,
|
||||
[]model.EntityTypeType{model.EntityTypeTypeCEM},
|
||||
cc.Port, certificate, time.Second*4,
|
||||
// no SHIP Pairing (and thus no ring buffer persistence): remote services are
|
||||
// trusted by their configured SKI via RegisterRemoteService
|
||||
nil, nil,
|
||||
pairingConfig, ringBuffer,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -182,6 +188,7 @@ func NewServer(other Config) (*EEBus, error) {
|
|||
log: util.NewLogger("eebus"),
|
||||
ski: ski,
|
||||
clients: make(map[string][]Device),
|
||||
connected: make(map[string]bool),
|
||||
}
|
||||
|
||||
c.service = service.NewService(configuration, c)
|
||||
|
|
@ -270,6 +277,18 @@ func NewServer(other Config) (*EEBus, error) {
|
|||
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)
|
||||
instance = c
|
||||
|
||||
|
|
@ -281,6 +300,111 @@ func (c *EEBus) Ski() string {
|
|||
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 {
|
||||
ski = shiputil.NormalizeSKI(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")
|
||||
}
|
||||
|
||||
// trust for the paired device is established by pairing, not by configuration
|
||||
if ski != "" {
|
||||
identity := shipapi.NewServiceIdentity(ski, "", "")
|
||||
if len(ip) > 0 {
|
||||
identity.IPv4 = ip
|
||||
}
|
||||
c.service.RegisterRemoteService(identity)
|
||||
}
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -318,9 +456,12 @@ func (c *EEBus) UnregisterDevice(ski string, device Device) {
|
|||
// 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
|
||||
// deadlock the same goroutine on its own non-reentrant mutex. See #28942.
|
||||
// The paired device (empty ski) stays trusted until unpaired.
|
||||
if ski != "" {
|
||||
defer c.service.UnregisterRemoteService(shipapi.NewServiceIdentity(ski, "", ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
if clients, ok := c.clients[ski]; ok {
|
||||
for _, client := range clients {
|
||||
for _, client := range c.clientsFor(ski) {
|
||||
client.UseCaseEvent(device, entity, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
c.log.DEBUG.Printf("ski %s %s", ski, action)
|
||||
c.log.DEBUG.Printf("ski %s %s", identity.SKI, action)
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
if clients, ok := c.clients[ski]; ok {
|
||||
for _, client := range clients {
|
||||
client.Connect(connected)
|
||||
// learn the ski of a device paired via the SHIP Pairing Service
|
||||
if c.pairedIndex(identity) >= 0 {
|
||||
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) {
|
||||
c.connect(identity.SKI, true)
|
||||
c.connect(identity, true)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -400,7 +544,14 @@ func (c *EEBus) VisibleRemoteMdnsServicesUpdated(service eebusapi.ServiceInterfa
|
|||
// Provides updated service information (ShipID, fingerprint, ...) discovered
|
||||
// during the handshake process. This needs to be persisted and passed on for
|
||||
// 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
|
||||
// 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
|
||||
// RegisterRemoteService and does not use SHIP Pairing; logged for visibility only
|
||||
// SHIP Pairing Service events: the trusted device identity is persisted so it
|
||||
// reconnects across restarts and is routed to consumers registered without ski
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
|
|
|
|||
79
server/eebus/pairing.go
Normal file
79
server/eebus/pairing.go
Normal 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})
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import (
|
|||
func init() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /services", getServices)
|
||||
mux.HandleFunc("GET /pairings", getPairings)
|
||||
mux.HandleFunc("DELETE /pairings/{id}", deletePairing)
|
||||
|
||||
service.Register("eebus", mux)
|
||||
}
|
||||
|
|
@ -23,3 +25,19 @@ func getServices(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ type controlbox struct {
|
|||
}
|
||||
|
||||
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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -48,16 +60,19 @@ func createControlbox(ctx context.Context, remoteSki string, port int) (*control
|
|||
ski: ski,
|
||||
}
|
||||
|
||||
// unique per instance: a shared serial collides on ShipID when multiple controlboxes run concurrently
|
||||
serial := ski[:8]
|
||||
|
||||
configuration, err := api.NewConfiguration(
|
||||
"Demo", "Demo", "ControlBox", "123456789",
|
||||
"Demo", "Demo", "ControlBox", serial,
|
||||
[]shipapi.DeviceCategoryType{shipapi.DeviceCategoryTypeGridConnectionHub},
|
||||
model.DeviceTypeTypeElectricitySupplySystem,
|
||||
[]model.EntityTypeType{model.EntityTypeTypeGridGuard},
|
||||
port, certificate, time.Second*60, nil, nil)
|
||||
port, certificate, time.Second*60, pairing, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configuration.SetAlternateIdentifier("Demo-ControlBox-123456789")
|
||||
configuration.SetAlternateIdentifier("Demo-ControlBox-" + serial)
|
||||
|
||||
h.myService = service.NewService(configuration, 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.myService.AddUseCase(h.uclpp)
|
||||
|
||||
h.myService.RegisterRemoteService(shipapi.NewServiceIdentity(remoteSki, "", ""))
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *controlbox) start(ctx context.Context) {
|
||||
h.myService.Start()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
h.myService.Shutdown()
|
||||
}()
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *controlbox) remoteEntity(event api.EventType) []spineapi.EntityRemoteInterface {
|
||||
|
|
|
|||
|
|
@ -47,11 +47,10 @@ func TestEEBus(t *testing.T) {
|
|||
eventC := make(chan api.EventType, 16)
|
||||
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")
|
||||
|
||||
go hems.Run()
|
||||
|
||||
// wait for DataUpdateLimit which signals that limit descriptions and data are available
|
||||
require.Eventually(t, func() bool {
|
||||
return len(box.remoteEntity(lpc.DataUpdateLimit)) > 0
|
||||
|
|
|
|||
208
server/eebus/test/pairing_test.go
Normal file
208
server/eebus/test/pairing_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package eebus
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
|
|
@ -26,6 +28,7 @@ type Config struct {
|
|||
ShipID string `json:"shipid"`
|
||||
Interfaces []string `json:"interfaces,omitempty"`
|
||||
Certificate Certificate `json:"certificate"`
|
||||
Secret string `json:"secret,omitempty"` // hex SHIP pairing secret
|
||||
}
|
||||
|
||||
// IsConfigured returns true if the EEbus server is configured
|
||||
|
|
@ -52,6 +55,15 @@ func createShipID() string {
|
|||
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) {
|
||||
cert, err := CreateCertificate()
|
||||
if err != nil {
|
||||
|
|
@ -69,9 +81,15 @@ func DefaultConfig(conf *Config) (*Config, error) {
|
|||
port = 4712
|
||||
}
|
||||
|
||||
secret, err := CreatePairingSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := Config{
|
||||
Port: port,
|
||||
ShipID: createShipID(),
|
||||
Secret: secret,
|
||||
Certificate: Certificate{
|
||||
Public: public,
|
||||
Private: private,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ test.describe("eebus", async () => {
|
|||
await expect(modal.getByLabel("SHIP-ID")).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 expect(modal.getByLabel("Port")).toHaveValue(String(eebusPort()));
|
||||
await expect(modal.getByLabel("Interfaces")).toBeVisible();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue