package main import ( "context" "encoding/json" "errors" "log" "net/http" "sync" "time" "github.com/gorilla/websocket" "powerpod/gotool/pb" ) const ( defaultAccelStreamInterval = 16 * time.Millisecond minAPIStreamInterval = 1 * time.Millisecond maxAPIStreamInterval = 10 * time.Second // How long tap events stay in API push/cache after first sight (matches dashboard). apiTapDisplayMinMs = 2000 ) // AccelClientSample is one slave's cached accel on the master. type AccelClientSample struct { ClientID uint32 `json:"client_id"` Valid bool `json:"valid"` X int32 `json:"x,omitempty"` Y int32 `json:"y,omitempty"` Z int32 `json:"z,omitempty"` AgeMs uint32 `json:"age_ms,omitempty"` } // AccelStreamMessage is sent to external WebSocket clients (hello + accel samples). type AccelStreamMessage struct { Type string `json:"type"` // "hello" | "accel" Serial string `json:"serial_port,omitempty"` IntervalMs int `json:"interval_ms,omitempty"` TapDisplayMinMs int `json:"tap_display_min_ms,omitempty"` Commands []string `json:"commands,omitempty"` Note string `json:"note,omitempty"` T int64 `json:"t,omitempty"` // Unix nanoseconds Success bool `json:"success,omitempty"` Clients []AccelClientSample `json:"clients,omitempty"` Error string `json:"error,omitempty"` } // StreamStatusMessage is the reply to set_stream / get_stream (this connection). type StreamStatusMessage struct { Type string `json:"type"` // "stream_status" ReceiveAccel bool `json:"receive_accel"` IntervalMs int `json:"interval_ms"` Success bool `json:"success"` Error string `json:"error,omitempty"` } // AccelStreamStatusMessage is the reply to set_accel_stream / get_accel_stream (slave). type AccelStreamStatusMessage struct { Type string `json:"type"` // "accel_stream_status" ClientID uint32 `json:"client_id"` Enabled bool `json:"enabled"` Success bool `json:"success"` SlavesUpdated uint32 `json:"slaves_updated,omitempty"` Error string `json:"error,omitempty"` } // TapClientEvent is one tap visible to API clients (fresh or within tap_display_min_ms). type TapClientEvent struct { ClientID uint32 `json:"client_id"` Valid bool `json:"valid"` Kind string `json:"kind,omitempty"` // single | double | triple AgeMs uint32 `json:"age_ms,omitempty"` ShownAtMs int64 `json:"shown_at_ms,omitempty"` // Unix ms when API first saw this tap } // TapStreamMessage is pushed to external WebSocket clients when receive_tap is on. type TapStreamMessage struct { Type string `json:"type"` // "tap" T int64 `json:"t,omitempty"` Success bool `json:"success,omitempty"` Events []TapClientEvent `json:"events,omitempty"` Error string `json:"error,omitempty"` } // TapStreamStatusMessage is the reply to set_tap_stream / get_tap_stream (this connection). type TapStreamStatusMessage struct { Type string `json:"type"` // "tap_stream_status" ReceiveTap bool `json:"receive_tap"` IntervalMs int `json:"interval_ms"` Success bool `json:"success"` Error string `json:"error,omitempty"` } // TapNotifyStatusMessage is the reply to set_tap_notify / get_tap_notify (slave). type TapNotifyStatusMessage struct { Type string `json:"type"` // "tap_notify_status" ClientID uint32 `json:"client_id"` Single bool `json:"single"` DoubleTap bool `json:"double_tap"` Triple bool `json:"triple"` Success bool `json:"success"` SlavesUpdated uint32 `json:"slaves_updated,omitempty"` Error string `json:"error,omitempty"` } type accelWSCommand struct { Type string `json:"type"` ClientID uint32 `json:"client_id"` Enable *bool `json:"enable"` IntervalMs *int `json:"interval_ms"` Single *bool `json:"single"` DoubleTap *bool `json:"double_tap"` Triple *bool `json:"triple"` AllClients bool `json:"all_clients"` } type APIInfoResponse struct { Name string `json:"name"` Version string `json:"version"` SerialPort string `json:"serial_port"` WebSocket string `json:"websocket"` DefaultIntervalMs int `json:"default_interval_ms"` MinIntervalMs int `json:"min_interval_ms"` MaxIntervalMs int `json:"max_interval_ms"` TapDisplayMinMs int `json:"tap_display_min_ms"` Description string `json:"description"` } type cachedTapEvent struct { kind string shownAt time.Time } type wsSubscriber struct { conn *websocket.Conn receiveAccel bool receiveTap bool interval time.Duration lastAccelSent time.Time lastTapSent time.Time } type accelStreamHub struct { mu sync.RWMutex clients map[*websocket.Conn]*wsSubscriber defaultInterval time.Duration configChanged chan struct{} recentTaps map[uint32]cachedTapEvent } func newAccelStreamHub(defaultInterval time.Duration) *accelStreamHub { return &accelStreamHub{ clients: make(map[*websocket.Conn]*wsSubscriber), defaultInterval: defaultInterval, configChanged: make(chan struct{}, 1), } } func (h *accelStreamHub) notifyConfigChanged() { select { case h.configChanged <- struct{}{}: default: } } func clampAPIInterval(d time.Duration) time.Duration { if d < minAPIStreamInterval { return minAPIStreamInterval } if d > maxAPIStreamInterval { return maxAPIStreamInterval } return d } func (h *accelStreamHub) register(conn *websocket.Conn, portName string) *wsSubscriber { sub := &wsSubscriber{ conn: conn, receiveAccel: false, interval: h.defaultInterval, } h.mu.Lock() h.clients[conn] = sub h.mu.Unlock() hello := AccelStreamMessage{ Type: "hello", Serial: portName, IntervalMs: int(h.defaultInterval / time.Millisecond), TapDisplayMinMs: apiTapDisplayMinMs, Note: "set_tap_notify configures slave S/D/T only; set_tap_stream enables tap polling/push", Commands: []string{ "set_stream", "get_stream", "set_accel_stream", "get_accel_stream", "set_tap_stream", "get_tap_stream", "set_tap_notify", "get_tap_notify", "set_led_ring", "get_battery", }, } if data, err := json.Marshal(hello); err == nil { _ = conn.WriteMessage(websocket.TextMessage, data) } return sub } func (h *accelStreamHub) unregister(conn *websocket.Conn) { h.mu.Lock() delete(h.clients, conn) anyTap := false for _, sub := range h.clients { if sub.receiveTap { anyTap = true break } } if !anyTap { h.recentTaps = nil } h.mu.Unlock() h.notifyConfigChanged() } func (h *accelStreamHub) anyWantsAccel() bool { h.mu.RLock() defer h.mu.RUnlock() for _, sub := range h.clients { if sub.receiveAccel { return true } } return false } func (h *accelStreamHub) anyWantsTap() bool { h.mu.RLock() defer h.mu.RUnlock() for _, sub := range h.clients { if sub.receiveTap { return true } } return false } func (h *accelStreamHub) minWantedInterval() time.Duration { h.mu.RLock() defer h.mu.RUnlock() var min time.Duration for _, sub := range h.clients { if !sub.receiveAccel && !sub.receiveTap { continue } if min == 0 || sub.interval < min { min = sub.interval } } if min == 0 { return h.defaultInterval } return min } func (h *accelStreamHub) setStream(sub *wsSubscriber, enable bool, intervalMs *int) StreamStatusMessage { h.mu.Lock() sub.receiveAccel = enable if intervalMs != nil { sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond) } ms := int(sub.interval / time.Millisecond) h.mu.Unlock() h.notifyConfigChanged() return StreamStatusMessage{ Type: "stream_status", ReceiveAccel: enable, IntervalMs: ms, Success: true, } } func (h *accelStreamHub) getStream(sub *wsSubscriber) StreamStatusMessage { h.mu.RLock() defer h.mu.RUnlock() return StreamStatusMessage{ Type: "stream_status", ReceiveAccel: sub.receiveAccel, IntervalMs: int(sub.interval / time.Millisecond), Success: true, } } func (h *accelStreamHub) setTapStream(sub *wsSubscriber, enable bool, intervalMs *int) TapStreamStatusMessage { h.mu.Lock() sub.receiveTap = enable if !enable { h.recentTaps = nil } if intervalMs != nil { sub.interval = clampAPIInterval(time.Duration(*intervalMs) * time.Millisecond) } ms := int(sub.interval / time.Millisecond) h.mu.Unlock() h.notifyConfigChanged() return TapStreamStatusMessage{ Type: "tap_stream_status", ReceiveTap: enable, IntervalMs: ms, Success: true, } } func (h *accelStreamHub) getTapStream(sub *wsSubscriber) TapStreamStatusMessage { h.mu.RLock() defer h.mu.RUnlock() return TapStreamStatusMessage{ Type: "tap_stream_status", ReceiveTap: sub.receiveTap, IntervalMs: int(sub.interval / time.Millisecond), Success: true, } } func (h *accelStreamHub) ingestTapEvents(incoming []TapClientEvent) []TapClientEvent { h.mu.Lock() defer h.mu.Unlock() now := time.Now() if h.recentTaps == nil { h.recentTaps = make(map[uint32]cachedTapEvent) } for _, e := range incoming { if !e.Valid || e.Kind == "" { continue } h.recentTaps[e.ClientID] = cachedTapEvent{kind: e.Kind, shownAt: now} } return h.activeTapEventsLocked(now) } func (h *accelStreamHub) activeTapEventsLocked(now time.Time) []TapClientEvent { if len(h.recentTaps) == 0 { return nil } cutoff := now.Add(-apiTapDisplayMinMs * time.Millisecond) out := make([]TapClientEvent, 0, len(h.recentTaps)) for id, ev := range h.recentTaps { if ev.shownAt.Before(cutoff) { delete(h.recentTaps, id) continue } shownAtMs := ev.shownAt.UnixMilli() out = append(out, TapClientEvent{ ClientID: id, Valid: true, Kind: ev.kind, AgeMs: uint32(now.Sub(ev.shownAt).Milliseconds()), ShownAtMs: shownAtMs, }) } return out } func (h *accelStreamHub) deliver(msg AccelStreamMessage) { data, err := json.Marshal(msg) if err != nil { return } now := time.Now() h.mu.Lock() defer h.mu.Unlock() for conn, sub := range h.clients { if !sub.receiveAccel { continue } if !sub.lastAccelSent.IsZero() && now.Sub(sub.lastAccelSent) < sub.interval { continue } sub.lastAccelSent = now if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { delete(h.clients, conn) _ = conn.Close() } } } func (h *accelStreamHub) deliverTap(msg TapStreamMessage) { data, err := json.Marshal(msg) if err != nil { return } now := time.Now() h.mu.Lock() defer h.mu.Unlock() for conn, sub := range h.clients { if !sub.receiveTap { continue } if !sub.lastTapSent.IsZero() && now.Sub(sub.lastTapSent) < sub.interval { continue } sub.lastTapSent = now if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { delete(h.clients, conn) _ = conn.Close() } } } func runAccelStreamer(link *managedSerial, hub *accelStreamHub, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) { var ticker *time.Ticker var tick <-chan time.Time resetTicker := func() { if ticker != nil { ticker.Stop() } interval := hub.minWantedInterval() ticker = time.NewTicker(interval) tick = ticker.C } resetTicker() defer func() { if ticker != nil { ticker.Stop() } }() for { select { case <-stop: return case <-hub.configChanged: resetTicker() case <-tick: wantAccel := hub.anyWantsAccel() && accelStreamPollingActive(dash, ctl) wantTap := hub.anyWantsTap() if !wantAccel && !wantTap { continue } now := time.Now().UnixNano() cache, err := link.readCacheStatusPoll() if errors.Is(err, errUARTBusy) { if wantAccel { hub.deliver(AccelStreamMessage{ Type: "accel", T: now, Success: false, Error: "uart busy", }) } if wantTap { hub.deliverTap(TapStreamMessage{ Type: "tap", T: now, Success: false, Error: "uart busy", }) } continue } if err != nil { if wantAccel { hub.deliver(AccelStreamMessage{ Type: "accel", T: now, Success: false, Error: err.Error(), }) } if wantTap { hub.deliverTap(TapStreamMessage{ Type: "tap", T: now, Success: false, Error: err.Error(), }) } continue } if wantAccel { clients := make([]AccelClientSample, 0, len(cache.GetAccel())) for _, s := range cache.GetAccel() { clients = append(clients, AccelClientSample{ ClientID: s.GetClientId(), Valid: s.GetValid(), X: s.GetX(), Y: s.GetY(), Z: s.GetZ(), AgeMs: s.GetAgeMs(), }) } hub.deliver(AccelStreamMessage{ Type: "accel", T: now, Success: true, Clients: clients, }) } if wantTap { fresh := make([]TapClientEvent, 0, len(cache.GetTaps())) for _, e := range cache.GetTaps() { if !e.GetValid() { continue } fresh = append(fresh, TapClientEvent{ ClientID: e.GetClientId(), Valid: true, Kind: tapKindLabelPB(e.GetKind()), AgeMs: e.GetAgeMs(), }) } events := hub.ingestTapEvents(fresh) if len(events) > 0 { hub.deliverTap(TapStreamMessage{ Type: "tap", T: now, Success: true, Events: events, }) } } } } } func accelStreamPollingActive(dash *wsHub, ctl *accelStreamCtl) bool { if ctl != nil && ctl.Any() { return true } return dash != nil && dash.anyAccelStreamEnabled() } func writeStreamStatus(conn *websocket.Conn, msg StreamStatusMessage) { data, err := json.Marshal(msg) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func writeBatteryStatus(conn *websocket.Conn, out batteryAPIResponse) { out.Type = "battery_status" data, err := json.Marshal(out) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func writeLedRingStatus(conn *websocket.Conn, out ledRingAPIResponse) { out.Type = "led_ring_status" data, err := json.Marshal(out) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func writeAccelStreamStatus(conn *websocket.Conn, out accelStreamAPIResponse) { msg := AccelStreamStatusMessage{ Type: "accel_stream_status", ClientID: out.ClientID, Enabled: out.Enabled, Success: out.Success, SlavesUpdated: out.SlavesUpdated, Error: out.Error, } data, err := json.Marshal(msg) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func writeTapStreamStatus(conn *websocket.Conn, msg TapStreamStatusMessage) { data, err := json.Marshal(msg) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func writeTapNotifyStatus(conn *websocket.Conn, out tapNotifyAPIResponse) { msg := TapNotifyStatusMessage{ Type: "tap_notify_status", ClientID: out.ClientID, Single: out.Single, DoubleTap: out.DoubleTap, Triple: out.Triple, Success: out.Success, SlavesUpdated: out.SlavesUpdated, Error: out.Error, } data, err := json.Marshal(msg) if err != nil { return } _ = conn.WriteMessage(websocket.TextMessage, data) } func applyTapNotifyClientWS(link *managedSerial, dash *wsHub, tapCtl *tapNotifyCtl, clientID uint32, single, doubleTap, triple bool) tapNotifyAPIResponse { resp, err := link.TapNotify(&pb.TapNotifyRequest{ Write: true, ClientId: clientID, Single: single, DoubleTap: doubleTap, Triple: triple, }) if err != nil { return tapNotifyAPIResponse{ClientID: clientID, Error: err.Error()} } out := tapNotifyAPIResponse{ ClientID: resp.GetClientId(), Success: resp.GetSuccess(), SlavesUpdated: resp.GetSlavesUpdated(), Single: resp.GetSingle(), DoubleTap: resp.GetDoubleTap(), Triple: resp.GetTriple(), } if resp.GetSuccess() { if tapCtl != nil { tapCtl.Set(clientID, single, doubleTap, triple) } if dash != nil { dash.patchClientTapNotify(clientID, single, doubleTap, triple) } } return out } func handleAccelWSCommand(conn *websocket.Conn, sub *wsSubscriber, data []byte, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, hub *accelStreamHub) { var cmd accelWSCommand if err := json.Unmarshal(data, &cmd); err != nil { writeStreamStatus(conn, StreamStatusMessage{Type: "stream_status", Error: "invalid JSON"}) return } switch cmd.Type { case "set_stream": if cmd.Enable == nil { writeStreamStatus(conn, StreamStatusMessage{ Type: "stream_status", Error: "enable required", }) return } writeStreamStatus(conn, hub.setStream(sub, *cmd.Enable, cmd.IntervalMs)) case "get_stream": writeStreamStatus(conn, hub.getStream(sub)) case "set_accel_stream": if cmd.ClientID == 0 { writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"}) return } if cmd.Enable == nil { writeAccelStreamStatus(conn, accelStreamAPIResponse{ ClientID: cmd.ClientID, Error: "enable required", }) return } writeAccelStreamStatus(conn, applyAccelStreamClient(link, dash, ctl, cmd.ClientID, *cmd.Enable)) case "get_accel_stream": if cmd.ClientID == 0 { writeAccelStreamStatus(conn, accelStreamAPIResponse{Error: "client_id required"}) return } resp, err := link.AccelStreamPoll(&pb.AccelStreamRequest{ Write: false, ClientId: cmd.ClientID, }) if err != nil { writeAccelStreamStatus(conn, accelStreamAPIResponse{ ClientID: cmd.ClientID, Error: err.Error(), }) return } if ctl != nil { ctl.Set(cmd.ClientID, resp.GetEnabled()) } writeAccelStreamStatus(conn, accelStreamAPIResponse{ Enabled: resp.GetEnabled(), ClientID: resp.GetClientId(), Success: resp.GetSuccess(), }) case "set_tap_stream": if cmd.Enable == nil { writeTapStreamStatus(conn, TapStreamStatusMessage{ Type: "tap_stream_status", Error: "enable required", }) return } writeTapStreamStatus(conn, hub.setTapStream(sub, *cmd.Enable, cmd.IntervalMs)) case "get_tap_stream": writeTapStreamStatus(conn, hub.getTapStream(sub)) case "set_tap_notify": if cmd.AllClients { if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil { writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "single, double_tap, triple required"}) return } updated, err := applyTapNotifyAll(link, dash, tapCtl, *cmd.Single, *cmd.DoubleTap, *cmd.Triple) if err != nil { writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: err.Error()}) return } writeTapNotifyStatus(conn, tapNotifyAPIResponse{ Success: updated > 0, SlavesUpdated: updated, Single: *cmd.Single, DoubleTap: *cmd.DoubleTap, Triple: *cmd.Triple, }) return } if cmd.ClientID == 0 { writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"}) return } if cmd.Single == nil || cmd.DoubleTap == nil || cmd.Triple == nil { writeTapNotifyStatus(conn, tapNotifyAPIResponse{ ClientID: cmd.ClientID, Error: "single, double_tap, triple required", }) return } writeTapNotifyStatus(conn, applyTapNotifyClientWS(link, dash, tapCtl, cmd.ClientID, *cmd.Single, *cmd.DoubleTap, *cmd.Triple)) case "get_tap_notify": if cmd.ClientID == 0 { writeTapNotifyStatus(conn, tapNotifyAPIResponse{Error: "client_id required"}) return } resp, err := link.TapNotifyPoll(&pb.TapNotifyRequest{ Write: false, ClientId: cmd.ClientID, }) if err != nil { writeTapNotifyStatus(conn, tapNotifyAPIResponse{ ClientID: cmd.ClientID, Error: err.Error(), }) return } if tapCtl != nil { tapCtl.Set(cmd.ClientID, resp.GetSingle(), resp.GetDoubleTap(), resp.GetTriple()) } writeTapNotifyStatus(conn, tapNotifyAPIResponse{ ClientID: cmd.ClientID, Success: resp.GetSuccess(), Single: resp.GetSingle(), DoubleTap: resp.GetDoubleTap(), Triple: resp.GetTriple(), }) case "set_led_ring": var body ledRingAPIRequest if err := json.Unmarshal(data, &body); err != nil { writeLedRingStatus(conn, ledRingAPIResponse{Error: "invalid JSON"}) return } if body.Mode == "" { writeLedRingStatus(conn, ledRingAPIResponse{Error: "mode required"}) return } writeLedRingStatus(conn, applyLedRing(link, body)) case "get_battery": var body batteryAPIRequest if err := json.Unmarshal(data, &body); err != nil { writeBatteryStatus(conn, batteryAPIResponse{Error: "invalid JSON"}) return } if !body.AllClients && body.ClientID == 0 { body.AllClients = true } writeBatteryStatus(conn, applyBatteryStatus(link, body)) default: writeStreamStatus(conn, StreamStatusMessage{ Type: "stream_status", Error: "unknown type (set_stream, get_stream, set_accel_stream, get_accel_stream, set_tap_stream, get_tap_stream, set_tap_notify, get_tap_notify, set_led_ring, get_battery)", }) } } func serveExternalWS(conn *websocket.Conn, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, portName string, hub *accelStreamHub) { sub := hub.register(conn, portName) defer hub.unregister(conn) defer conn.Close() for { _, data, err := conn.ReadMessage() if err != nil { return } handleAccelWSCommand(conn, sub, data, link, dash, ctl, tapCtl, hub) } } func mountExternalAPI(mux *http.ServeMux, portName string, defaultInterval time.Duration, hub *accelStreamHub, link *managedSerial, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl) { defMs := int(defaultInterval / time.Millisecond) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/api/v1" && r.URL.Path != "/api/v1/" { http.NotFound(w, r) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } writeJSON(w, http.StatusOK, APIInfoResponse{ Name: "powerpod-external-api", Version: "1", SerialPort: portName, WebSocket: "/ws", DefaultIntervalMs: defMs, MinIntervalMs: int(minAPIStreamInterval / time.Millisecond), MaxIntervalMs: int(maxAPIStreamInterval / time.Millisecond), TapDisplayMinMs: apiTapDisplayMinMs, Description: "WebSocket: set_accel_stream + set_stream for accel; set_tap_notify (slave S/D/T) then set_tap_stream for tap events (shown ≥2s)", }) }) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { log.Printf("api websocket upgrade: %v", err) return } serveExternalWS(conn, link, dash, ctl, tapCtl, portName, hub) }) } func runAPIServer(portName string, link *managedSerial, addr string, defaultInterval time.Duration, dash *wsHub, ctl *accelStreamCtl, tapCtl *tapNotifyCtl, stop <-chan struct{}) *http.Server { hub := newAccelStreamHub(defaultInterval) go runAccelStreamer(link, hub, dash, ctl, tapCtl, stop) mux := http.NewServeMux() mountExternalAPI(mux, portName, defaultInterval, hub, link, dash, ctl, tapCtl) mountLedRingAPI(mux, link) mountBatteryAPI(mux, link) srv := &http.Server{Addr: addr, Handler: mux} go func() { log.Printf("external API http://localhost%s WebSocket ws://localhost%s/ws (default stream interval %s, per-client via set_stream / set_tap_stream)", addr, addr, defaultInterval.String()) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Printf("external API server: %v", err) } }() return srv } func shutdownAPIServer(srv *http.Server) { if srv == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = srv.Shutdown(ctx) }