diff --git a/assets/js/components/Config/ShmModal.vue b/assets/js/components/Config/ShmModal.vue index 731577262..3209d42c3 100644 --- a/assets/js/components/Config/ShmModal.vue +++ b/assets/js/components/Config/ShmModal.vue @@ -51,6 +51,22 @@ minlength="12" maxlength="12" pattern="[A-Fa-f0-9]{12}" + /> + + + diff --git a/hems/shm/shm.go b/hems/shm/shm.go index de878accb..c28d588a1 100644 --- a/hems/shm/shm.go +++ b/hems/shm/shm.go @@ -44,6 +44,7 @@ type Config struct { AllowControl_ bool `json:"allowControl,omitempty"` // deprecated VendorId string `json:"vendorId"` DeviceId string `json:"deviceId"` + DeviceSerial string `json:"deviceSerial"` } // NewFromConfig creates a new SEMP instance from configuration and starts it @@ -60,6 +61,24 @@ func NewFromConfig(cfg Config, hostUri string, site site.API, addr string, route return err } + // Only if DeviceSerial is explicitly configured: validate it and patch the + // UUID node (last 6 bytes) to ensure the UDN and DeviceSerial are stable across restarts. + // Ideally we'd have used the same machine-id approach as UniqueDeviceID does, but that + // would break existing installations that relied on the node ID of the UUID which was set to the MAC address + // of the host. See https://github.com/evcc-io/evcc/issues/28126 for context. + if cfg.DeviceSerial != "" { + b, err := hex.DecodeString(cfg.DeviceSerial) + if err != nil { + return fmt.Errorf("device serial: %w", err) + } + if len(b) != 6 { + return fmt.Errorf("invalid device serial: %v. Must be 12 characters HEX string", cfg.DeviceSerial) + } + + // replaces the node (last 6 bytes) of a UUID with the given bytes. + copy(uid[10:], b) + } + var did []byte if cfg.DeviceId == "" { if did, err = UniqueDeviceID(); err != nil { diff --git a/i18n/de.json b/i18n/de.json index 7f4fbf073..dea30cad9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -640,11 +640,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc ist mit einer Integration für den SMA Sunny Home Manager (SHM) mittels SEMP-Protokoll ausgestattet. Wenn dieser im selben Netzwerk läuft, sollte dir nach der Anmeldung in deinem Sunny Portal-Konto automatisch angeboten werden, alle in evcc konfigurierten Ladepunkte als neu erkannte Verbraucher hinzuzufügen. Alles sollte sofort einsatzbereit sein, ohne dass hier weitere Anpassungen erforderlich sind.", "descriptionDeviceId": "12-stelliger HEX-String. Präfix für alle Geräte (Ladepunkt, ..).", + "descriptionDeviceSerial": "12-stelliger HEX-String. Basisseriennummer für alle Geräte (Ladepunkt, ..). Standardmäßig leitet evcc diese aus der MAC-Addresse des Hosts ab.", "descriptionIdPattern": "Kennungsmuster", "descriptionIds": "Im Sunny Portal benötigt jeder Verbraucher (Ladepunkt, ..) eine eindeutige Kennung. evcc generiert basierend auf deiner Hardware eine eindeutige Kennung. Wenn du evcc auf andere Hardware migrierst, kann sich diese Kennung ändern. Um die Historie zu erhalten, kannst du die generierten Kennungen hier überschreiben. Öffne die SEMP-URL (/semp), um deine aktuellen Kennungen zu überprüfen.", "descriptionSempUrl": "SEMP-URL", "descriptionVendorId": "8-stelliger HEX-String. Allgemeines Präfix aller Entitäten. Standardmäßig verwendet evcc seine eigene interne Hersteller-ID.", "labelDeviceId": "Geräte-ID", + "labelDeviceSerial": "Geräteseriennummer", "labelVendorId": "Hersteller-ID", "title": "SMA Sunny Home Manager" }, diff --git a/i18n/en.json b/i18n/en.json index fc596ebf7..f0be3a337 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -640,11 +640,13 @@ "cardTitle": "Sunny Home Manager", "description": "evcc is equipped with integration for the SMA Sunny Home Manager (SHM) via SEMP protocol. If it is running on the same network, after logging into your Sunny Portal account, you should automatically be offered to add all chargers configured in evcc as newly discovered consumers. Everything should be ready to use immediately, without any adjustments required below.", "descriptionDeviceId": "12 characters HEX string. Prefix for all devices (charging point, ..).", + "descriptionDeviceSerial": "12 characters HEX string. Base serial for all devices (charging point, ..). By default evcc derives this from the MAC address of the host.", "descriptionIdPattern": "Identifier pattern", "descriptionIds": "In Sunny Portal every consumer device needs a unique identifier. evcc generates a unique identifier based on your hardware. If you migrate evcc to another hardware these identifiers might change. If you want to maintain history you can override the generated identifiers here. Open the SEMP URL (/semp) to check your current identifiers.", "descriptionSempUrl": "SEMP URL", "descriptionVendorId": "8 characters HEX string. General prefix of all entities. By default evcc will use its own internal vendor ID.", "labelDeviceId": "Device ID", + "labelDeviceSerial": "Device Serial", "labelVendorId": "Vendor ID", "title": "SMA Sunny Home Manager" }, diff --git a/tests/config-shm.spec.ts b/tests/config-shm.spec.ts index 2ff5b3de5..a1fc86ee2 100644 --- a/tests/config-shm.spec.ts +++ b/tests/config-shm.spec.ts @@ -11,8 +11,10 @@ test.afterEach(async () => { const CONFIG = "basics.evcc.yaml"; const VALID_VENDOR_ID = "ABCD1234"; const VALID_DEVICE_ID = "1234567890AB"; +const VALID_DEVICE_SERIAL = "AABBCCDDEEFF"; const INVALID_VENDOR_ID = "INVALID"; const INVALID_DEVICE_ID = "NOTVALID"; +const INVALID_DEVICE_SERIAL = "NOTVALID"; test.describe("SHM", () => { test("configure SHM with validation and persistence", async ({ page }) => { @@ -43,6 +45,13 @@ test.describe("SHM", () => { await device.fill(VALID_DEVICE_ID); expect(await device.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true); + // test device serial validation + const serial = modal.getByLabel("Device Serial"); + await serial.fill(INVALID_DEVICE_SERIAL); + expect(await serial.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(false); + await serial.fill(VALID_DEVICE_SERIAL); + expect(await serial.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true); + await modal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(modal); @@ -54,8 +63,9 @@ test.describe("SHM", () => { await expectModalVisible(modal); await expect(vendor).toHaveValue(VALID_VENDOR_ID); await expect(device).toHaveValue(VALID_DEVICE_ID); + await expect(serial).toHaveValue(VALID_DEVICE_SERIAL); - // verify SEMP endpoint contains configured IDs + // verify SEMP endpoint contains configured IDs and serial const [sempPage] = await Promise.all([ page.context().waitForEvent("page"), modal.getByTestId("semp-url").click(), @@ -64,6 +74,7 @@ test.describe("SHM", () => { expect(xml).toContain( `F-${VALID_VENDOR_ID}-${VALID_DEVICE_ID.toLowerCase()}-00` ); + expect(xml).toContain(`${VALID_DEVICE_SERIAL.toLowerCase()}-0`); await sempPage.close(); }); });