From 7865cd43c7b40cd9fe5d1b2c3b0918e4d3772680 Mon Sep 17 00:00:00 2001 From: Jan Unterbrink <19386509+subpathdev@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:00:10 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20a=205=20minute=20airing=20option?= =?UTF-8?q?=20for=20a=20quick=20Sto=C3=9Fl=C3=BCften?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buttons now read "Air 5/15/30/60", to fit four in a row at phone width. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/api/api.gen.go | 3 +++ internal/api/openapi.yaml | 2 +- internal/web/static/index.html | 6 +++--- internal/web/web.go | 2 +- internal/web/web_test.go | 8 ++++++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4e5159f..6d3ce22 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A small web page to adjust the heating per room from a phone on the home network ## Airing -"Airing 15/30/60" sends `system_mode: off`. When the time is up, or on "Heat again", the room gets back the mode and target from before. +"Air 5/15/30/60" sends `system_mode: off` for that many minutes, with 5 for a quick Stoßlüften. When the time is up, or on "Heat again", the room gets back the mode and target from before. The service keeps no saved state, so a restart during airing forgets it: **that room stays off until someone taps "Heat again"**. A target waiting to be sent is lost too. The thermostat's own frost protection still applies. diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 08647ad..379d2c9 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -68,6 +68,7 @@ func (e Mode) Valid() bool { const ( N15 StartAiringRequestMinutes = 15 N30 StartAiringRequestMinutes = 30 + N5 StartAiringRequestMinutes = 5 N60 StartAiringRequestMinutes = 60 ) @@ -78,6 +79,8 @@ func (e StartAiringRequestMinutes) Valid() bool { return true case N30: return true + case N5: + return true case N60: return true default: diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 5798e46..4c9b8c8 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -256,7 +256,7 @@ components: properties: minutes: type: integer - enum: [15, 30, 60] + enum: [5, 15, 30, 60] description: How long to air the room. SetTargetRequest: type: object diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 9b7f2ed..e27a853 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -50,7 +50,7 @@ .note { color: var(--muted); font-size: .9rem; margin-top: 8px; } .note:empty { display: none; } .note.problem { color: var(--error); } - .airing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; } + .airing { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 8px; } .airing button.stop { grid-column: 1 / -1; background: var(--accent); color: var(--card); font-weight: 600; } [hidden] { display: none !important; } @@ -78,7 +78,7 @@

Heating

errorEl.textContent = commandError || connectionError; } - const airingMinutes = [15, 30, 60]; + const airingMinutes = [5, 15, 30, 60]; const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const degrees = v => v == null ? '–' : v.toFixed(1) + ' °C'; @@ -140,7 +140,7 @@

Heating

airing.className = 'airing'; const start = airingMinutes.map(minutes => { const b = document.createElement('button'); - b.textContent = 'Airing ' + minutes; + b.textContent = 'Air ' + minutes; b.setAttribute('aria-label', 'Air ' + room.label + ' for ' + minutes + ' minutes'); b.addEventListener('click', () => startAiring(room.id, minutes)); airing.append(b); diff --git a/internal/web/web.go b/internal/web/web.go index 74653cc..ef90b74 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -144,7 +144,7 @@ func (s server) SetRoomTarget(_ context.Context, r api.SetRoomTargetRequestObjec func (s server) StartRoomAiring(_ context.Context, r api.StartRoomAiringRequestObject) (api.StartRoomAiringResponseObject, error) { if !r.Body.Minutes.Valid() { - return api.StartRoomAiring400JSONResponse{Message: "minutes must be 15, 30 or 60"}, nil + return api.StartRoomAiring400JSONResponse{Message: "minutes must be 5, 15, 30 or 60"}, nil } switch err := s.rooms.StartAiring(r.Id, time.Duration(r.Body.Minutes)*time.Minute); { case err == nil: diff --git a/internal/web/web_test.go b/internal/web/web_test.go index b89999e..5ec8854 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -190,6 +190,14 @@ func TestStartAiring(t *testing.T) { } } +func TestStartAiringForFiveMinutes(t *testing.T) { + f := &fakeRooms{} + rec := post(t, handler(f), "/api/rooms/office/airing", `{"minutes":5}`, nil) + if rec.Code != http.StatusAccepted || f.airing != 5*time.Minute { + t.Fatalf("status %d, airing %v", rec.Code, f.airing) + } +} + func TestStartAiringErrors(t *testing.T) { tests := []struct { name string