Merge PR #103: feat: choose temperature and hours for Ciepła woda teraz
CI / Check (gateway, backend, frontend) (push) Successful in 1m10s

This commit is contained in:
root committed 2026-10-25 09:20:53 +01:00
commit b3fb7f8b40
8 files changed
+290 -56

No files matched your search

+3 -1
View File
@@ -134,7 +134,9 @@ needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `s
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
@@ -57,8 +57,11 @@ public class ApiServer {
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
private record PresetRequest(String until, Map<String, Integer> values) {
/**
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
* preset, by setpoint name.
*/
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
@@ -316,14 +319,17 @@ public class ApiServer {
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (SetpointService.InvalidHoursException e) {
send(exchange, 400, error("invalid_hours"));
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
@@ -25,6 +25,16 @@ import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
public static final int MAX_PRESET_HOURS = 4;
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
public static class InvalidHoursException extends IllegalArgumentException {
InvalidHoursException(String message) {
super(message);
}
}
private final GatewayCommands gateway;
private final List<Preset> presets;
@@ -53,12 +63,15 @@ public class SetpointService {
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
@@ -68,7 +81,7 @@ public class SetpointService {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withValues(preset.get().changes(), values);
List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
@@ -87,6 +100,19 @@ public class SetpointService {
c.hours())).toList();
}
private static List<Change> withHours(List<Change> changes, Integer hours) {
if (hours == null) {
return changes;
}
if (hours < 1 || hours > MAX_PRESET_HOURS) {
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
}
if (changes.stream().anyMatch(c -> c.hours() == null)) {
throw new InvalidHoursException("this preset has no hours to choose");
}
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
@@ -863,6 +863,25 @@ class ApiServerTest {
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void timedPresetTakesOwnHoursAndValue() throws Exception {
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
}
@Test
void invalidHoursAre400AndSendNothing() throws Exception {
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
}
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
assertEquals(400, held.statusCode());
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
assertTrue(sent.isEmpty());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -127,15 +127,38 @@ class SetpointServiceTest {
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("cwu-now", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
}
@Test
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
for (int hours : List.of(0, 5, -1, 72)) {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetHasNoHoursToChoose() {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
@@ -144,7 +167,7 @@ class SetpointServiceTest {
@Test
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
service.applyPreset("away", RETURN, null, Map.of("co_comfort", 16), "anna");
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
@@ -152,7 +175,7 @@ class SetpointServiceTest {
@Test
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
}
@@ -160,7 +183,7 @@ class SetpointServiceTest {
@Test
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
assertTrue(sent.isEmpty());
}
@@ -168,14 +191,14 @@ class SetpointServiceTest {
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
for (int value : List.of(4, 26, 0)) {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
() -> service.applyPreset("away", RETURN, null, Map.of("co_comfort", value), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -184,8 +207,8 @@ class SetpointServiceTest {
@Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@@ -194,7 +217,7 @@ class SetpointServiceTest {
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 19, null)));
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -204,7 +227,7 @@ class SetpointServiceTest {
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@@ -213,7 +236,7 @@ class SetpointServiceTest {
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -225,7 +248,7 @@ class SetpointServiceTest {
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
@@ -239,7 +262,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -250,7 +273,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -260,7 +283,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -270,7 +293,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -280,7 +303,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -292,7 +315,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -0,0 +1 @@
{ "hours": 3, "values": { "cwu_economy": 47 } }
+92 -2
View File
@@ -210,8 +210,10 @@ describe("Settings", () => {
expect(posts(fetch)).toHaveLength(0);
});
it("keeps the plain confirmation for a preset without a chosen end", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
it("keeps the plain confirmation for a preset that is neither held nor timed", async () => {
const data = example<Setpoints>("setpoints");
data.presets[0].changes[0].hours = null;
const fetch = serve({ "api/setpoints": data }, () => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
@@ -223,6 +225,94 @@ describe("Settings", () => {
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
});
describe("form of Ciepła woda teraz", () => {
const open = async (data = example<Setpoints>("setpoints"), pump?: HeatPump) => {
const fetch = serve({ "api/setpoints": data, ...(pump && { "api/heat-pump": pump }) },
() => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
return fetch;
};
const more = (times: number) => Array.from({ length: times }).reduce<Promise<void>>(
(done) => done.then(() => userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie"))), Promise.resolve());
it("starts at the preset's temperature and hours and sends them", async () => {
const fetch = await open();
expect(screen.getByRole("status").textContent).toBe("45 °C");
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText(paragraph("Ciepła woda do 45 °C przez 2 godz., potem wróci 8 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 2, values: { cwu_economy: 45 } });
});
it("sends the temperature and hours the user chose", async () => {
const fetch = await open();
await userEvent.click(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie"));
await userEvent.click(screen.getByRole("button", { name: "4 h" }));
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByText(paragraph("Ciepła woda do 44 °C przez 4 godz., potem wróci 8 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 4, values: { cwu_economy: 44 } });
});
it("stays between just above the current value and the gateway's maximum", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 44;
data.presets[0].changes[0].value = 40;
await open(data);
expect(screen.getByRole("status").textContent).toBe("45 °C");
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
await more(5);
expect(screen.getByRole("status").textContent).toBe("48 °C");
expect(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
});
it("cannot be confirmed when the water is already at the maximum", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 48;
await open(data);
expect(screen.getByText("Już na maksimum (48 °C), nie da się podnieść.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Podgrzej wodę" })).toBeDisabled();
});
it("warns above 47 °C that the pump alone does not get there", async () => {
await open();
const hint = "Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)";
await more(2);
expect(screen.queryByText(hint)).toBeNull();
await more(1);
expect(screen.getByText(hint)).toBeInTheDocument();
});
it("keeps the comfort-period note, for the chosen temperature", async () => {
const pump = example<HeatPump>("heat-pump");
await open(undefined, { ...pump, setpoints: { ...pump.setpoints, cwuComfort: 46 }, periods: { ...pump.periods, cwu: "comfort" } });
expect(await screen.findAllByText("Teraz strefa komfortu – woda i tak grzeje się do 46 °C")).toHaveLength(1);
await more(2);
expect(screen.getByText(/Teraz strefa komfortu \(46 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 47 °C/))
.toBeInTheDocument();
});
it("closes on cancel without writing", async () => {
const fetch = await open();
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
expect(screen.getByRole("button", { name: "Ciepła woda teraz" })).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
});
});
it("shows a held change with its end date and the value that comes back", async () => {
const data = example<Setpoints>("setpoints");
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
+95 -28
View File
@@ -85,6 +85,46 @@ function ResultBox({ result }: { result: Result }) {
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
}
type Limits = Record<string, { min: number; max: number; step: number }>;
const limitsOf = (data: Setpoints, preset: Preset, range: (s: Setpoint) => { min: number; max: number }): Limits =>
Object.fromEntries(preset.changes.map((c) => {
const s = find(data, c.setpoint);
return [c.setpoint, { ...range(s), step: s.step }];
}));
// The preset's own values, moved into the limits; the user steps them from there.
function usePresetValues(preset: Preset, limits: Limits) {
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
})));
const stepValue = (id: string, direction: number) => {
const { min, max, step } = limits[id];
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
};
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
return { values, stepValue, stuck };
}
function ValueSteppers({ data, preset, limits, values, busy, stuckNote, onStep }: {
data: Setpoints; preset: Preset; limits: Limits; values: Record<string, number>; busy: boolean;
stuckNote: (current: string) => string; onStep: (id: string, direction: number) => void;
}) {
return preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
const label = name(c.setpoint);
return (
<div key={c.setpoint} className="my-3">
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
onStep={(direction) => onStep(c.setpoint, direction)} />
{max < min && <p className="text-sm text-alarm">{stuckNote(degrees(find(data, c.setpoint).value))}</p>}
</div>
);
});
}
const DAY_MS = 24 * 3600_000;
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
@@ -95,19 +135,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
onConfirm: (until: string, values: Record<string, number>) => void;
}) {
const limits = Object.fromEntries(preset.changes.map((c) => {
const s = find(data, c.setpoint);
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
}));
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
})));
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
const stepValue = (id: string, direction: number) => {
const { min, max, step } = limits[id];
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
};
const limits = limitsOf(data, preset, (s) => ({ min: s.min, max: lowestAllowed(s) }));
const { values, stepValue, stuck } = usePresetValues(preset, limits);
const [now] = useState(() => Date.now());
const min = localInput(new Date(now + 3600_000));
const max = localInput(new Date(now + 30 * DAY_MS));
@@ -125,18 +154,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
</label>
{preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
const label = name(c.setpoint);
return (
<div key={c.setpoint} className="my-3">
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
onStep={(direction) => stepValue(c.setpoint, direction)} />
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
</div>
);
})}
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
stuckNote={(current) => `Już na minimum (${current}), nie da się obniżyć.`} />
<div className="flex gap-3">
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
@@ -145,14 +164,58 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
);
}
// What the pump reaches on its own in the tank; above it only the CWU output (off at 52 °C) helps.
const PUMP_ALONE_MAX = 47;
const PRESET_HOURS = [1, 2, 3, 4];
// A timed preset raises: from just above the current value up to the gateway's maximum (none left: min > max).
const raisable = (s: Setpoint) => ({ min: Math.max(s.min, s.value! + s.step), max: s.max });
const isCwu = (setpoint: string) => setpoint.startsWith("cwu_");
function TimedForm({ data, preset, hp, busy, onCancel, onConfirm }: {
data: Setpoints; preset: Preset; hp: HeatPump | undefined; busy: boolean; onCancel: () => void;
onConfirm: (hours: number, values: Record<string, number>) => void;
}) {
const limits = limitsOf(data, preset, raisable);
const { values, stepValue, stuck } = usePresetValues(preset, limits);
const presetHours = preset.changes[0].hours!;
const [hours, setHours] = useState(Math.min(4, Math.max(1, Math.round(presetHours))));
const chosen = preset.changes.map((c) => ({ ...c, value: values[c.setpoint] }));
const above = chosen.some((c) => isCwu(c.setpoint) && c.value > PUMP_ALONE_MAX);
return (
<>
{chosen.map((c) => (
<p key={c.setpoint} className="my-2 text-lg font-semibold">
{isCwu(c.setpoint) ? "Ciepła woda" : name(c.setpoint)} do <b>{degrees(c.value)}</b> przez {duration(hours)}, potem wróci {degrees(returnValue(data, c.setpoint))}.
</p>
))}
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
stuckNote={(current) => `Już na maksimum (${current}), nie da się podnieść.`} />
{above && <p className="text-sm text-soft">Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)</p>}
<ComfortNote changes={chosen} hp={hp} />
<p className="mt-4 mb-2 font-semibold">Na jak długo</p>
<div className="mb-4 grid grid-cols-4 gap-2">
{PRESET_HOURS.map((h) => (
<Button key={h} aria-pressed={h === hours} disabled={busy} onClick={() => setHours(h)}>{h} h</Button>
))}
</div>
<div className="flex gap-3">
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => onConfirm(hours, values)} disabled={busy || stuck}>Podgrzej wodę</Button>
</div>
</>
);
}
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
const presetHint = (p: Preset) => p.until
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
// The controller works to the comfort value in a comfort period, whatever economy is set to.
function ComfortNote({ preset, hp }: { preset: Preset; hp: HeatPump | undefined }) {
const raised = preset.changes.find((c) => c.setpoint === "cwu_economy")?.value;
function ComfortNote({ changes, hp }: { changes: Change[]; hp: HeatPump | undefined }) {
const raised = changes.find((c) => c.setpoint === "cwu_economy")?.value;
const comfort = hp?.setpoints.cwuComfort;
if (raised == null || comfort == null || hp?.stale || hp?.periods.cwu !== "comfort") return null;
return (
@@ -244,6 +307,7 @@ export function Settings() {
const preset = data.presets.find((p) => p.id === presetId);
if (preset) {
const timed = preset.changes.every((c) => c.hours != null);
return (
<>
{title}
@@ -252,6 +316,9 @@ export function Settings() {
{preset.until ? (
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
) : timed ? (
<TimedForm data={data} preset={preset} hp={hp} busy={busy} onCancel={cancel}
onConfirm={(hours, values) => send(`api/presets/${preset.id}`, { hours, values }, preset.changes)} />
) : (
<>
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</p>)}
@@ -310,7 +377,7 @@ export function Settings() {
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
<ComfortNote preset={p} hp={hp} />
<ComfortNote changes={p.changes} hp={hp} />
</div>
);
})}