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"
|
: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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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
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() {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue