// Package shelly provides HTTP communication shared by Shelly product implementations. package shelly import ( "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) const maxErrorBodySize = 4 * 1024 // StatusClient is consumed by product collectors and can be replaced in tests. type StatusClient interface { GetStatus(context.Context, any) error } // Client fetches status data from one Shelly device. type Client struct { baseURL *url.URL username string password string httpClient *http.Client } // NewClient creates a client that uses HTTP Basic Authentication when credentials are set. func NewClient(rawURL, username, password string, timeout time.Duration) (*Client, error) { baseURL, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("parse Shelly URL: %w", err) } return &Client{ baseURL: baseURL, username: username, password: password, httpClient: &http.Client{ Timeout: timeout, Transport: insecureTLSTransport(), }, }, nil } // insecureTLSTransport retains the standard HTTP transport behavior but accepts // certificates that are expired, self-signed, or issued for another hostname. func insecureTLSTransport() *http.Transport { transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, // #nosec G402 -- Shelly device certificates are intentionally not verified. } return transport } // GetStatus decodes the /status response into destination. func (c *Client) GetStatus(ctx context.Context, destination any) error { statusURL := *c.baseURL statusURL.Path = strings.TrimRight(statusURL.Path, "/") + "/status" statusURL.RawQuery = "" statusURL.Fragment = "" request, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL.String(), nil) if err != nil { return fmt.Errorf("create status request: %w", err) } request.Header.Set("Accept", "application/json") request.Header.Set("User-Agent", "shelly-exporter") if c.username != "" || c.password != "" { request.SetBasicAuth(c.username, c.password) } response, err := c.httpClient.Do(request) if err != nil { return fmt.Errorf("request %s: %w", statusURL.String(), err) } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { body, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorBodySize)) message := strings.TrimSpace(string(body)) if message == "" { return fmt.Errorf("request %s returned %s", statusURL.String(), response.Status) } return fmt.Errorf("request %s returned %s: %s", statusURL.String(), response.Status, message) } if err := json.NewDecoder(response.Body).Decode(destination); err != nil { return fmt.Errorf("decode response from %s: %w", statusURL.String(), err) } return nil }