diff --git a/backend/internal/app/handlers.go b/backend/internal/app/handlers.go index 01a5448..b8d32a2 100644 --- a/backend/internal/app/handlers.go +++ b/backend/internal/app/handlers.go @@ -82,8 +82,16 @@ func (a *App) getStatus(c *gin.Context) { type ToggleStatusRequest struct { CardID string `json:"cardId"` Hash string `json:"hash"` + // Reason marks a non-standard close. When set to "gelatino" the toggle + // becomes an idempotent close ("chiusa per gelatino") instead of flipping + // the previous state. Empty for a regular toggle. + Reason string `json:"reason,omitempty"` } +// reasonGelatino is the canonical tag for the "chiusa per gelatino" closure, +// triggered by a fast double-click on the physical button. +const reasonGelatino = "gelatino" + func (a *App) toggleStatus(c *gin.Context) { sp := spaceFrom(c) @@ -117,9 +125,17 @@ func (a *App) toggleStatus(c *gin.Context) { } } + // "gelatino" is a forced close, not a flip: a double-click should always + // land in the closed state regardless of the previous one. + newIsOpen := !currentStatus.IsOpen + if req.Reason == reasonGelatino { + newIsOpen = false + } + newStatus := database.SedeStatus{ SpaceID: sp.ID, - IsOpen: !currentStatus.IsOpen, + IsOpen: newIsOpen, + Reason: req.Reason, Timestamp: time.Now().UTC(), } @@ -136,6 +152,10 @@ func (a *App) toggleStatus(c *gin.Context) { emoji = "🔴" action = "chiusa" } + if newStatus.Reason == reasonGelatino { + emoji = "🍦" + action = "chiusa per gelatino" + } var msg string if cardName != "" { @@ -281,9 +301,19 @@ func (a *App) getSpaceAPI(c *gin.Context) { var isOpen bool var lastChange int64 + var reason string if !errors.Is(err, gorm.ErrRecordNotFound) { isOpen = status.IsOpen lastChange = status.Timestamp.Unix() + reason = status.Reason + } + + // When the latest event is a gelatino closure, surface it in the SpaceAPI + // message field so any external consumer (websites, dashboards) sees the + // reason rather than just "closed". + message := sp.Message + if !isOpen && reason == reasonGelatino { + message = "Chiusa per gelatino 🍦" } var projects []string @@ -313,7 +343,7 @@ func (a *App) getSpaceAPI(c *gin.Context) { State: SpaceAPIState{ Open: isOpen, LastChange: lastChange, - Message: sp.Message, + Message: message, }, Contact: map[string]string{ "email": sp.ContactEmail, diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 9a66c8f..a7590c1 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -52,10 +52,16 @@ type Space struct { // SpaceID exists solely so that adding the column via SQLite ALTER TABLE on // an existing single-space DB succeeds; new rows always set SpaceID // explicitly, and boot-time backfill rewrites any legacy zeros. +// +// Reason is an optional tag explaining a non-standard closure (e.g. "gelatino" +// when the sede is closed because everyone went for ice cream). Empty on +// normal toggles. Stored as a plain string column to keep the schema simple +// and the ESP32 client free to send arbitrary reasons over the wire. type SedeStatus struct { ID uint `gorm:"primarykey"` SpaceID uint `gorm:"not null;default:0;index:idx_space_timestamp,priority:1"` IsOpen bool `gorm:"not null"` + Reason string `gorm:"default:''"` Timestamp time.Time `gorm:"not null;index:idx_space_timestamp,priority:2"` } diff --git a/hardware/config/pulsante-sede.yaml b/hardware/config/pulsante-sede.yaml index 6737231..9ff6f71 100644 --- a/hardware/config/pulsante-sede.yaml +++ b/hardware/config/pulsante-sede.yaml @@ -170,67 +170,118 @@ binary_sensor: mode: INPUT_PULLUP inverted: true name: "Pulsante Sede" - on_press: - then: - - light.turn_on: - id: rgb_light - effect: "Elaborazione" - red: 0% - green: 0% - blue: 100% - - http_request.post: - url: !secret sede_toggle_url - headers: - X-API-KEY: !secret sede_api_key - Content-Type: application/json - json: - cardId: !lambda 'return id(card_id);' - hash: !lambda 'return id(card_hash);' - capture_response: true - on_response: - then: - - if: - condition: - lambda: return response->status_code == 200; - then: - - if: - condition: - lambda: return body == "true"; - then: - - light.turn_on: - id: rgb_light - red: 0% - green: 100% - blue: 0% - effect: none - - globals.set: - id: sede_state - value: 'true' - else: - - light.turn_on: - id: rgb_light - red: 100% - green: 0% - blue: 0% - effect: none - - globals.set: - id: sede_state - value: 'false' - else: - - light.turn_on: - id: rgb_light - effect: "Errore" - - globals.set: - id: sede_state - value: 'false' - on_error: - then: - - light.turn_on: - id: rgb_light - effect: "Errore" - - globals.set: - id: sede_state - value: 'false' + on_multi_click: + # Doppio click veloce -> sede chiusa per gelatino (LED azzurro) + - timing: + - ON for at most 0.5s + - OFF for at most 0.3s + - ON for at most 0.5s + - OFF for at least 0.2s + then: + - light.turn_on: + id: rgb_light + effect: "Elaborazione" + red: 0% + green: 50% + blue: 100% + - http_request.post: + url: !secret sede_toggle_url + headers: + X-API-KEY: !secret sede_api_key + Content-Type: application/json + json: + cardId: !lambda 'return id(card_id);' + hash: !lambda 'return id(card_hash);' + reason: "gelatino" + capture_response: true + on_response: + then: + - if: + condition: + lambda: return response->status_code == 200; + then: + - globals.set: + id: sede_state + value: 'false' + - light.turn_on: + id: rgb_light + red: 0% + green: 50% + blue: 100% + effect: none + else: + - light.turn_on: + id: rgb_light + effect: "Errore" + on_error: + then: + - light.turn_on: + id: rgb_light + effect: "Errore" + # Singolo click -> toggle standard + - timing: + - ON for at most 1s + - OFF for at least 0.5s + then: + - light.turn_on: + id: rgb_light + effect: "Elaborazione" + red: 0% + green: 0% + blue: 100% + - http_request.post: + url: !secret sede_toggle_url + headers: + X-API-KEY: !secret sede_api_key + Content-Type: application/json + json: + cardId: !lambda 'return id(card_id);' + hash: !lambda 'return id(card_hash);' + capture_response: true + on_response: + then: + - if: + condition: + lambda: return response->status_code == 200; + then: + - if: + condition: + lambda: return body == "true"; + then: + - light.turn_on: + id: rgb_light + red: 0% + green: 100% + blue: 0% + effect: none + - globals.set: + id: sede_state + value: 'true' + else: + - light.turn_on: + id: rgb_light + red: 100% + green: 0% + blue: 0% + effect: none + - globals.set: + id: sede_state + value: 'false' + else: + - light.turn_on: + id: rgb_light + effect: "Errore" + - globals.set: + id: sede_state + value: 'false' + on_error: + then: + - light.turn_on: + id: rgb_light + effect: "Errore" + - globals.set: + id: sede_state + value: 'false' i2c: sda: GPIO5