Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
| File | When to load |
|------|-------------|
| [Core Domain](docs/agents/core-domain.md) | Control loop, loadpoint logic, PV surplus, charge modes, tariffs, interfaces |
| [Battery Management](docs/agents/battery-management.md) | Multi-battery solar control (tiering, sticky selection, swaps, fast loop, modes, calibration) |
| [Hardware Integrations](docs/agents/hardware-integrations.md) | Charger/meter/vehicle implementations, adding new devices |
| [Easee Architecture](docs/agents/easee-architecture.md) | Easee charger (REST+SignalR, async correlation, concurrency) |
| [OCPP Forwarder](docs/agents/ocpp-forwarder.md) | OCPP proxy/forwarder (sidecar relay to upstream OCPP server, read-only mode) |
Expand Down
6 changes: 6 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ type BatteryController interface {
SetBatteryMode(BatteryMode) error
}

// BatteryPowerController allows active control of battery charge/discharge power in watts.
type BatteryPowerController interface {
SetBatteryChargePower(watts float64) error
SetBatteryDischargePower(watts float64) error
}

// Charger provides current charging status and enable/disable charging
type Charger interface {
ChargeState
Expand Down
20 changes: 20 additions & 0 deletions api/implement/implementations.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 96 additions & 4 deletions assets/js/components/Battery/BatteryExperimental.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,73 @@
:battery="state.battery"
/>

<Card v-if="solarControlPossible" class="mb-4" :title="$t('batterySettings.batteryControlTab')">
<div class="form-check form-switch mb-3">
<input
id="batteryExpSolarControl"
:checked="state.batterySolarControl"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeSolarControl"
/>
<label class="form-check-label" for="batteryExpSolarControl">
{{ $t("batterySettings.batteryControl") }}
</label>
</div>
<template v-if="state.batterySolarControl">
<div class="mb-1 small text-muted fw-semibold text-uppercase">
{{ $t("batterySettings.batteryControlModeTab") }}
</div>
<div class="d-flex gap-4 mb-3">
<div class="form-check">
<input
id="batteryExpSolarModePerBattery"
:checked="!state.batterySolarPool"
class="form-check-input"
type="radio"
name="batteryExpSolarMode"
@change="changePool(false)"
/>
<label class="form-check-label" for="batteryExpSolarModePerBattery">
{{ $t("batterySettings.batteryControlModePerBattery") }}
</label>
</div>
<div class="form-check">
<input
id="batteryExpSolarModePool"
:checked="state.batterySolarPool"
class="form-check-input"
type="radio"
name="batteryExpSolarMode"
@change="changePool(true)"
/>
<label class="form-check-label" for="batteryExpSolarModePool">
{{ $t("batterySettings.batteryControlModePool") }}
</label>
</div>
</div>
<p class="text-muted small mb-3">
{{ state.batterySolarPool ? $t("batterySettings.batteryControlModePoolDesc") : $t("batterySettings.batteryControlModePerBatteryDesc") }}
</p>
<hr class="my-3" />
<div class="fw-bold mb-2">{{ $t("batterySettings.calibrationTab") }}</div>
<div class="form-check form-switch">
<input
id="batteryExpCalibrationCharge"
:checked="state.batteryCalibrationCharge"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeCalibrationCharge"
/>
<label class="form-check-label" for="batteryExpCalibrationCharge">
{{ $t("batterySettings.calibrationLabel") }}
</label>
</div>
</template>
</Card>

<Card
v-if="gridChargeVisible"
class="box-pull-out"
Expand Down Expand Up @@ -102,11 +169,11 @@ export default defineComponent({
forecastToSeries(this.evopt, this.now.getTime())
);
},
solarControlPossible(): boolean {
return this.devices.some(({ controllable }) => controllable);
},
gridChargePossible(): boolean {
return (
this.devices.some(({ controllable }) => controllable) &&
!!this.state.smartCostAvailable
);
return this.solarControlPossible && !!this.state.smartCostAvailable;
},
gridChargeLimit(): number | null {
return this.state.batteryGridChargeLimit ?? null;
Expand Down Expand Up @@ -146,6 +213,31 @@ export default defineComponent({
},
},
methods: {
async changeSolarControl(e: Event) {
try {
await api.post(
`batterysolarcontrol/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
async changeCalibrationCharge(e: Event) {
try {
await api.post(
`batterycalibrationcharge/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
async changePool(value: boolean) {
try {
await api.post(`batterysolarpool/${value ? "true" : "false"}`);
} catch (err) {
console.error(err);
}
},
onRangeStart(start: Date) {
this.rangeStart = start;
},
Expand Down
11 changes: 10 additions & 1 deletion assets/js/components/Battery/BatteryStatusCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export default defineComponent({
| "discharging"
| "idle" {
if (this.controllable) {
if (this.batteryMode === BATTERY_MODE.HOLD) return BATTERY_MODE.HOLD;
// FORK (feat/battery_loop): HOLD intentionally omitted here. Watt-level solar
// control keeps the battery in HOLD (RS485 enabled) while actively charging/
// discharging, so mode-priority would mislabel it "discharge locked". Fall
// through to the power-based status instead. Restore the HOLD line to follow
// upstream if watt-level control is dropped.
if (this.batteryMode === BATTERY_MODE.HOLDCHARGE) return BATTERY_MODE.HOLDCHARGE;
if (this.batteryMode === BATTERY_MODE.CHARGE) return BATTERY_MODE.CHARGE;
}
Expand All @@ -112,6 +116,11 @@ export default defineComponent({
return this.$t("battery.card.charging");
case "discharging":
return this.$t("battery.card.discharging");
// FORK (feat/battery_loop): label idle batteries (tiered-off units under
// watt-level control sit at 0W) instead of showing no status. Remove to
// follow upstream.
case "idle":
return this.$t("battery.card.idle");
default:
return undefined;
}
Expand Down
15 changes: 7 additions & 8 deletions assets/js/components/Battery/SocGauge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@
:class="{ 'layer--active': isCharging || isDischarging }"
:style="{ '--rotate': arrowRotate }"
/>
<BatteryHold
:size="ICON_SIZE.M"
class="layer"
:class="{ 'layer--active': mode === BATTERY_MODE.HOLD }"
/>
<!-- FORK (feat/battery_loop): upstream's BatteryHold layer removed - under watt-level
control HOLD is active, not paused, so the arrow/dot icons drive it. Restore the
BatteryHold layer (active on BATTERY_MODE.HOLD) to follow upstream. -->
<BatteryHoldCharge
:size="ICON_SIZE.M"
class="layer"
Expand All @@ -55,7 +53,6 @@ import { defineComponent, type PropType } from "vue";
import "@h2d2/shopicons/es/regular/powersupply";
import { BATTERY_MODE, ICON_SIZE } from "@/types/evcc";
import ArrowDown from "../MaterialIcon/ArrowDown.vue";
import BatteryHold from "../MaterialIcon/BatteryHold.vue";
import BatteryHoldCharge from "../MaterialIcon/BatteryHoldCharge.vue";
import Dot from "../MaterialIcon/Dot.vue";

Expand All @@ -66,8 +63,10 @@ const C = SIZE / 2;
const R = (SIZE - STROKE) / 2;
const CIRCUMFERENCE = 2 * Math.PI * R;

// FORK (feat/battery_loop): BATTERY_MODE.HOLD intentionally omitted — see
// BatteryStatusCard.statusState. Watt-level solar control drives charge/discharge while the
// mode stays HOLD, so the gauge should show real power, not a lock. Restore HOLD to follow upstream.
const LOCKED_MODES: BATTERY_MODE[] = [
BATTERY_MODE.HOLD,
BATTERY_MODE.HOLDCHARGE,
BATTERY_MODE.CHARGE,
];
Expand All @@ -76,7 +75,7 @@ const LOCKED_MODES: BATTERY_MODE[] = [
// tweens instead of hard-swapping
export default defineComponent({
name: "SocGauge",
components: { ArrowDown, BatteryHold, BatteryHoldCharge, Dot },
components: { ArrowDown, BatteryHoldCharge, Dot },
props: {
soc: { type: Number, default: 0 },
color: { type: String, default: "" },
Expand Down
4 changes: 4 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export interface State {
bufferStartSoc?: number;
batteryDischargeControl?: boolean;
solarAdjusted?: boolean;
batterySolarControl?: boolean;
batterySolarPool?: boolean;
batteryCalibrationCharge?: boolean;
batteryControlDeadBand?: number;
batteryGridChargeLimit?: number | null;
smartCostAvailable?: boolean;
smartCostType?: SMART_COST_TYPE;
Expand Down
104 changes: 104 additions & 0 deletions assets/js/views/Battery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,78 @@
:battery-discharge-control="state.batteryDischargeControl"
:battery="state.battery"
/>
<template v-if="solarControlPossible">
<hr class="my-5" />
<h3 class="fw-normal my-4 mt-5">
{{ $t("batterySettings.batteryControlTab") }}
</h3>
<div class="form-check form-switch">
<input
id="batterySolarControl"
:checked="state.batterySolarControl"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeSolarControl"
/>
<label class="form-check-label" for="batterySolarControl">
{{ $t("batterySettings.batteryControl") }}
</label>
</div>
<template v-if="state.batterySolarControl">
<div class="mt-3 mb-1 small text-muted fw-semibold text-uppercase">
{{ $t("batterySettings.batteryControlModeTab") }}
</div>
<div class="d-flex gap-4">
<div class="form-check">
<input
id="batterySolarModePerBattery"
:checked="!state.batterySolarPool"
class="form-check-input"
type="radio"
name="batterySolarMode"
@change="changePool(false)"
/>
<label class="form-check-label" for="batterySolarModePerBattery">
{{ $t("batterySettings.batteryControlModePerBattery") }}
</label>
</div>
<div class="form-check">
<input
id="batterySolarModePool"
:checked="state.batterySolarPool"
class="form-check-input"
type="radio"
name="batterySolarMode"
@change="changePool(true)"
/>
<label class="form-check-label" for="batterySolarModePool">
{{ $t("batterySettings.batteryControlModePool") }}
</label>
</div>
</div>
<p class="text-muted small mt-1">
{{ state.batterySolarPool ? $t("batterySettings.batteryControlModePoolDesc") : $t("batterySettings.batteryControlModePerBatteryDesc") }}
</p>
</template>
<hr class="my-4" />
<h3 class="fw-normal my-4">
{{ $t("batterySettings.calibrationTab") }}
</h3>
<div class="form-check form-switch">
<input
id="batteryCalibrationCharge"
:checked="state.batteryCalibrationCharge"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeCalibrationCharge"
/>
<label class="form-check-label" for="batteryCalibrationCharge">
{{ $t("batterySettings.calibrationLabel") }}
</label>
</div>
</template>
<template v-if="gridChargeVisible">
<hr class="my-5" />
<h3 class="fw-normal my-4 mt-5">
Expand All @@ -39,6 +111,7 @@ import BatteryExperimental from "../components/Battery/BatteryExperimental.vue";
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
import store from "../store";
import settings from "../settings";
import api from "../api";
import { SMART_COST_TYPE } from "../types/evcc";

export default defineComponent({
Expand All @@ -62,6 +135,10 @@ export default defineComponent({
batteryAvailable() {
return (this.state.battery?.devices?.length ?? 0) > 0;
},
solarControlPossible() {
const devices = this.state.battery?.devices ?? [];
return devices.some(({ controllable }) => controllable);
},
gridChargePossible() {
const devices = this.state.battery?.devices ?? [];
return (
Expand Down Expand Up @@ -89,5 +166,32 @@ export default defineComponent({
};
},
},
methods: {
async changeSolarControl(e: Event) {
try {
await api.post(
`batterysolarcontrol/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
async changeCalibrationCharge(e: Event) {
try {
await api.post(
`batterycalibrationcharge/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
async changePool(value: boolean) {
try {
await api.post(`batterysolarpool/${value ? "true" : "false"}`);
} catch (err) {
console.error(err);
}
},
},
});
</script>
1 change: 1 addition & 0 deletions cmd/implement/implement.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func generate(out io.Writer) error {
reflect.TypeFor[api.Battery](),
reflect.TypeFor[api.BatteryCapacity](),
reflect.TypeFor[api.BatteryController](),
reflect.TypeFor[api.BatteryPowerController](),
reflect.TypeFor[api.BatteryPowerLimiter](),
reflect.TypeFor[api.BatterySocLimiter](),
reflect.TypeFor[api.ChargeController](),
Expand Down
Loading
Loading