diff --git a/assets/js/components/Config/DeviceModal/Actions.vue b/assets/js/components/Config/DeviceModal/Actions.vue
index 838beef1e..faddd5183 100644
--- a/assets/js/components/Config/DeviceModal/Actions.vue
+++ b/assets/js/components/Config/DeviceModal/Actions.vue
@@ -8,6 +8,8 @@
@test="$emit('test')"
/>
+
+
+ >
+
+
+
+
diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue
index d42746572..1858d2b2f 100644
--- a/assets/js/components/Config/MeterModal.vue
+++ b/assets/js/components/Config/MeterModal.vue
@@ -85,6 +85,18 @@
/>
+
+
+
+
@@ -105,7 +117,7 @@ import {
} from "./DeviceModal";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import defaultMeterYaml from "./defaultYaml/meter.yaml?raw";
-import { getModal, replaceModal } from "@/configModal";
+import { getModal, replaceModal, closeModal } from "@/configModal";
const initialValues = {
type: ConfigType.Template,
@@ -192,6 +204,9 @@ export default defineComponent({
isNew(): boolean {
return this.id === undefined;
},
+ canConvertToConsumer(): boolean {
+ return !this.isNew && this.selectedType === "ext" && this.extMeterUsage === "charge";
+ },
extMeterUsageOptions() {
return ["charge", "aux", "grid", "pv", "battery"].map((key) => ({
name: this.$t(`config.meter.usage.${key}`),
@@ -270,6 +285,11 @@ export default defineComponent({
const result = { action, name, type };
this.$emit("changed", result);
},
+ async convertToConsumer() {
+ if (!window.confirm(this.$t("config.meter.convertToConsumer.confirm"))) return;
+ this.$emit("changed", { action: "converted", type: "ext", id: this.id });
+ await closeModal();
+ },
handleClose() {
this.extMeterUsage = "charge";
this.$emit("close");
diff --git a/assets/js/configModal.ts b/assets/js/configModal.ts
index 68f1256a6..098aed074 100644
--- a/assets/js/configModal.ts
+++ b/assets/js/configModal.ts
@@ -11,7 +11,7 @@ export interface ModalEntry {
}
export interface ModalResult {
- action: "added" | "updated" | "removed" | "cancelled";
+ action: "added" | "updated" | "removed" | "converted" | "cancelled";
name?: string;
id?: number;
type?: string;
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 9a75c563a..31e3f1467 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -1097,6 +1097,17 @@ export default defineComponent({
}
}
+ // Converted: move ext meter to consumers (history is reconciled on restart)
+ if (result.action === "converted") {
+ const name = this.meters.find((m) => m.id === result.id)?.name;
+ if (name) {
+ const ext = (this.site.ext || []).filter((n) => n !== name);
+ const consumers = [...(this.site.consumers || []), name];
+ await api.put("/config/site", { ext, consumers });
+ await this.loadSite();
+ }
+ }
+
// Removed: reload site config
if (result.action === "removed") {
await this.loadSite();
diff --git a/core/metrics/collector.go b/core/metrics/collector.go
index 970612a95..69e2a15b7 100644
--- a/core/metrics/collector.go
+++ b/core/metrics/collector.go
@@ -41,6 +41,14 @@ func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collec
// createEntity ensures the entity row exists and refreshes its title.
func createEntity(group, name, title string) (entity, error) {
+ // keep history when a meter is regrouped "meter" -> "consumer" (aux, ext convert)
+ if group == Consumer {
+ var prev entity
+ if db.Instance.Where(`"group" = ? AND name = ?`, Meter, name).Limit(1).Find(&prev).RowsAffected > 0 {
+ db.Instance.Model(&prev).UpdateColumn("group", Consumer)
+ }
+ }
+
e := entity{Group: group, Name: name}
if err := db.Instance.Where(&e).Attrs(entity{Title: title}).FirstOrCreate(&e).Error; err != nil {
diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go
index c4a4588d5..c85927d9d 100644
--- a/core/metrics/collector_test.go
+++ b/core/metrics/collector_test.go
@@ -309,3 +309,56 @@ func TestCreateEntityRefreshesTitle(t *testing.T) {
require.NoError(t, db.Instance.Model(new(entity)).Where("\"group\" = ? AND name = ?", "grid", "grid").Count(&count).Error)
require.EqualValues(t, 1, count, "must not duplicate existing rows")
}
+
+// a meter regrouped to consumer keeps its id and history via in-place relabel
+func TestCreateEntityReconcilesExtToConsumer(t *testing.T) {
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ // ext meter with a persisted history slot
+ ext, err := createEntity(Meter, "db:5", "Fridge")
+ require.NoError(t, err)
+ require.NoError(t, persist(ext, time.Unix(15*60, 0), 0.3, 0))
+
+ // reconfigured as consumer: same row relabeled, history intact
+ con, err := createEntity(Consumer, "db:5", "Fridge")
+ require.NoError(t, err)
+ require.Equal(t, ext.Id, con.Id, "must reuse the existing row")
+ require.Equal(t, Consumer, con.Group)
+
+ var stored entity
+ require.NoError(t, db.Instance.First(&stored, ext.Id).Error)
+ require.Equal(t, Consumer, stored.Group, "group must be persisted")
+
+ // no duplicate entity for the name
+ var entities int64
+ require.NoError(t, db.Instance.Model(new(entity)).Where("name = ?", "db:5").Count(&entities).Error)
+ require.EqualValues(t, 1, entities)
+
+ // history row still attached to the (now consumer) entity
+ var meters int64
+ require.NoError(t, db.Instance.Model(new(meter)).Where("meter = ?", ext.Id).Count(&meters).Error)
+ require.EqualValues(t, 1, meters)
+}
+
+// an existing consumer row blocks the relabel, leaving the meter row untouched
+func TestCreateEntityReconcileGuard(t *testing.T) {
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+ require.NoError(t, SetupSchema())
+
+ meterRow, err := createEntity(Meter, "db:7", "")
+ require.NoError(t, err)
+
+ // pre-existing consumer row blocks the in-place relabel
+ conRow := entity{Group: Consumer, Name: "db:7"}
+ require.NoError(t, db.Instance.Create(&conRow).Error)
+
+ got, err := createEntity(Consumer, "db:7", "")
+ require.NoError(t, err)
+ require.Equal(t, conRow.Id, got.Id, "must reuse existing consumer row")
+
+ // meter row untouched
+ var stored entity
+ require.NoError(t, db.Instance.First(&stored, meterRow.Id).Error)
+ require.Equal(t, Meter, stored.Group)
+}
diff --git a/i18n/de.json b/i18n/de.json
index a0d57dcf1..7d6843cf7 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -535,6 +535,10 @@
},
"meter": {
"cancel": "Abbrechen",
+ "convertToConsumer": {
+ "confirm": "Wandelt diesen \"zusätzlichen Zähler\" in einen \"Verbraucher\" um.",
+ "link": "In Verbraucher umwandeln"
+ },
"delete": "Löschen",
"generic": "Generische Integrationen",
"option": {
diff --git a/i18n/en.json b/i18n/en.json
index 74bf648e2..f304c5f99 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -534,6 +534,10 @@
},
"meter": {
"cancel": "Cancel",
+ "convertToConsumer": {
+ "confirm": "Converts this \"additional meter\" to a \"consumer\".",
+ "link": "Convert to consumer"
+ },
"delete": "Delete",
"generic": "Generic integrations",
"option": {
diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts
index 4be5b7e94..39410191e 100644
--- a/tests/config-ext-meter.spec.ts
+++ b/tests/config-ext-meter.spec.ts
@@ -86,6 +86,56 @@ test.describe("ext meter", async () => {
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
+ test("convert charge ext meter to consumer", async ({ page }) => {
+ await page.goto("/#/config");
+ await expect(page.getByTestId("ext")).toHaveCount(0);
+ await expect(page.getByTestId("consumer")).toHaveCount(0);
+
+ // additional meter defaults to usage charge
+ await createAdditionalMeter(page, "Fridge", "150");
+ await expect(page.getByTestId("ext")).toHaveCount(1);
+
+ const meterModal = page.getByTestId("meter-modal");
+ await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+
+ page.once("dialog", (dialog) => dialog.accept());
+ await meterModal.getByRole("button", { name: "Convert to consumer" }).click();
+ await expectModalHidden(meterModal);
+
+ // moved from additional meters into consumers
+ await expect(page.getByTestId("ext")).toHaveCount(0);
+ await expect(page.getByTestId("consumer")).toHaveCount(1);
+ await expect(page.getByTestId("consumer")).toContainText("Fridge");
+
+ // persists across restart (history reconciled on boot)
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+ await expect(page.getByTestId("ext")).toHaveCount(0);
+ await expect(page.getByTestId("consumer")).toHaveCount(1);
+ await expect(page.getByTestId("consumer")).toContainText("Fridge");
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+ });
+
+ test("convert option hidden for non-charge ext meter", async ({ page }) => {
+ await page.goto("/#/config");
+
+ await page.getByRole("button", { name: "Add additional meter" }).click();
+ const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
+
+ await meterModal.getByLabel("Usage").selectOption("battery");
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
+ await meterModal.getByLabel("Title").fill("House battery");
+ await meterModal.getByLabel("Charge").fill("75");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(meterModal);
+
+ await page.getByTestId("ext").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+ await expect(meterModal.getByRole("button", { name: "Convert to consumer" })).toHaveCount(0);
+ });
+
test("switch from template to custom ext meter", async ({ page }) => {
await page.goto("/#/config");