package agentapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" ) // ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ── // // The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409 // with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household // WHY (a backup runs, too soon after start, already asked…), so this call reads it. // Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go). const ( PowerOffTooSoon = "too_soon_after_boot" PowerOffAlreadyAsked = "already_requested_this_boot" PowerOffBackup = "backup_running" PowerOffRestoreTest = "restore_test_running" PowerOffOSUpdate = "os_update_running" PowerOffCtrlUpdate = "controller_update_running" PowerOffDrive = "drive_operation_running" PowerOffEscrow = "escrow_ceremony_running" PowerOffBusy = "host_busy" PowerOffOtherGuests = "other_guests_running" PowerOffUnknown = "state_unknown" ) // PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log. type PowerOffRefused struct { Reason string Detail string } func (e *PowerOffRefused) Error() string { return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")" } // HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down // within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that // predates the route. func (c *Client) HostPowerOff(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}"))) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") start := time.Now() resp, err := c.hc.Do(req) if err != nil { return fmt.Errorf("agentapi: POST /host/poweroff: %w", err) } defer resp.Body.Close() c.noteAgentVersion(resp) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds()) switch resp.StatusCode { case http.StatusAccepted, http.StatusOK: return nil case http.StatusConflict: var env struct { Data struct { Reason string `json:"reason"` Detail string `json:"detail"` } `json:"data"` } if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" { return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"} } return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail} default: return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode} } } // IsPowerOffRefused returns the refusal, if err is one. func IsPowerOffRefused(err error) (*PowerOffRefused, bool) { var r *PowerOffRefused ok := errors.As(err, &r) return r, ok }