Config UI: manually convert additional meter to consumer (#31130)

This commit is contained in:
Michael Geers 2026-06-23 10:09:15 +02:00 • committed by GitHub
parent 31e93f1dfc
commit 4c58f6b69f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 159 additions and 3 deletions

View file

@ -8,6 +8,8 @@
@test="$emit('test')"
/>
<slot name="after-test"></slot>
<div class="mt-4 d-flex justify-content-between">
<button
v-if="isDeletable"

View file

@ -178,7 +178,11 @@
@save="handleSave"
@remove="handleRemove"
@test="testManually"
/>
>
<template #after-test>
<slot name="after-test" :values="values"></slot>
</template>
</DeviceModalActions>
</template>
</form>
</GenericModal>

View file

@ -85,6 +85,18 @@
/>
</FormRow>
</template>
<template #after-test>
<button
v-if="canConvertToConsumer"
type="button"
class="btn btn-link text-muted"
tabindex="0"
@click="convertToConsumer"
>
{{ $t("config.meter.convertToConsumer.link") }}
</button>
</template>
</DeviceModalBase>
</template>
@ -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");

View file

@ -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;

View file

@ -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();

View file

@ -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 {

View file

@ -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)
}

View file

@ -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": {

View file

@ -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": {

View file

@ -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");